crossmate

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

GameStoreUnseenMovesTests.swift (13028B)


      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", .serialized)
     11 @MainActor
     12 struct GameStoreUnseenMovesTests {
     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.lastSeenOtherMoveAt == nil)
    102         #expect(summary.hasUnseenOtherMoves)
    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.hasUnseenOtherMoves)
    126     }
    127 
    128     @Test("Opening a game advances lastSeenOtherMoveAt 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.lastSeenOtherMoveAt == latest)
    141         let summary = try #require(GameSummary(entity: entity))
    142         #expect(!summary.hasUnseenOtherMoves)
    143     }
    144 
    145     @Test("unseenOtherMovesGameCount tallies shared games with pending other-author moves")
    146     func unseenOtherMovesGameCountAcrossGames() 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 lastSeen catches up to latest.
    166         let (gameB, gameBID) = try makeSharedGame(in: ctx)
    167         let seenLatest = Date(timeIntervalSinceNow: -30)
    168         gameB.latestOtherMoveAt = seenLatest
    169         gameB.lastSeenOtherMoveAt = seenLatest
    170         try ctx.save()
    171 
    172         #expect(store.unseenOtherMovesGameCount() == 1)
    173 
    174         // Opening the unseen game advances lastSeen and clears the badge tally.
    175         _ = try store.loadGame(id: gameAID)
    176         #expect(store.unseenOtherMovesGameCount() == 0)
    177     }
    178 
    179     @Test("Inbound moves while the puzzle is visible advance lastSeenOtherMoveAt")
    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.lastSeenOtherMoveAt == updatedAt)
    202         let summary = try #require(GameSummary(entity: entity))
    203         #expect(!summary.hasUnseenOtherMoves)
    204     }
    205 
    206     @Test("Inbound moves after backing out of a puzzle still mark it unseen")
    207     func inboundMovesAfterBackOutMarkUnseen() throws {
    208         let persistence = makeTestPersistence()
    209         let store = makeTestStore(persistence: persistence)
    210         let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
    211         // Simulate a prior open: `currentEntity` is set inside the store and
    212         // `lastSeenOtherMoveAt` is up-to-date with no pending moves. The user
    213         // then backs out — `NotificationState.activePuzzleID` clears, but the
    214         // store's `currentEntity` deliberately stays put.
    215         _ = try store.loadGame(id: gameID)
    216         NotificationState.setActivePuzzleID(nil)
    217 
    218         let updatedAt = Date()
    219         try addMovesRow(
    220             for: entity,
    221             gameID: gameID,
    222             authorID: Self.otherAuthorID,
    223             updatedAt: updatedAt,
    224             in: persistence.viewContext
    225         )
    226 
    227         store.noteIncomingMovesUpdate(
    228             gameIDs: [gameID],
    229             currentAuthorID: Self.localAuthorID
    230         )
    231 
    232         #expect(entity.latestOtherMoveAt == updatedAt)
    233         #expect(entity.lastSeenOtherMoveAt == nil)
    234         let summary = try #require(GameSummary(entity: entity))
    235         #expect(summary.hasUnseenOtherMoves)
    236     }
    237 
    238     @Test("Inbound moves within the leave grace after backing out stay seen")
    239     func inboundMovesWithinLeaveGraceStaySeen() throws {
    240         let persistence = makeTestPersistence()
    241         let store = makeTestStore(persistence: persistence)
    242         let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
    243         _ = try store.loadGame(id: gameID)
    244 
    245         // Simulate the real open → back-out path: the view sets the active
    246         // puzzle on appear and clears it on `.onDisappear`, which now opens a
    247         // short grace window rather than dropping the active state instantly.
    248         NotificationState.setActivePuzzleID(gameID)
    249         NotificationState.clearActivePuzzleID(if: gameID)
    250         defer { NotificationState.setActivePuzzleID(nil) }
    251 
    252         let updatedAt = Date()
    253         try addMovesRow(
    254             for: entity,
    255             gameID: gameID,
    256             authorID: Self.otherAuthorID,
    257             updatedAt: updatedAt,
    258             in: persistence.viewContext
    259         )
    260 
    261         // An inbound batch (or back-out catch-up) that finishes processing a
    262         // beat after the view disappeared is still treated as seen — the user
    263         // watched these moves arrive while the grid was on screen.
    264         store.noteIncomingMovesUpdate(
    265             gameIDs: [gameID],
    266             currentAuthorID: Self.localAuthorID
    267         )
    268 
    269         #expect(entity.lastSeenOtherMoveAt == updatedAt)
    270         let summary = try #require(GameSummary(entity: entity))
    271         #expect(!summary.hasUnseenOtherMoves)
    272     }
    273 
    274     @Test("A sibling device's open lease keeps inbound moves seen here")
    275     func remoteLeaseKeepsInboundMovesSeen() throws {
    276         let persistence = makeTestPersistence()
    277         let store = makeTestStore(persistence: persistence)
    278         let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
    279         _ = try store.loadGame(id: gameID)
    280 
    281         // Not viewing here, but a sibling device of the same user holds an
    282         // open lease — the user has eyes on the puzzle there.
    283         NotificationState.setActivePuzzleID(nil)
    284         let sentMs = Int64(Date().timeIntervalSince1970 * 1000)
    285         NotificationState.noteRemoteLease(
    286             gameID: gameID,
    287             until: Date().addingTimeInterval(NotificationState.openLeaseDuration),
    288             sentAtMs: sentMs
    289         )
    290         defer {
    291             NotificationState.noteRemoteLease(
    292                 gameID: gameID,
    293                 until: Date().addingTimeInterval(-1),
    294                 sentAtMs: sentMs + 1
    295             )
    296         }
    297 
    298         let updatedAt = Date()
    299         try addMovesRow(
    300             for: entity,
    301             gameID: gameID,
    302             authorID: Self.otherAuthorID,
    303             updatedAt: updatedAt,
    304             in: persistence.viewContext
    305         )
    306 
    307         store.noteIncomingMovesUpdate(
    308             gameIDs: [gameID],
    309             currentAuthorID: Self.localAuthorID
    310         )
    311 
    312         #expect(entity.lastSeenOtherMoveAt == updatedAt)
    313         let summary = try #require(GameSummary(entity: entity))
    314         #expect(!summary.hasUnseenOtherMoves)
    315     }
    316 
    317     @Test("Completed shared games do not show as unseen even with later other-author moves")
    318     func completedSharedGameSuppressesUnseen() throws {
    319         let persistence = makeTestPersistence()
    320         let store = makeTestStore(persistence: persistence)
    321         let ctx = persistence.viewContext
    322 
    323         let (entity, gameID) = try makeSharedGame(in: ctx)
    324         entity.completedAt = Date(timeIntervalSinceNow: -100)
    325         try ctx.save()
    326 
    327         try addMovesRow(
    328             for: entity,
    329             gameID: gameID,
    330             authorID: Self.otherAuthorID,
    331             updatedAt: Date(),
    332             in: ctx
    333         )
    334 
    335         store.noteIncomingMovesUpdate(
    336             gameIDs: [gameID],
    337             currentAuthorID: Self.localAuthorID
    338         )
    339 
    340         let summary = try #require(GameSummary(entity: entity))
    341         #expect(!summary.hasUnseenOtherMoves)
    342         #expect(store.unseenOtherMovesGameCount() == 0)
    343     }
    344 
    345     @Test("Opening a stale CmVer game reparses source and records current CmVer")
    346     func openingStaleCmVerGameReparsesSource() throws {
    347         let persistence = makeTestPersistence()
    348         let store = makeTestStore(persistence: persistence)
    349         let ctx = persistence.viewContext
    350         let (entity, _) = try makeSharedGame(in: ctx)
    351         entity.puzzleCmVersion = 0
    352         entity.gridWidth = 0
    353         entity.gridHeight = 0
    354         entity.blockMask = nil
    355         try ctx.save()
    356 
    357         _ = try store.loadGame(id: entity.id!)
    358 
    359         #expect(entity.puzzleCmVersion == Int64(XD.currentCmVersion))
    360         #expect(entity.gridWidth == 3)
    361         #expect(entity.gridHeight == 3)
    362         #expect(entity.blockMask?.count == 9)
    363     }
    364 }