crossmate

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

GameMutatorTests.swift (21401B)


      1 import CoreData
      2 import Foundation
      3 import Testing
      4 
      5 @testable import Crossmate
      6 
      7 @Suite("GameMutator", .serialized)
      8 @MainActor
      9 struct GameMutatorTests {
     10 
     11     // MARK: - Basic mutations
     12 
     13     @Test("setLetter writes entry and mark to game")
     14     func setLetterWritesToGame() throws {
     15         let (game, mutator, _, _) = try makeTestGame()
     16 
     17         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
     18 
     19         #expect(game.squares[0][0].entry == "A")
     20         #expect(game.squares[0][0].mark == .none)
     21     }
     22 
     23     @Test("setLetter in pencil mode sets pencil mark")
     24     func setLetterPencilMode() throws {
     25         let (game, mutator, _, _) = try makeTestGame()
     26 
     27         mutator.setLetter("B", atRow: 0, atCol: 1, pencil: true)
     28 
     29         #expect(game.squares[0][1].entry == "B")
     30         #expect(game.squares[0][1].mark == .pencil(checked: nil))
     31     }
     32 
     33     @Test("clearLetter clears entry and mark")
     34     func clearLetterClearsEntry() throws {
     35         let (game, mutator, _, _) = try makeTestGame()
     36 
     37         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
     38         mutator.clearLetter(atRow: 0, atCol: 0)
     39 
     40         #expect(game.squares[0][0].entry == "")
     41         #expect(game.squares[0][0].mark == .none)
     42         #expect(game.squares[0][0].letterAuthorID == nil)
     43     }
     44 
     45     @Test("A version 2 edit advances the largest observed logical tick")
     46     func versionTwoEditAdvancesLogicalTick() async throws {
     47         let (game, capture, updater, persistence) = try makeMutatorWithUpdater(
     48             actingAuthorID: "bob"
     49         )
     50         let mutator = GameMutator(
     51             game: game,
     52             gameID: capture.gameID,
     53             movesUpdater: updater,
     54             authorIDProvider: { "bob" },
     55             syncVersion: GameSyncVersion.logicalTicks,
     56             initialLogicalTick: 2_000_000
     57         )
     58 
     59         mutator.setLetter("M", atRow: 0, atCol: 0, pencil: false)
     60         try await waitForEmittedGameID(
     61             capture.gameID,
     62             updater: updater,
     63             collector: capture.collector
     64         )
     65 
     66         let persisted = try cellFromMoves(
     67             persistence: persistence,
     68             gameID: capture.gameID,
     69             row: 0,
     70             col: 0
     71         )
     72         let cell = try #require(persisted)
     73         #expect(cell.tick == 2_000_001)
     74     }
     75 
     76     @Test("A version 1 edit remains timestamp-only")
     77     func versionOneEditHasNoLogicalTick() async throws {
     78         let (game, capture, updater, persistence) = try makeMutatorWithUpdater(
     79             actingAuthorID: "bob"
     80         )
     81         let mutator = GameMutator(
     82             game: game,
     83             gameID: capture.gameID,
     84             movesUpdater: updater,
     85             authorIDProvider: { "bob" },
     86             syncVersion: GameSyncVersion.legacy
     87         )
     88 
     89         mutator.setLetter("N", atRow: 0, atCol: 0, pencil: false)
     90         try await waitForEmittedGameID(
     91             capture.gameID,
     92             updater: updater,
     93             collector: capture.collector
     94         )
     95 
     96         let persisted = try cellFromMoves(
     97             persistence: persistence,
     98             gameID: capture.gameID,
     99             row: 0,
    100             col: 0
    101         )
    102         let cell = try #require(persisted)
    103         #expect(cell.tick == nil)
    104     }
    105 
    106     @Test("A completed mutator rejects every mutation")
    107     func completedMutatorIsReadOnly() throws {
    108         let (game, _, _, persistence) = try makeTestGame()
    109         let mutator = GameMutator(
    110             game: game,
    111             gameID: UUID(),
    112             movesUpdater: nil,
    113             movesJournal: MovesJournal(persistence: persistence),
    114             isCompleted: true
    115         )
    116 
    117         // Single-cell input is a no-op.
    118         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
    119         #expect(game.squares[0][0].entry == "")
    120 
    121         // Bulk help/clear gestures are no-ops too.
    122         mutator.revealCells([game.puzzle.cells[0][0]])
    123         #expect(game.squares[0][0].entry == "")
    124         mutator.checkCells([game.puzzle.cells[0][2]])
    125         mutator.clearCells([game.puzzle.cells[0][2]])
    126         #expect(game.squares[0][2].entry == "")
    127 
    128         // Undo/redo are disabled and inert.
    129         #expect(mutator.canUndo == false)
    130         #expect(mutator.canRedo == false)
    131         #expect(mutator.undo() == nil)
    132         #expect(mutator.redo() == nil)
    133     }
    134 
    135     @Test("A revoked mutator rejects every mutation")
    136     func revokedMutatorIsReadOnly() throws {
    137         let (game, _, _, persistence) = try makeTestGame()
    138         let mutator = GameMutator(
    139             game: game,
    140             gameID: UUID(),
    141             movesUpdater: nil,
    142             movesJournal: MovesJournal(persistence: persistence),
    143             isAccessRevoked: true
    144         )
    145 
    146         // Revocation blocks the in-memory mutation itself, not just the sync
    147         // emit — no letter may even appear on the revoked copy.
    148         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
    149         #expect(game.squares[0][0].entry == "")
    150 
    151         // Bulk help/clear gestures are no-ops too.
    152         mutator.revealCells([game.puzzle.cells[0][0]])
    153         #expect(game.squares[0][0].entry == "")
    154         mutator.checkCells([game.puzzle.cells[0][2]])
    155         mutator.clearCells([game.puzzle.cells[0][2]])
    156         #expect(game.squares[0][2].entry == "")
    157 
    158         // Undo/redo are disabled and inert.
    159         #expect(mutator.canUndo == false)
    160         #expect(mutator.canRedo == false)
    161         #expect(mutator.undo() == nil)
    162         #expect(mutator.redo() == nil)
    163     }
    164 
    165     @Test("An unsupported sync version rejects every mutation")
    166     func unsupportedSyncVersionIsReadOnly() throws {
    167         let (game, _, _, persistence) = try makeTestGame()
    168         let mutator = GameMutator(
    169             game: game,
    170             gameID: UUID(),
    171             movesUpdater: nil,
    172             movesJournal: MovesJournal(persistence: persistence),
    173             isSyncSupported: false
    174         )
    175 
    176         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
    177         mutator.revealCells([game.puzzle.cells[0][0]])
    178 
    179         #expect(game.squares[0][0].entry == "")
    180         #expect(mutator.canUndo == false)
    181         #expect(mutator.canRedo == false)
    182         #expect(mutator.undo() == nil)
    183         #expect(mutator.redo() == nil)
    184     }
    185 
    186     // MARK: - Bulk mutations
    187 
    188     @Test("checkCells marks wrong entries via mutator")
    189     func checkCellsMarksWrong() throws {
    190         let (game, mutator, _, _) = try makeTestGame()
    191 
    192         // Cell (0,0) has solution "A", enter "Z"
    193         mutator.setLetter("Z", atRow: 0, atCol: 0, pencil: false)
    194         mutator.checkCells([game.puzzle.cells[0][0]])
    195 
    196         #expect(game.squares[0][0].mark == .pen(checked: .wrong))
    197     }
    198 
    199     @Test("checkCells inks correct pencil entries")
    200     func checkCellsStampsCorrectPencilEntries() throws {
    201         let (game, mutator, _, _) = try makeTestGame()
    202 
    203         // Cell (0,0) has solution "A"; checking a correct draft commits it to ink.
    204         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: true)
    205         mutator.checkCells([game.puzzle.cells[0][0]])
    206 
    207         #expect(game.squares[0][0].mark == .pen(checked: .right))
    208     }
    209 
    210     @Test("checkCells keeps wrong pencil entries penciled")
    211     func checkCellsKeepsWrongPencilEntriesPenciled() throws {
    212         let (game, mutator, _, _) = try makeTestGame()
    213 
    214         // Cell (0,0) has solution "A"; wrong drafts stay tentative after check.
    215         mutator.setLetter("Z", atRow: 0, atCol: 0, pencil: true)
    216         mutator.checkCells([game.puzzle.cells[0][0]])
    217 
    218         #expect(game.squares[0][0].mark == .pencil(checked: .wrong))
    219     }
    220 
    221     @Test("checking an empty puzzle emits no empty cell moves")
    222     func checkingEmptyPuzzleEmitsNoEmptyCellMoves() async throws {
    223         let (game, capture, updater, persistence) = try makeMutatorWithUpdater(actingAuthorID: "bob")
    224         let mutator = makeMutator(
    225             game: game,
    226             updater: updater,
    227             gameID: capture.gameID,
    228             actingAuthorID: "bob"
    229         )
    230 
    231         mutator.checkCells(game.puzzle.cells.flatMap { $0 })
    232         await updater.flush()
    233 
    234         #expect(await capture.collector.allGameIDs.isEmpty)
    235         #expect(try cellFromMoves(
    236             persistence: persistence,
    237             gameID: capture.gameID,
    238             row: 0,
    239             col: 0
    240         ) == nil)
    241     }
    242 
    243     @Test("revealCells sets entry to solution and marks revealed")
    244     func revealCellsSetsAnswer() throws {
    245         let (game, mutator, _, _) = try makeTestGame()
    246 
    247         mutator.revealCells([game.puzzle.cells[0][0]])
    248 
    249         #expect(game.squares[0][0].entry == "A")
    250         #expect(game.squares[0][0].mark == .revealed)
    251     }
    252 
    253     @Test("revealCells leaves correct entries unmarked")
    254     func revealCellsSkipsCorrectEntries() throws {
    255         let (game, mutator, _, _) = try makeTestGame()
    256 
    257         // Cell (0,0) has solution "A" — user already entered it correctly in pen.
    258         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
    259         mutator.revealCells([game.puzzle.cells[0][0]])
    260 
    261         #expect(game.squares[0][0].entry == "A")
    262         #expect(game.squares[0][0].mark == .none)
    263     }
    264 
    265     @Test("revealCells preserves pencil mark on correct entries")
    266     func revealCellsPreservesPencilOnCorrect() throws {
    267         let (game, mutator, _, _) = try makeTestGame()
    268 
    269         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: true)
    270         mutator.revealCells([game.puzzle.cells[0][0]])
    271 
    272         #expect(game.squares[0][0].entry == "A")
    273         #expect(game.squares[0][0].mark == .pencil(checked: nil))
    274     }
    275 
    276     @Test("revealCells overwrites wrong entries and marks them revealed")
    277     func revealCellsOverwritesWrong() throws {
    278         let (game, mutator, _, _) = try makeTestGame()
    279 
    280         mutator.setLetter("Z", atRow: 0, atCol: 0, pencil: false)
    281         mutator.revealCells([game.puzzle.cells[0][0]])
    282 
    283         #expect(game.squares[0][0].entry == "A")
    284         #expect(game.squares[0][0].mark == .revealed)
    285     }
    286 
    287     @Test("fill quarter reveals a quarter of empty fillable cells")
    288     func fillQuarterRevealsQuarterOfRemainingCells() throws {
    289         let (game, mutator, _, _) = try makeTestGame()
    290         let session = PlayerSession(game: game, mutator: mutator)
    291 
    292         session.fillQuarter()
    293 
    294         #expect(revealedCellCount(in: game) == 2)
    295         #expect(revealedCells(in: game).allSatisfy { cell in
    296             game.squares[cell.row][cell.col].entry == cell.solution?.uppercased()
    297         })
    298     }
    299 
    300     @Test("fill half counts only still-empty cells")
    301     func fillHalfCountsOnlyEmptyRemainingCells() throws {
    302         let (game, mutator, _, _) = try makeTestGame()
    303         let session = PlayerSession(game: game, mutator: mutator)
    304 
    305         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
    306         mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false)
    307         mutator.setLetter("C", atRow: 0, atCol: 2, pencil: false)
    308         mutator.setLetter("D", atRow: 1, atCol: 0, pencil: false)
    309 
    310         session.fillHalf()
    311 
    312         #expect(revealedCellCount(in: game) == 2)
    313         #expect(revealedCells(in: game).allSatisfy { cell in
    314             game.squares[cell.row][cell.col].entry == cell.solution?.uppercased()
    315         })
    316         #expect(!game.squares[0][0].mark.isRevealed)
    317         #expect(!game.squares[0][1].mark.isRevealed)
    318         #expect(!game.squares[0][2].mark.isRevealed)
    319         #expect(!game.squares[1][0].mark.isRevealed)
    320     }
    321 
    322     @Test("clearCells clears non-revealed cells")
    323     func clearCellsClearsNonRevealed() throws {
    324         let (game, mutator, _, _) = try makeTestGame()
    325 
    326         mutator.setLetter("X", atRow: 0, atCol: 0, pencil: false)
    327         mutator.clearCells([game.puzzle.cells[0][0]])
    328 
    329         #expect(game.squares[0][0].entry == "")
    330         #expect(game.squares[0][0].mark == .none)
    331     }
    332 
    333     // MARK: - Completion
    334 
    335     @Test("completion state updates incrementally as entries change")
    336     func completionStateUpdatesIncrementally() throws {
    337         let (game, mutator, _, _) = try makeTestGame()
    338 
    339         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
    340         mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false)
    341         mutator.setLetter("C", atRow: 0, atCol: 2, pencil: false)
    342         mutator.setLetter("D", atRow: 1, atCol: 0, pencil: false)
    343         mutator.setLetter("E", atRow: 1, atCol: 2, pencil: false)
    344         mutator.setLetter("F", atRow: 2, atCol: 0, pencil: false)
    345         mutator.setLetter("G", atRow: 2, atCol: 1, pencil: false)
    346 
    347         #expect(game.completionState == .incomplete)
    348 
    349         mutator.setLetter("Z", atRow: 2, atCol: 2, pencil: false)
    350         #expect(game.completionState == .filledWithErrors)
    351 
    352         mutator.setLetter("H", atRow: 2, atCol: 2, pencil: false)
    353         #expect(game.completionState == .solved)
    354 
    355         mutator.clearLetter(atRow: 0, atCol: 0)
    356         #expect(game.completionState == .incomplete)
    357     }
    358 
    359     @Test("reveal and clear keep completion cache in sync")
    360     func revealAndClearKeepCompletionCacheInSync() throws {
    361         let (game, mutator, _, _) = try makeTestGame()
    362 
    363         mutator.revealCells(game.puzzle.cells.flatMap { $0 })
    364         #expect(game.completionState == .solved)
    365 
    366         mutator.clearCells(game.puzzle.cells.flatMap { $0 })
    367         #expect(game.completionState == .solved)
    368     }
    369 
    370     // MARK: - Author preservation
    371 
    372     @Test("setLetter preserves the existing author when the letter is unchanged")
    373     func setLetterSameLetterPreservesAuthor() throws {
    374         let (game, _, _, _) = try makeTestGame()
    375         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
    376 
    377         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "bob")
    378 
    379         #expect(game.squares[0][0].entry == "A")
    380         #expect(game.squares[0][0].letterAuthorID == "alice")
    381     }
    382 
    383     @Test("setLetter normalises case before deciding whether the letter changed")
    384     func setLetterSameLetterCaseInsensitivePreservesAuthor() throws {
    385         let (game, _, _, _) = try makeTestGame()
    386         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
    387 
    388         game.setLetter("a", atRow: 0, atCol: 0, pencil: false, authorID: "bob")
    389 
    390         #expect(game.squares[0][0].letterAuthorID == "alice")
    391     }
    392 
    393     @Test("setLetter overwrites the author when the letter changes")
    394     func setLetterDifferentLetterOverwritesAuthor() throws {
    395         let (game, _, _, _) = try makeTestGame()
    396         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
    397 
    398         game.setLetter("B", atRow: 0, atCol: 0, pencil: false, authorID: "bob")
    399 
    400         #expect(game.squares[0][0].entry == "B")
    401         #expect(game.squares[0][0].letterAuthorID == "bob")
    402     }
    403 
    404     @Test("revealCells preserves the existing author for cells already correct")
    405     func revealCellsPreservesAuthorOnCorrect() throws {
    406         let (game, _, _, _) = try makeTestGame()
    407         // Cell (0,0) has solution "A".
    408         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
    409 
    410         game.revealCells([game.puzzle.cells[0][0]])
    411 
    412         #expect(game.squares[0][0].entry == "A")
    413         #expect(game.squares[0][0].letterAuthorID == "alice")
    414     }
    415 
    416     // MARK: - Move emission carries the cell-effective author
    417 
    418     @Test("Same-letter rewrite emits a move carrying the preserved author, not the acting user")
    419     func sameLetterEmitsMoveWithPreservedAuthor() async throws {
    420         let (game, capture, updater, persistence) = try makeMutatorWithUpdater(actingAuthorID: "bob")
    421 
    422         // Alice already entered "A" — seed via Game directly so the updater
    423         // only sees Bob's subsequent action.
    424         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
    425 
    426         let mutator = makeMutator(
    427             game: game,
    428             updater: updater,
    429             gameID: capture.gameID,
    430             actingAuthorID: "bob"
    431         )
    432         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
    433 
    434         try await waitForEmittedGameID(
    435             capture.gameID,
    436             updater: updater,
    437             collector: capture.collector
    438         )
    439 
    440         let affected = await capture.collector.allGameIDs
    441         #expect(affected.contains(capture.gameID))
    442         let cell = try cellFromMoves(
    443             persistence: persistence,
    444             gameID: capture.gameID,
    445             row: 0,
    446             col: 0
    447         )
    448         #expect(cell?.letter == "A")
    449         #expect(cell?.authorID == "alice")
    450         #expect(game.squares[0][0].letterAuthorID == "alice")
    451     }
    452 
    453     @Test("Reveal of an already-correct cell emits a move carrying the preserved author")
    454     func revealCorrectEmitsMoveWithPreservedAuthor() async throws {
    455         let (game, capture, updater, persistence) = try makeMutatorWithUpdater(actingAuthorID: "bob")
    456 
    457         // Cell (0,0)'s solution is "A". Alice has already filled it in.
    458         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
    459 
    460         let mutator = makeMutator(
    461             game: game,
    462             updater: updater,
    463             gameID: capture.gameID,
    464             actingAuthorID: "bob"
    465         )
    466         mutator.revealCells([game.puzzle.cells[0][0]])
    467 
    468         try await waitForEmittedGameID(
    469             capture.gameID,
    470             updater: updater,
    471             collector: capture.collector
    472         )
    473 
    474         let affected = await capture.collector.allGameIDs
    475         #expect(affected.contains(capture.gameID))
    476         let cell = try cellFromMoves(
    477             persistence: persistence,
    478             gameID: capture.gameID,
    479             row: 0,
    480             col: 0
    481         )
    482         #expect(cell?.authorID == "alice")
    483         #expect(game.squares[0][0].letterAuthorID == "alice")
    484         // The cell was already correct, so it is not locked into `.revealed`.
    485         #expect(game.squares[0][0].mark == .none)
    486     }
    487 
    488     // MARK: - Test scaffolding for emission tests
    489 
    490     actor GameIDCollector {
    491         private(set) var allGameIDs: Set<UUID> = []
    492         func append(_ ids: Set<UUID>) { allGameIDs.formUnion(ids) }
    493     }
    494 
    495     /// Polls `updater.flush()` until the collector sees `gameID`. `setLetter`
    496     /// and friends spawn an internal Task to call `updater.enqueue`; if the
    497     /// test flushed before that Task landed it would see an empty buffer.
    498     /// Each iteration re-flushes so the first flush after the enqueue lands
    499     /// reaches the sink; total wall-clock is typically <10ms versus the
    500     /// previous fixed 50ms grace period.
    501     private func waitForEmittedGameID(
    502         _ gameID: UUID,
    503         updater: MovesUpdater,
    504         collector: GameIDCollector,
    505         timeout: Duration = .seconds(2)
    506     ) async throws {
    507         let deadline = ContinuousClock.now.advanced(by: timeout)
    508         while ContinuousClock.now < deadline {
    509             await updater.flush()
    510             if await collector.allGameIDs.contains(gameID) { return }
    511             try await Task.sleep(for: .milliseconds(5))
    512         }
    513     }
    514 
    515     struct UpdaterHarness {
    516         let collector: GameIDCollector
    517         let gameID: UUID
    518     }
    519 
    520     /// Builds a `Game` plus a `MovesUpdater` whose sink is captured. Returns
    521     /// the game, the capture harness, the updater, and the persistence
    522     /// controller (so the test can read back the persisted MovesEntity).
    523     private func makeMutatorWithUpdater(
    524         actingAuthorID: String
    525     ) throws -> (Game, UpdaterHarness, MovesUpdater, PersistenceController) {
    526         let (game, _, entity, persistence) = try makeTestGame()
    527         let collector = GameIDCollector()
    528         let updater = MovesUpdater(
    529             debounceInterval: .seconds(10),
    530             persistence: persistence,
    531             writerAuthorIDProvider: { actingAuthorID },
    532             sink: { ids, _ in await collector.append(ids) }
    533         )
    534         let gameID = entity.id ?? UUID()
    535         return (game, UpdaterHarness(collector: collector, gameID: gameID), updater, persistence)
    536     }
    537 
    538     private func makeMutator(
    539         game: Game,
    540         updater: MovesUpdater,
    541         gameID: UUID,
    542         actingAuthorID: String
    543     ) -> GameMutator {
    544         GameMutator(
    545             game: game,
    546             gameID: gameID,
    547             movesUpdater: updater,
    548             authorIDProvider: { actingAuthorID }
    549         )
    550     }
    551 
    552     private func revealedCellCount(in game: Game) -> Int {
    553         game.squares.flatMap { $0 }.filter { $0.mark.isRevealed }.count
    554     }
    555 
    556     private func revealedCells(in game: Game) -> [Puzzle.Cell] {
    557         game.puzzle.cells.flatMap { $0 }.filter { cell in
    558             game.squares[cell.row][cell.col].mark.isRevealed
    559         }
    560     }
    561 
    562     /// Reads back the per-cell entry at `(row, col)` from the local-device
    563     /// MovesEntity for `gameID`. Used by emission tests to verify what got
    564     /// persisted into the cells blob.
    565     private func cellFromMoves(
    566         persistence: PersistenceController,
    567         gameID: UUID,
    568         row: Int,
    569         col: Int
    570     ) throws -> TimestampedCell? {
    571         let ctx = persistence.viewContext
    572         let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    573         req.predicate = NSPredicate(
    574             format: "game.id == %@ AND deviceID == %@",
    575             gameID as CVarArg,
    576             RecordSerializer.localDeviceID
    577         )
    578         req.fetchLimit = 1
    579         ctx.refreshAllObjects()
    580         guard let entity = try ctx.fetch(req).first,
    581               let data = entity.cells
    582         else { return nil }
    583         let cells = try MovesCodec.decode(data)
    584         return cells[GridPosition(row: row, col: col)]
    585     }
    586 }