crossmate

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

GameStoreUnreadMovesTests.swift (21496B)


      1 import CoreData
      2 import Foundation
      3 import Testing
      4 
      5 @testable import Crossmate
      6 
      7 /// Pins down the Date-based unread-badge heuristic on `GameStore`. A shared
      8 /// game gains an unread badge when another author's `MovesEntity` row has a
      9 /// later `updatedAt` than the local user's last open.
     10 @Suite("GameStore unread badge", .isolatedNotificationState)
     11 @MainActor
     12 struct GameStoreUnreadMovesTests {
     13 
     14     private static let localAuthorID = "local-author"
     15     private static let otherAuthorID = "other-author"
     16 
     17     private static let sharedPuzzleSource = """
     18     Title: Test Puzzle
     19     Author: Test
     20 
     21 
     22     ABC
     23     D#E
     24     FGH
     25 
     26 
     27     A1. Across 1 ~ ABC
     28     A4. Across 4 ~ DE
     29     A5. Across 5 ~ FGH
     30     D1. Down 1 ~ ADF
     31     D2. Down 2 ~ BG
     32     D3. Down 3 ~ CEH
     33     """
     34 
     35     private func makeSharedGame(
     36         in ctx: NSManagedObjectContext
     37     ) throws -> (GameEntity, UUID) {
     38         let gameID = UUID()
     39         let xd = try XD.parse(Self.sharedPuzzleSource)
     40         let puzzle = Puzzle(xd: xd)
     41 
     42         let entity = GameEntity(context: ctx)
     43         entity.id = gameID
     44         entity.title = "Shared"
     45         entity.puzzleSource = Self.sharedPuzzleSource
     46         entity.createdAt = Date()
     47         entity.updatedAt = Date()
     48         entity.ckRecordName = "game-\(gameID.uuidString)"
     49         entity.ckZoneName = "game-\(gameID.uuidString)"
     50         entity.ckZoneOwnerName = "_someOtherUser"
     51         entity.databaseScope = 1
     52         // Pre-populate the cached summary fields so `GameSummary.init?` takes
     53         // the fast path and doesn't have to re-parse XD.
     54         entity.populateCachedSummaryFields(from: puzzle)
     55         try ctx.save()
     56         return (entity, gameID)
     57     }
     58 
     59     private func addMovesRow(
     60         for entity: GameEntity,
     61         gameID: UUID,
     62         authorID: String,
     63         updatedAt: Date,
     64         in ctx: NSManagedObjectContext
     65     ) throws {
     66         let row = MovesEntity(context: ctx)
     67         row.game = entity
     68         row.authorID = authorID
     69         row.deviceID = "test-\(authorID)"
     70         row.cells = Data()
     71         row.updatedAt = updatedAt
     72         row.ckRecordName = RecordSerializer.recordName(
     73             forMovesInGame: gameID,
     74             authorID: authorID,
     75             deviceID: "test-\(authorID)"
     76         )
     77         try ctx.save()
     78     }
     79 
     80     @Test("Other-author Moves update marks the shared game unread")
     81     func otherAuthorMoveMarksSharedGameUnread() throws {
     82         let persistence = makeTestPersistence()
     83         let store = makeTestStore(persistence: persistence)
     84         let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
     85         let updatedAt = Date(timeIntervalSinceNow: -10)
     86         try addMovesRow(
     87             for: entity,
     88             gameID: gameID,
     89             authorID: Self.otherAuthorID,
     90             updatedAt: updatedAt,
     91             in: persistence.viewContext
     92         )
     93 
     94         store.noteIncomingMovesUpdate(
     95             gameIDs: [gameID],
     96             currentAuthorID: Self.localAuthorID
     97         )
     98 
     99         let summary = try #require(GameSummary(entity: entity))
    100         #expect(entity.latestOtherMoveAt == updatedAt)
    101         #expect(entity.lastReadOtherMoveAt == nil)
    102         #expect(summary.hasUnreadOtherMoves)
    103     }
    104 
    105     @Test("Own Moves update does not mark the shared game unread")
    106     func ownMoveDoesNotMarkSharedGameUnread() throws {
    107         let persistence = makeTestPersistence()
    108         let store = makeTestStore(persistence: persistence)
    109         let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
    110         try addMovesRow(
    111             for: entity,
    112             gameID: gameID,
    113             authorID: Self.localAuthorID,
    114             updatedAt: Date(),
    115             in: persistence.viewContext
    116         )
    117 
    118         store.noteIncomingMovesUpdate(
    119             gameIDs: [gameID],
    120             currentAuthorID: Self.localAuthorID
    121         )
    122 
    123         let summary = try #require(GameSummary(entity: entity))
    124         #expect(entity.latestOtherMoveAt == nil)
    125         #expect(!summary.hasUnreadOtherMoves)
    126     }
    127 
    128     @Test("Opening a game advances readThroughAt to latestOtherMoveAt")
    129     func openingGameMarksOtherMovesSeen() throws {
    130         let persistence = makeTestPersistence()
    131         let store = makeTestStore(persistence: persistence)
    132         let ctx = persistence.viewContext
    133         let (entity, _) = try makeSharedGame(in: ctx)
    134         let latest = Date(timeIntervalSinceNow: -10)
    135         entity.latestOtherMoveAt = latest
    136         try ctx.save()
    137 
    138         _ = try store.loadGame(id: entity.id!)
    139 
    140         #expect(entity.readThroughAt == latest)
    141         let summary = try #require(GameSummary(entity: entity))
    142         #expect(!summary.hasUnreadOtherMoves)
    143     }
    144 
    145     @Test("unreadOtherMovesGameCount tallies shared games with pending other-author moves")
    146     func unreadOtherMovesGameCountAcrossGames() throws {
    147         let persistence = makeTestPersistence()
    148         let store = makeTestStore(persistence: persistence)
    149         let ctx = persistence.viewContext
    150 
    151         // Unseen: shared game with other-author moves and no lastSeen.
    152         let (gameA, gameAID) = try makeSharedGame(in: ctx)
    153         try addMovesRow(
    154             for: gameA,
    155             gameID: gameAID,
    156             authorID: Self.otherAuthorID,
    157             updatedAt: Date(timeIntervalSinceNow: -20),
    158             in: ctx
    159         )
    160         store.noteIncomingMovesUpdate(
    161             gameIDs: [gameAID],
    162             currentAuthorID: Self.localAuthorID
    163         )
    164 
    165         // Seen: shared game whose watermark catches up to latest.
    166         let (gameB, _) = try makeSharedGame(in: ctx)
    167         let seenLatest = Date(timeIntervalSinceNow: -30)
    168         gameB.latestOtherMoveAt = seenLatest
    169         gameB.readThroughAt = seenLatest
    170         try ctx.save()
    171 
    172         #expect(store.unreadOtherMovesGameCount() == 1)
    173 
    174         // Opening the unseen game advances lastSeen and clears the badge tally.
    175         _ = try store.loadGame(id: gameAID)
    176         #expect(store.unreadOtherMovesGameCount() == 0)
    177     }
    178 
    179     @Test("Inbound moves while the puzzle is visible advance readThroughAt")
    180     func inboundMovesWhilePuzzleVisibleMarkSeen() throws {
    181         let persistence = makeTestPersistence()
    182         let store = makeTestStore(persistence: persistence)
    183         let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
    184         let updatedAt = Date(timeIntervalSinceNow: -10)
    185         try addMovesRow(
    186             for: entity,
    187             gameID: gameID,
    188             authorID: Self.otherAuthorID,
    189             updatedAt: updatedAt,
    190             in: persistence.viewContext
    191         )
    192 
    193         NotificationState.setActivePuzzleID(gameID)
    194         defer { NotificationState.setActivePuzzleID(nil) }
    195 
    196         store.noteIncomingMovesUpdate(
    197             gameIDs: [gameID],
    198             currentAuthorID: Self.localAuthorID
    199         )
    200 
    201         #expect(entity.readThroughAt == updatedAt)
    202         let summary = try #require(GameSummary(entity: entity))
    203         #expect(!summary.hasUnreadOtherMoves)
    204     }
    205 
    206     @Test("Inbound moves while visible do not shorten an active read lease")
    207     func inboundMovesWhileVisiblePreserveFutureLease() throws {
    208         let persistence = makeTestPersistence()
    209         let store = makeTestStore(persistence: persistence)
    210         let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
    211         let lease = Date(timeIntervalSinceNow: 10 * 60)
    212         entity.lastReadOtherMoveAt = lease
    213         try persistence.viewContext.save()
    214 
    215         let updatedAt = Date(timeIntervalSinceNow: -10)
    216         try addMovesRow(
    217             for: entity,
    218             gameID: gameID,
    219             authorID: Self.otherAuthorID,
    220             updatedAt: updatedAt,
    221             in: persistence.viewContext
    222         )
    223 
    224         NotificationState.setActivePuzzleID(gameID)
    225         defer { NotificationState.setActivePuzzleID(nil) }
    226 
    227         store.noteIncomingMovesUpdate(
    228             gameIDs: [gameID],
    229             currentAuthorID: Self.localAuthorID
    230         )
    231 
    232         #expect(entity.latestOtherMoveAt == updatedAt)
    233         #expect(entity.lastReadOtherMoveAt == lease)
    234         let summary = try #require(GameSummary(entity: entity))
    235         #expect(!summary.hasUnreadOtherMoves)
    236     }
    237 
    238     @Test("Inbound moves after backing out of a puzzle still mark it unseen")
    239     func inboundMovesAfterBackOutMarkUnseen() throws {
    240         let persistence = makeTestPersistence()
    241         let store = makeTestStore(persistence: persistence)
    242         let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
    243         // Simulate a prior open: `currentEntity` is set inside the store and
    244         // `lastReadOtherMoveAt` is up-to-date with no pending moves. The user
    245         // then backs out — `NotificationState.activePuzzleID` clears, but the
    246         // store's `currentEntity` deliberately stays put.
    247         _ = try store.loadGame(id: gameID)
    248         NotificationState.setActivePuzzleID(nil)
    249 
    250         let updatedAt = Date()
    251         try addMovesRow(
    252             for: entity,
    253             gameID: gameID,
    254             authorID: Self.otherAuthorID,
    255             updatedAt: updatedAt,
    256             in: persistence.viewContext
    257         )
    258 
    259         store.noteIncomingMovesUpdate(
    260             gameIDs: [gameID],
    261             currentAuthorID: Self.localAuthorID
    262         )
    263 
    264         #expect(entity.latestOtherMoveAt == updatedAt)
    265         #expect(entity.lastReadOtherMoveAt == nil)
    266         let summary = try #require(GameSummary(entity: entity))
    267         #expect(summary.hasUnreadOtherMoves)
    268     }
    269 
    270     @Test("Inbound moves within the leave grace after backing out stay seen")
    271     func inboundMovesWithinLeaveGraceStaySeen() throws {
    272         let persistence = makeTestPersistence()
    273         let store = makeTestStore(persistence: persistence)
    274         let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
    275         _ = try store.loadGame(id: gameID)
    276 
    277         // Simulate the real open → back-out path: the view sets the active
    278         // puzzle on appear and clears it on `.onDisappear`, which now opens a
    279         // short grace window rather than dropping the active state instantly.
    280         NotificationState.setActivePuzzleID(gameID)
    281         NotificationState.clearActivePuzzleID(if: gameID)
    282         defer { NotificationState.setActivePuzzleID(nil) }
    283 
    284         let updatedAt = Date()
    285         try addMovesRow(
    286             for: entity,
    287             gameID: gameID,
    288             authorID: Self.otherAuthorID,
    289             updatedAt: updatedAt,
    290             in: persistence.viewContext
    291         )
    292 
    293         // An inbound batch (or back-out catch-up) that finishes processing a
    294         // beat after the view disappeared is still treated as seen — the user
    295         // watched these moves arrive while the grid was on screen.
    296         store.noteIncomingMovesUpdate(
    297             gameIDs: [gameID],
    298             currentAuthorID: Self.localAuthorID
    299         )
    300 
    301         #expect(entity.readThroughAt == updatedAt)
    302         let summary = try #require(GameSummary(entity: entity))
    303         #expect(!summary.hasUnreadOtherMoves)
    304     }
    305 
    306     @Test("A sibling's presenceUntil presence lease is adopted last-writer-wins")
    307     func incomingReadCursorSetsBadgeHorizon() throws {
    308         let persistence = makeTestPersistence()
    309         let store = makeTestStore(
    310             persistence: persistence,
    311             authorIDProvider: { Self.localAuthorID }
    312         )
    313         let ctx = persistence.viewContext
    314         let (entity, gameID) = try makeSharedGame(in: ctx)
    315 
    316         let earlier = Date(timeIntervalSinceNow: -30)
    317         let later = Date(timeIntervalSinceNow: -10)
    318         let future = Date(timeIntervalSinceNow: 10 * 60)
    319 
    320         // All of an account's devices share one Player record, so the inbound
    321         // `presenceUntil` is the account's resolved *presence lease*: adopt it verbatim
    322         // under last-writer-wins. This is the presence horizon only; the unread
    323         // badge is driven by the read watermark (see the watermark test below).
    324         store.noteIncomingReadCursor(gameID: gameID, presenceUntil: earlier)
    325         #expect(entity.lastReadOtherMoveAt == earlier)
    326 
    327         store.noteIncomingReadCursor(gameID: gameID, presenceUntil: later)
    328         #expect(entity.lastReadOtherMoveAt == later)
    329 
    330         // A sibling opens an active session and leases the horizon into the
    331         // future; the lease is adopted verbatim.
    332         store.noteIncomingReadCursor(gameID: gameID, presenceUntil: future)
    333         #expect(entity.lastReadOtherMoveAt == future)
    334 
    335         // That sibling leaves and publishes the current time. Under
    336         // last-writer-wins the single shared scalar simply moves back to the
    337         // close value — there is no per-device lease here to protect it.
    338         store.noteIncomingReadCursor(gameID: gameID, presenceUntil: later)
    339         #expect(entity.lastReadOtherMoveAt == later)
    340     }
    341 
    342     @Test("The unread badge tracks the read watermark, not the presence lease")
    343     func badgeTracksWatermarkNotLease() throws {
    344         let persistence = makeTestPersistence()
    345         let store = makeTestStore(
    346             persistence: persistence,
    347             authorIDProvider: { Self.localAuthorID }
    348         )
    349         let ctx = persistence.viewContext
    350         let (entity, gameID) = try makeSharedGame(in: ctx)
    351 
    352         let earlier = Date(timeIntervalSinceNow: -30)
    353         let latest = Date(timeIntervalSinceNow: -10)
    354         let future = Date(timeIntervalSinceNow: 10 * 60)
    355         try addMovesRow(
    356             for: entity,
    357             gameID: gameID,
    358             authorID: Self.otherAuthorID,
    359             updatedAt: latest,
    360             in: ctx
    361         )
    362         store.noteIncomingMovesUpdate(gameIDs: [gameID], currentAuthorID: Self.localAuthorID)
    363         #expect(entity.readThroughAt == nil)
    364         #expect(store.unreadOtherMovesGameCount() == 1)
    365         #expect(store.hasUnreadOtherMoves(gameID: gameID))
    366 
    367         // A future presence lease must NOT clear the badge — this is the bug:
    368         // a leased-but-backgrounded reader had moves silently swallowed.
    369         #expect(store.setReadCursor(gameID: gameID, presenceUntil: future))
    370         #expect(entity.lastReadOtherMoveAt == future)
    371         #expect(store.unreadOtherMovesGameCount() == 1)
    372         #expect(store.hasUnreadOtherMoves(gameID: gameID))
    373 
    374         // The watermark, older than the latest move, still leaves it unread.
    375         #expect(store.advanceReadThrough(gameID: gameID, through: earlier))
    376         #expect(store.unreadOtherMovesGameCount() == 1)
    377         #expect(store.hasUnreadOtherMoves(gameID: gameID))
    378 
    379         // The watermark catching the latest move is what clears the badge.
    380         #expect(store.advanceReadThrough(gameID: gameID, through: latest))
    381         #expect(entity.readThroughAt == latest)
    382         #expect(store.unreadOtherMovesGameCount() == 0)
    383         #expect(!store.hasUnreadOtherMoves(gameID: gameID))
    384     }
    385 
    386     /// Checks the summary and the store predicate agree: both must key off the
    387     /// watermark alone, so a forward-dated presence lease cannot clear unread.
    388     @Test("Read watermark overrides a newer presence lease")
    389     func readWatermarkOverridesNewerPresenceLease() throws {
    390         let persistence = makeTestPersistence()
    391         let store = makeTestStore(persistence: persistence)
    392         let ctx = persistence.viewContext
    393         let (entity, gameID) = try makeSharedGame(in: ctx)
    394 
    395         let readThrough = Date(timeIntervalSinceNow: -120)
    396         let latest = Date(timeIntervalSinceNow: -60)
    397         entity.latestOtherMoveAt = latest
    398         entity.readThroughAt = readThrough
    399         entity.lastReadOtherMoveAt = Date(timeIntervalSinceNow: 10 * 60)
    400         try ctx.save()
    401 
    402         let summary = try #require(GameSummary(entity: entity))
    403         #expect(summary.hasUnreadOtherMoves)
    404         #expect(store.unreadOtherMovesGameCount() == 1)
    405         #expect(store.hasUnreadOtherMoves(gameID: gameID))
    406     }
    407 
    408     @Test("Active read leases refresh only when the horizon is below the floor")
    409     func activeReadLeaseRefreshesAtFloor() throws {
    410         let persistence = makeTestPersistence()
    411         let store = makeTestStore(persistence: persistence)
    412         let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
    413 
    414         let now = Date()
    415         let farEnough = now.addingTimeInterval(6 * 60)
    416         let floor = now.addingTimeInterval(5 * 60)
    417         let refreshed = now.addingTimeInterval(10 * 60)
    418 
    419         #expect(store.setReadCursor(gameID: gameID, presenceUntil: farEnough))
    420         #expect(!store.setReadCursor(
    421             gameID: gameID,
    422             presenceUntil: refreshed,
    423             minimumExistingPresenceUntil: floor
    424         ))
    425         #expect(entity.lastReadOtherMoveAt == farEnough)
    426 
    427         let tooClose = now.addingTimeInterval(4 * 60)
    428         #expect(store.setReadCursor(gameID: gameID, presenceUntil: tooClose))
    429         #expect(store.setReadCursor(
    430             gameID: gameID,
    431             presenceUntil: refreshed,
    432             minimumExistingPresenceUntil: floor
    433         ))
    434         #expect(entity.lastReadOtherMoveAt == refreshed)
    435     }
    436 
    437     @Test("Completed shared games show as unseen when a peer finished or resigned unseen")
    438     func completedSharedGameSurfacesUnseen() throws {
    439         let persistence = makeTestPersistence()
    440         let store = makeTestStore(persistence: persistence)
    441         let ctx = persistence.viewContext
    442 
    443         // A peer's win or resignation is itself an unseen event: the move that
    444         // finished the game lands as a later other-author move, so the finished
    445         // game should flag as unread until the user opens it to review.
    446         let (entity, gameID) = try makeSharedGame(in: ctx)
    447         entity.completedAt = Date(timeIntervalSinceNow: -100)
    448         try ctx.save()
    449 
    450         try addMovesRow(
    451             for: entity,
    452             gameID: gameID,
    453             authorID: Self.otherAuthorID,
    454             updatedAt: Date(),
    455             in: ctx
    456         )
    457 
    458         store.noteIncomingMovesUpdate(
    459             gameIDs: [gameID],
    460             currentAuthorID: Self.localAuthorID
    461         )
    462 
    463         let summary = try #require(GameSummary(entity: entity))
    464         #expect(summary.hasUnreadOtherMoves)
    465         #expect(store.unreadOtherMovesGameCount() == 1)
    466 
    467         // Reviewing the finished game advances the read watermark and clears it.
    468         store.advanceReadThrough(gameID: gameID, through: Date())
    469         let reviewed = try #require(GameSummary(entity: entity))
    470         #expect(!reviewed.hasUnreadOtherMoves)
    471         #expect(store.unreadOtherMovesGameCount() == 0)
    472     }
    473 
    474     @Test("Realtime cell edit updates the open game through the move merger")
    475     func realtimeCellEditUpdatesOpenGame() throws {
    476         let persistence = makeTestPersistence()
    477         let store = makeTestStore(
    478             persistence: persistence,
    479             authorIDProvider: { Self.localAuthorID }
    480         )
    481         let (_, gameID) = try makeSharedGame(in: persistence.viewContext)
    482         let (game, _) = try store.loadGame(id: gameID)
    483         let updatedAt = Date()
    484 
    485         let applied = store.applyRealtimeCellEdit(RealtimeCellEdit(
    486             gameID: gameID,
    487             authorID: Self.localAuthorID,
    488             deviceID: "remote-device",
    489             row: 0,
    490             col: 0,
    491             letter: "Q",
    492             mark: .none,
    493             updatedAt: updatedAt,
    494             cellAuthorID: Self.localAuthorID
    495         ))
    496 
    497         #expect(applied)
    498         #expect(game.squares[0][0].entry == "Q")
    499         #expect(game.squares[0][0].letterAuthorID == Self.localAuthorID)
    500     }
    501 
    502     @Test("Older realtime cell edit from the same device is ignored")
    503     func olderRealtimeCellEditIsIgnored() throws {
    504         let persistence = makeTestPersistence()
    505         let store = makeTestStore(
    506             persistence: persistence,
    507             authorIDProvider: { Self.localAuthorID }
    508         )
    509         let (_, gameID) = try makeSharedGame(in: persistence.viewContext)
    510         let (game, _) = try store.loadGame(id: gameID)
    511         let later = Date(timeIntervalSince1970: 200)
    512         let earlier = Date(timeIntervalSince1970: 100)
    513 
    514         #expect(store.applyRealtimeCellEdit(RealtimeCellEdit(
    515             gameID: gameID,
    516             authorID: Self.localAuthorID,
    517             deviceID: "remote-device",
    518             row: 0,
    519             col: 0,
    520             letter: "Q",
    521             mark: .none,
    522             updatedAt: later,
    523             cellAuthorID: Self.localAuthorID
    524         )))
    525         #expect(!store.applyRealtimeCellEdit(RealtimeCellEdit(
    526             gameID: gameID,
    527             authorID: Self.localAuthorID,
    528             deviceID: "remote-device",
    529             row: 0,
    530             col: 0,
    531             letter: "R",
    532             mark: .none,
    533             updatedAt: earlier,
    534             cellAuthorID: Self.localAuthorID
    535         )))
    536 
    537         #expect(game.squares[0][0].entry == "Q")
    538     }
    539 
    540     @Test("Opening a stale parser-version game reparses source and records current version")
    541     func openingStaleParserVersionGameReparsesSource() throws {
    542         let persistence = makeTestPersistence()
    543         let store = makeTestStore(persistence: persistence)
    544         let ctx = persistence.viewContext
    545         let (entity, _) = try makeSharedGame(in: ctx)
    546         entity.puzzleParserVersion = 0
    547         entity.gridWidth = 0
    548         entity.gridHeight = 0
    549         entity.blockMask = nil
    550         try ctx.save()
    551 
    552         _ = try store.loadGame(id: entity.id!)
    553 
    554         #expect(entity.puzzleParserVersion == Int64(XD.currentParserVersion))
    555         #expect(entity.gridWidth == 3)
    556         #expect(entity.gridHeight == 3)
    557         #expect(entity.blockMask?.count == 9)
    558     }
    559 }