crossmate

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

GameStoreUnreadMovesTests.swift (25110B)


      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 readAt 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         // `readAt` 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, readAt: earlier)
    325         #expect(entity.lastReadOtherMoveAt == earlier)
    326 
    327         store.noteIncomingReadCursor(gameID: gameID, readAt: 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, readAt: 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, readAt: 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, readAt: 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     @Test("Legacy read cursor backs pre-watermark rows")
    387     func legacyReadCursorBacksPreWatermarkRows() throws {
    388         let persistence = makeTestPersistence()
    389         let store = makeTestStore(persistence: persistence)
    390         let ctx = persistence.viewContext
    391         let (entity, gameID) = try makeSharedGame(in: ctx)
    392 
    393         let latest = Date(timeIntervalSinceNow: -60)
    394         entity.latestOtherMoveAt = latest
    395         entity.lastReadOtherMoveAt = latest
    396         entity.readThroughAt = nil
    397         try ctx.save()
    398 
    399         let summary = try #require(GameSummary(entity: entity))
    400         #expect(!summary.hasUnreadOtherMoves)
    401         #expect(store.unreadOtherMovesGameCount() == 0)
    402         #expect(!store.hasUnreadOtherMoves(gameID: gameID))
    403     }
    404 
    405     @Test("Read watermark overrides a newer legacy presence lease")
    406     func readWatermarkOverridesNewerLegacyPresenceLease() throws {
    407         let persistence = makeTestPersistence()
    408         let store = makeTestStore(persistence: persistence)
    409         let ctx = persistence.viewContext
    410         let (entity, gameID) = try makeSharedGame(in: ctx)
    411 
    412         let readThrough = Date(timeIntervalSinceNow: -120)
    413         let latest = Date(timeIntervalSinceNow: -60)
    414         entity.latestOtherMoveAt = latest
    415         entity.readThroughAt = readThrough
    416         entity.lastReadOtherMoveAt = Date(timeIntervalSinceNow: 10 * 60)
    417         try ctx.save()
    418 
    419         let summary = try #require(GameSummary(entity: entity))
    420         #expect(summary.hasUnreadOtherMoves)
    421         #expect(store.unreadOtherMovesGameCount() == 1)
    422         #expect(store.hasUnreadOtherMoves(gameID: gameID))
    423     }
    424 
    425     @Test("Legacy read-through backfill clears phantom pre-watermark unread rows")
    426     func legacyReadThroughBackfillClearsPhantomRows() throws {
    427         let persistence = makeTestPersistence()
    428         let store = makeTestStore(persistence: persistence)
    429         let ctx = persistence.viewContext
    430         let (entity, gameID) = try makeSharedGame(in: ctx)
    431 
    432         let latest = Date(timeIntervalSinceNow: -60)
    433         entity.latestOtherMoveAt = latest
    434         entity.readThroughAt = nil
    435         entity.lastReadOtherMoveAt = nil
    436         try ctx.save()
    437 
    438         #expect(store.unreadOtherMovesGameCount() == 1)
    439 
    440         #expect(store.backfillLegacyReadThrough(excluding: []) == 1)
    441         #expect(entity.readThroughAt == latest)
    442         #expect(store.unreadOtherMovesGameCount() == 0)
    443         #expect(!store.hasUnreadOtherMoves(gameID: gameID))
    444     }
    445 
    446     @Test("Legacy read-through backfill preserves delivered unread games")
    447     func legacyReadThroughBackfillPreservesDeliveredUnread() throws {
    448         let persistence = makeTestPersistence()
    449         let store = makeTestStore(persistence: persistence)
    450         let ctx = persistence.viewContext
    451         let (entity, gameID) = try makeSharedGame(in: ctx)
    452 
    453         entity.latestOtherMoveAt = Date(timeIntervalSinceNow: -60)
    454         entity.readThroughAt = nil
    455         try ctx.save()
    456 
    457         #expect(store.backfillLegacyReadThrough(excluding: [gameID]) == 0)
    458         #expect(entity.readThroughAt == nil)
    459         #expect(store.unreadOtherMovesGameCount() == 1)
    460     }
    461 
    462     @Test("Legacy read-through backfill advances stale watermarks")
    463     func legacyReadThroughBackfillAdvancesStaleWatermarks() throws {
    464         let persistence = makeTestPersistence()
    465         let store = makeTestStore(persistence: persistence)
    466         let ctx = persistence.viewContext
    467         let (entity, gameID) = try makeSharedGame(in: ctx)
    468 
    469         let readThrough = Date(timeIntervalSinceNow: -120)
    470         let latest = Date(timeIntervalSinceNow: -60)
    471         entity.latestOtherMoveAt = latest
    472         entity.readThroughAt = readThrough
    473         try ctx.save()
    474 
    475         #expect(store.unreadOtherMovesGameCount() == 1)
    476 
    477         #expect(store.backfillLegacyReadThrough(excluding: []) == 1)
    478         #expect(entity.readThroughAt == latest)
    479         #expect(store.unreadOtherMovesGameCount() == 0)
    480         #expect(!store.hasUnreadOtherMoves(gameID: gameID))
    481     }
    482 
    483     @Test("Legacy read-through backfill preserves delivered stale unread games")
    484     func legacyReadThroughBackfillPreservesDeliveredStaleUnread() throws {
    485         let persistence = makeTestPersistence()
    486         let store = makeTestStore(persistence: persistence)
    487         let ctx = persistence.viewContext
    488         let (entity, gameID) = try makeSharedGame(in: ctx)
    489 
    490         let readThrough = Date(timeIntervalSinceNow: -120)
    491         entity.latestOtherMoveAt = Date(timeIntervalSinceNow: -60)
    492         entity.readThroughAt = readThrough
    493         try ctx.save()
    494 
    495         #expect(store.backfillLegacyReadThrough(excluding: [gameID]) == 0)
    496         #expect(entity.readThroughAt == readThrough)
    497         #expect(store.unreadOtherMovesGameCount() == 1)
    498     }
    499 
    500     @Test("Active read leases refresh only when the horizon is below the floor")
    501     func activeReadLeaseRefreshesAtFloor() throws {
    502         let persistence = makeTestPersistence()
    503         let store = makeTestStore(persistence: persistence)
    504         let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
    505 
    506         let now = Date()
    507         let farEnough = now.addingTimeInterval(6 * 60)
    508         let floor = now.addingTimeInterval(5 * 60)
    509         let refreshed = now.addingTimeInterval(10 * 60)
    510 
    511         #expect(store.setReadCursor(gameID: gameID, readAt: farEnough))
    512         #expect(!store.setReadCursor(
    513             gameID: gameID,
    514             readAt: refreshed,
    515             minimumExistingReadAt: floor
    516         ))
    517         #expect(entity.lastReadOtherMoveAt == farEnough)
    518 
    519         let tooClose = now.addingTimeInterval(4 * 60)
    520         #expect(store.setReadCursor(gameID: gameID, readAt: tooClose))
    521         #expect(store.setReadCursor(
    522             gameID: gameID,
    523             readAt: refreshed,
    524             minimumExistingReadAt: floor
    525         ))
    526         #expect(entity.lastReadOtherMoveAt == refreshed)
    527     }
    528 
    529     @Test("Completed shared games show as unseen when a peer finished or resigned unseen")
    530     func completedSharedGameSurfacesUnseen() throws {
    531         let persistence = makeTestPersistence()
    532         let store = makeTestStore(persistence: persistence)
    533         let ctx = persistence.viewContext
    534 
    535         // A peer's win or resignation is itself an unseen event: the move that
    536         // finished the game lands as a later other-author move, so the finished
    537         // game should flag as unread until the user opens it to review.
    538         let (entity, gameID) = try makeSharedGame(in: ctx)
    539         entity.completedAt = Date(timeIntervalSinceNow: -100)
    540         try ctx.save()
    541 
    542         try addMovesRow(
    543             for: entity,
    544             gameID: gameID,
    545             authorID: Self.otherAuthorID,
    546             updatedAt: Date(),
    547             in: ctx
    548         )
    549 
    550         store.noteIncomingMovesUpdate(
    551             gameIDs: [gameID],
    552             currentAuthorID: Self.localAuthorID
    553         )
    554 
    555         let summary = try #require(GameSummary(entity: entity))
    556         #expect(summary.hasUnreadOtherMoves)
    557         #expect(store.unreadOtherMovesGameCount() == 1)
    558 
    559         // Reviewing the finished game advances the read watermark and clears it.
    560         store.advanceReadThrough(gameID: gameID, through: Date())
    561         let reviewed = try #require(GameSummary(entity: entity))
    562         #expect(!reviewed.hasUnreadOtherMoves)
    563         #expect(store.unreadOtherMovesGameCount() == 0)
    564     }
    565 
    566     @Test("Realtime cell edit updates the open game through the move merger")
    567     func realtimeCellEditUpdatesOpenGame() throws {
    568         let persistence = makeTestPersistence()
    569         let store = makeTestStore(
    570             persistence: persistence,
    571             authorIDProvider: { Self.localAuthorID }
    572         )
    573         let (_, gameID) = try makeSharedGame(in: persistence.viewContext)
    574         let (game, _) = try store.loadGame(id: gameID)
    575         let updatedAt = Date()
    576 
    577         let applied = store.applyRealtimeCellEdit(RealtimeCellEdit(
    578             gameID: gameID,
    579             authorID: Self.localAuthorID,
    580             deviceID: "remote-device",
    581             row: 0,
    582             col: 0,
    583             letter: "Q",
    584             mark: .none,
    585             updatedAt: updatedAt,
    586             cellAuthorID: Self.localAuthorID
    587         ))
    588 
    589         #expect(applied)
    590         #expect(game.squares[0][0].entry == "Q")
    591         #expect(game.squares[0][0].letterAuthorID == Self.localAuthorID)
    592     }
    593 
    594     @Test("Older realtime cell edit from the same device is ignored")
    595     func olderRealtimeCellEditIsIgnored() throws {
    596         let persistence = makeTestPersistence()
    597         let store = makeTestStore(
    598             persistence: persistence,
    599             authorIDProvider: { Self.localAuthorID }
    600         )
    601         let (_, gameID) = try makeSharedGame(in: persistence.viewContext)
    602         let (game, _) = try store.loadGame(id: gameID)
    603         let later = Date(timeIntervalSince1970: 200)
    604         let earlier = Date(timeIntervalSince1970: 100)
    605 
    606         #expect(store.applyRealtimeCellEdit(RealtimeCellEdit(
    607             gameID: gameID,
    608             authorID: Self.localAuthorID,
    609             deviceID: "remote-device",
    610             row: 0,
    611             col: 0,
    612             letter: "Q",
    613             mark: .none,
    614             updatedAt: later,
    615             cellAuthorID: Self.localAuthorID
    616         )))
    617         #expect(!store.applyRealtimeCellEdit(RealtimeCellEdit(
    618             gameID: gameID,
    619             authorID: Self.localAuthorID,
    620             deviceID: "remote-device",
    621             row: 0,
    622             col: 0,
    623             letter: "R",
    624             mark: .none,
    625             updatedAt: earlier,
    626             cellAuthorID: Self.localAuthorID
    627         )))
    628 
    629         #expect(game.squares[0][0].entry == "Q")
    630     }
    631 
    632     @Test("Opening a stale CmVer game reparses source and records current CmVer")
    633     func openingStaleCmVerGameReparsesSource() throws {
    634         let persistence = makeTestPersistence()
    635         let store = makeTestStore(persistence: persistence)
    636         let ctx = persistence.viewContext
    637         let (entity, _) = try makeSharedGame(in: ctx)
    638         entity.puzzleCmVersion = 0
    639         entity.gridWidth = 0
    640         entity.gridHeight = 0
    641         entity.blockMask = nil
    642         try ctx.save()
    643 
    644         _ = try store.loadGame(id: entity.id!)
    645 
    646         #expect(entity.puzzleCmVersion == Int64(XD.currentCmVersion))
    647         #expect(entity.gridWidth == 3)
    648         #expect(entity.gridHeight == 3)
    649         #expect(entity.blockMask?.count == 9)
    650     }
    651 }