crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

GameStoreLibraryDatesTests.swift (8782B)


      1 import CoreData
      2 import Foundation
      3 import Testing
      4 
      5 @testable import Crossmate
      6 
      7 /// Covers the query behind the External browser's "already in your library"
      8 /// calendar marker. A day is marked whenever the user holds that puzzle in any
      9 /// form — live row or Chronicle — and not on the strength of a game the block
     10 /// table has taken away from them.
     11 @Suite("GameStore library dates")
     12 @MainActor
     13 struct GameStoreLibraryDatesTests {
     14 
     15     private static func source(publisher: String, date: String) -> String {
     16         """
     17         Title: Test Puzzle
     18         Author: Test
     19         Publisher: \(publisher)
     20         Date: \(date)
     21 
     22 
     23         ABC
     24         D#E
     25         FGH
     26 
     27 
     28         A1. Across 1 ~ ABC
     29         A4. Across 4 ~ DE
     30         A5. Across 5 ~ FGH
     31         D1. Down 1 ~ ADF
     32         D2. Down 2 ~ BG
     33         D3. Down 3 ~ CEH
     34         """
     35     }
     36 
     37     /// The NY-midnight Date that `XD` parses a `YYYY-MM-DD` header into, which
     38     /// is also what the calendar grid produces for that day.
     39     private static func nytDay(_ year: Int, _ month: Int, _ day: Int) -> Date {
     40         var calendar = Calendar(identifier: .gregorian)
     41         calendar.timeZone = TimeZone(identifier: "America/New_York")!
     42         var comps = DateComponents()
     43         comps.year = year
     44         comps.month = month
     45         comps.day = day
     46         return calendar.date(from: comps)!
     47     }
     48 
     49     @discardableResult
     50     private func addGame(
     51         publisher: String = "New York Times",
     52         date: String,
     53         isHidden: Bool = false,
     54         in ctx: NSManagedObjectContext
     55     ) throws -> GameEntity {
     56         let source = Self.source(publisher: publisher, date: date)
     57         let puzzle = Puzzle(xd: try XD.parse(source))
     58 
     59         let gameID = UUID()
     60         let entity = GameEntity(context: ctx)
     61         entity.id = gameID
     62         entity.title = puzzle.title
     63         entity.puzzleSource = source
     64         entity.createdAt = Date()
     65         entity.updatedAt = Date()
     66         entity.ckRecordName = "game-\(gameID.uuidString)"
     67         entity.ckZoneName = "game-\(gameID.uuidString)"
     68         entity.isHidden = isHidden
     69         entity.populateCachedSummaryFields(from: puzzle)
     70         try ctx.save()
     71         return entity
     72     }
     73 
     74     @Test("Stored NYT dates match the calendar's day values")
     75     func returnsStoredNYTDates() throws {
     76         let persistence = makeTestPersistence()
     77         let store = makeTestStore(persistence: persistence)
     78         try addGame(date: "2020-01-01", in: persistence.viewContext)
     79         try addGame(date: "2024-07-04", in: persistence.viewContext)
     80 
     81         let dates = store.nytPuzzleDatesInLibrary()
     82 
     83         #expect(dates == [Self.nytDay(2020, 1, 1), Self.nytDay(2024, 7, 4)])
     84     }
     85 
     86     @Test("A game hidden by the block table is not reported as held")
     87     func excludesHiddenGames() throws {
     88         let persistence = makeTestPersistence()
     89         let store = makeTestStore(persistence: persistence)
     90         try addGame(date: "2020-01-01", isHidden: true, in: persistence.viewContext)
     91 
     92         #expect(store.nytPuzzleDatesInLibrary().isEmpty)
     93     }
     94 
     95     @Test("Puzzles from other publishers are excluded")
     96     func excludesOtherPublishers() throws {
     97         let persistence = makeTestPersistence()
     98         let store = makeTestStore(persistence: persistence)
     99         try addGame(publisher: "Crossmate", date: "2020-01-01", in: persistence.viewContext)
    100 
    101         #expect(store.nytPuzzleDatesInLibrary().isEmpty)
    102     }
    103 
    104     @Test("The same publication date held twice collapses to one entry")
    105     func deduplicatesRepeatedDates() throws {
    106         let persistence = makeTestPersistence()
    107         let store = makeTestStore(persistence: persistence)
    108         try addGame(date: "2020-01-01", in: persistence.viewContext)
    109         try addGame(date: "2020-01-01", in: persistence.viewContext)
    110 
    111         #expect(store.nytPuzzleDatesInLibrary() == [Self.nytDay(2020, 1, 1)])
    112     }
    113 
    114     // MARK: - Chronicle ledger
    115 
    116     @discardableResult
    117     private func addLedgerEntry(
    118         publisher: String = "New York Times",
    119         date: Date,
    120         participants: String? = nil,
    121         in ctx: NSManagedObjectContext
    122     ) throws -> UUID {
    123         let originalGameID = UUID()
    124         ChronicleLedgerEntity.upsert(
    125             originalGameID: originalGameID,
    126             publisher: publisher,
    127             puzzleDate: date,
    128             participants: participants,
    129             in: ctx
    130         )
    131         try ctx.save()
    132         return originalGameID
    133     }
    134 
    135     private func addBlockedFriend(authorID: String, in ctx: NSManagedObjectContext) throws {
    136         let friend = FriendEntity(context: ctx)
    137         friend.authorID = authorID
    138         friend.pairKey = "pair-\(authorID)"
    139         friend.createdAt = Date()
    140         friend.isBlocked = true
    141         try ctx.save()
    142     }
    143 
    144     @Test("A compacted puzzle with no local row is still reported as held")
    145     func reportsLedgerOnlyDates() throws {
    146         let persistence = makeTestPersistence()
    147         let store = makeTestStore(persistence: persistence)
    148         try addLedgerEntry(date: Self.nytDay(2026, 7, 14), in: persistence.viewContext)
    149 
    150         #expect(store.nytPuzzleDatesInLibrary() == [Self.nytDay(2026, 7, 14)])
    151     }
    152 
    153     @Test("A ledger entry for another publisher is excluded")
    154     func excludesLedgerEntriesFromOtherPublishers() throws {
    155         let persistence = makeTestPersistence()
    156         let store = makeTestStore(persistence: persistence)
    157         try addLedgerEntry(
    158             publisher: "Crossmate",
    159             date: Self.nytDay(2026, 7, 14),
    160             in: persistence.viewContext
    161         )
    162 
    163         #expect(store.nytPuzzleDatesInLibrary().isEmpty)
    164     }
    165 
    166     @Test("A ledger entry featuring a blocked collaborator is not reported as held")
    167     func excludesLedgerEntriesWithBlockedParticipants() throws {
    168         let persistence = makeTestPersistence()
    169         let store = makeTestStore(persistence: persistence)
    170         try addBlockedFriend(authorID: "_bob", in: persistence.viewContext)
    171         try addLedgerEntry(
    172             date: Self.nytDay(2026, 7, 14),
    173             participants: "_alice,_bob",
    174             in: persistence.viewContext
    175         )
    176 
    177         #expect(store.nytPuzzleDatesInLibrary().isEmpty)
    178     }
    179 
    180     @Test("A ledger entry whose collaborators are unblocked is reported as held")
    181     func includesLedgerEntriesWithUnblockedParticipants() throws {
    182         let persistence = makeTestPersistence()
    183         let store = makeTestStore(persistence: persistence)
    184         try addBlockedFriend(authorID: "_carol", in: persistence.viewContext)
    185         try addLedgerEntry(
    186             date: Self.nytDay(2026, 7, 14),
    187             participants: "_alice,_bob",
    188             in: persistence.viewContext
    189         )
    190 
    191         #expect(store.nytPuzzleDatesInLibrary() == [Self.nytDay(2026, 7, 14)])
    192     }
    193 
    194     @Test("A date held as both a live row and a ledger entry collapses to one")
    195     func deduplicatesAcrossRowAndLedger() throws {
    196         let persistence = makeTestPersistence()
    197         let store = makeTestStore(persistence: persistence)
    198         try addGame(date: "2026-07-14", in: persistence.viewContext)
    199         try addLedgerEntry(date: Self.nytDay(2026, 7, 14), in: persistence.viewContext)
    200 
    201         #expect(store.nytPuzzleDatesInLibrary() == [Self.nytDay(2026, 7, 14)])
    202     }
    203 
    204     @Test("Deleting an archived game drops its ledger entry")
    205     func deleteGamePrunesLedgerEntry() throws {
    206         let persistence = makeTestPersistence()
    207         let store = makeTestStore(persistence: persistence)
    208         let ctx = persistence.viewContext
    209         let game = try addGame(date: "2026-07-14", in: ctx)
    210         let gameID = try #require(game.id)
    211         // What compaction leaves behind: an archived live row whose publication
    212         // date is also recorded in the ledger.
    213         game.archivedAt = Date()
    214         ChronicleLedgerEntity.upsert(
    215             originalGameID: gameID,
    216             publisher: "New York Times",
    217             puzzleDate: Self.nytDay(2026, 7, 14),
    218             participants: nil,
    219             in: ctx
    220         )
    221         try ctx.save()
    222 
    223         try store.deleteGame(id: gameID)
    224 
    225         #expect(ChronicleLedgerEntity.knownOriginalGameIDs(in: ctx).isEmpty)
    226         #expect(store.nytPuzzleDatesInLibrary().isEmpty)
    227     }
    228 
    229     @Test("Reconciling against the archive zone drops entries whose Chronicle is gone")
    230     func retainOnlyDropsMissingChronicles() throws {
    231         let persistence = makeTestPersistence()
    232         let ctx = persistence.viewContext
    233         let kept = try addLedgerEntry(date: Self.nytDay(2026, 7, 14), in: ctx)
    234         try addLedgerEntry(date: Self.nytDay(2026, 7, 15), in: ctx)
    235 
    236         let removed = ChronicleLedgerEntity.retainOnly([kept], in: ctx)
    237         try ctx.save()
    238 
    239         #expect(removed == 1)
    240         #expect(ChronicleLedgerEntity.knownOriginalGameIDs(in: ctx) == [kept])
    241     }
    242 }