crossmate

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

GameMutatorTests.swift (15035B)


      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 completed mutator rejects every mutation")
     46     func completedMutatorIsReadOnly() throws {
     47         let (game, _, _, persistence) = try makeTestGame()
     48         let mutator = GameMutator(
     49             game: game,
     50             gameID: UUID(),
     51             movesUpdater: nil,
     52             movesJournal: MovesJournal(persistence: persistence),
     53             isCompleted: true
     54         )
     55 
     56         // Single-cell input is a no-op.
     57         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
     58         #expect(game.squares[0][0].entry == "")
     59 
     60         // Bulk help/clear gestures are no-ops too.
     61         mutator.revealCells([game.puzzle.cells[0][0]])
     62         #expect(game.squares[0][0].entry == "")
     63         mutator.checkCells([game.puzzle.cells[0][2]])
     64         mutator.clearCells([game.puzzle.cells[0][2]])
     65         #expect(game.squares[0][2].entry == "")
     66 
     67         // Undo/redo are disabled and inert.
     68         #expect(mutator.canUndo == false)
     69         #expect(mutator.canRedo == false)
     70         #expect(mutator.undo() == nil)
     71         #expect(mutator.redo() == nil)
     72     }
     73 
     74     // MARK: - Bulk mutations
     75 
     76     @Test("checkCells marks wrong entries via mutator")
     77     func checkCellsMarksWrong() throws {
     78         let (game, mutator, _, _) = try makeTestGame()
     79 
     80         // Cell (0,0) has solution "A", enter "Z"
     81         mutator.setLetter("Z", atRow: 0, atCol: 0, pencil: false)
     82         mutator.checkCells([game.puzzle.cells[0][0]])
     83 
     84         #expect(game.squares[0][0].mark == .pen(checked: .wrong))
     85     }
     86 
     87     @Test("checkCells inks correct pencil entries")
     88     func checkCellsStampsCorrectPencilEntries() throws {
     89         let (game, mutator, _, _) = try makeTestGame()
     90 
     91         // Cell (0,0) has solution "A"; checking a correct draft commits it to ink.
     92         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: true)
     93         mutator.checkCells([game.puzzle.cells[0][0]])
     94 
     95         #expect(game.squares[0][0].mark == .pen(checked: .right))
     96     }
     97 
     98     @Test("checkCells keeps wrong pencil entries penciled")
     99     func checkCellsKeepsWrongPencilEntriesPenciled() throws {
    100         let (game, mutator, _, _) = try makeTestGame()
    101 
    102         // Cell (0,0) has solution "A"; wrong drafts stay tentative after check.
    103         mutator.setLetter("Z", atRow: 0, atCol: 0, pencil: true)
    104         mutator.checkCells([game.puzzle.cells[0][0]])
    105 
    106         #expect(game.squares[0][0].mark == .pencil(checked: .wrong))
    107     }
    108 
    109     @Test("revealCells sets entry to solution and marks revealed")
    110     func revealCellsSetsAnswer() throws {
    111         let (game, mutator, _, _) = try makeTestGame()
    112 
    113         mutator.revealCells([game.puzzle.cells[0][0]])
    114 
    115         #expect(game.squares[0][0].entry == "A")
    116         #expect(game.squares[0][0].mark == .revealed)
    117     }
    118 
    119     @Test("revealCells leaves correct entries unmarked")
    120     func revealCellsSkipsCorrectEntries() throws {
    121         let (game, mutator, _, _) = try makeTestGame()
    122 
    123         // Cell (0,0) has solution "A" — user already entered it correctly in pen.
    124         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
    125         mutator.revealCells([game.puzzle.cells[0][0]])
    126 
    127         #expect(game.squares[0][0].entry == "A")
    128         #expect(game.squares[0][0].mark == .none)
    129     }
    130 
    131     @Test("revealCells preserves pencil mark on correct entries")
    132     func revealCellsPreservesPencilOnCorrect() throws {
    133         let (game, mutator, _, _) = try makeTestGame()
    134 
    135         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: true)
    136         mutator.revealCells([game.puzzle.cells[0][0]])
    137 
    138         #expect(game.squares[0][0].entry == "A")
    139         #expect(game.squares[0][0].mark == .pencil(checked: nil))
    140     }
    141 
    142     @Test("revealCells overwrites wrong entries and marks them revealed")
    143     func revealCellsOverwritesWrong() throws {
    144         let (game, mutator, _, _) = try makeTestGame()
    145 
    146         mutator.setLetter("Z", atRow: 0, atCol: 0, pencil: false)
    147         mutator.revealCells([game.puzzle.cells[0][0]])
    148 
    149         #expect(game.squares[0][0].entry == "A")
    150         #expect(game.squares[0][0].mark == .revealed)
    151     }
    152 
    153     @Test("clearCells clears non-revealed cells")
    154     func clearCellsClearsNonRevealed() throws {
    155         let (game, mutator, _, _) = try makeTestGame()
    156 
    157         mutator.setLetter("X", atRow: 0, atCol: 0, pencil: false)
    158         mutator.clearCells([game.puzzle.cells[0][0]])
    159 
    160         #expect(game.squares[0][0].entry == "")
    161         #expect(game.squares[0][0].mark == .none)
    162     }
    163 
    164     // MARK: - Completion
    165 
    166     @Test("completion state updates incrementally as entries change")
    167     func completionStateUpdatesIncrementally() throws {
    168         let (game, mutator, _, _) = try makeTestGame()
    169 
    170         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
    171         mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false)
    172         mutator.setLetter("C", atRow: 0, atCol: 2, pencil: false)
    173         mutator.setLetter("D", atRow: 1, atCol: 0, pencil: false)
    174         mutator.setLetter("E", atRow: 1, atCol: 2, pencil: false)
    175         mutator.setLetter("F", atRow: 2, atCol: 0, pencil: false)
    176         mutator.setLetter("G", atRow: 2, atCol: 1, pencil: false)
    177 
    178         #expect(game.completionState == .incomplete)
    179 
    180         mutator.setLetter("Z", atRow: 2, atCol: 2, pencil: false)
    181         #expect(game.completionState == .filledWithErrors)
    182 
    183         mutator.setLetter("H", atRow: 2, atCol: 2, pencil: false)
    184         #expect(game.completionState == .solved)
    185 
    186         mutator.clearLetter(atRow: 0, atCol: 0)
    187         #expect(game.completionState == .incomplete)
    188     }
    189 
    190     @Test("reveal and clear keep completion cache in sync")
    191     func revealAndClearKeepCompletionCacheInSync() throws {
    192         let (game, mutator, _, _) = try makeTestGame()
    193 
    194         mutator.revealCells(game.puzzle.cells.flatMap { $0 })
    195         #expect(game.completionState == .solved)
    196 
    197         mutator.clearCells(game.puzzle.cells.flatMap { $0 })
    198         #expect(game.completionState == .solved)
    199     }
    200 
    201     // MARK: - Author preservation
    202 
    203     @Test("setLetter preserves the existing author when the letter is unchanged")
    204     func setLetterSameLetterPreservesAuthor() throws {
    205         let (game, _, _, _) = try makeTestGame()
    206         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
    207 
    208         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "bob")
    209 
    210         #expect(game.squares[0][0].entry == "A")
    211         #expect(game.squares[0][0].letterAuthorID == "alice")
    212     }
    213 
    214     @Test("setLetter normalises case before deciding whether the letter changed")
    215     func setLetterSameLetterCaseInsensitivePreservesAuthor() throws {
    216         let (game, _, _, _) = try makeTestGame()
    217         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
    218 
    219         game.setLetter("a", atRow: 0, atCol: 0, pencil: false, authorID: "bob")
    220 
    221         #expect(game.squares[0][0].letterAuthorID == "alice")
    222     }
    223 
    224     @Test("setLetter overwrites the author when the letter changes")
    225     func setLetterDifferentLetterOverwritesAuthor() throws {
    226         let (game, _, _, _) = try makeTestGame()
    227         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
    228 
    229         game.setLetter("B", atRow: 0, atCol: 0, pencil: false, authorID: "bob")
    230 
    231         #expect(game.squares[0][0].entry == "B")
    232         #expect(game.squares[0][0].letterAuthorID == "bob")
    233     }
    234 
    235     @Test("revealCells preserves the existing author for cells already correct")
    236     func revealCellsPreservesAuthorOnCorrect() throws {
    237         let (game, _, _, _) = try makeTestGame()
    238         // Cell (0,0) has solution "A".
    239         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
    240 
    241         game.revealCells([game.puzzle.cells[0][0]])
    242 
    243         #expect(game.squares[0][0].entry == "A")
    244         #expect(game.squares[0][0].letterAuthorID == "alice")
    245     }
    246 
    247     // MARK: - Move emission carries the cell-effective author
    248 
    249     @Test("Same-letter rewrite emits a move carrying the preserved author, not the acting user")
    250     func sameLetterEmitsMoveWithPreservedAuthor() async throws {
    251         let (game, capture, updater, persistence) = try makeMutatorWithUpdater(actingAuthorID: "bob")
    252 
    253         // Alice already entered "A" — seed via Game directly so the updater
    254         // only sees Bob's subsequent action.
    255         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
    256 
    257         let mutator = makeMutator(
    258             game: game,
    259             updater: updater,
    260             gameID: capture.gameID,
    261             actingAuthorID: "bob"
    262         )
    263         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
    264 
    265         try await waitForEmittedGameID(
    266             capture.gameID,
    267             updater: updater,
    268             collector: capture.collector
    269         )
    270 
    271         let affected = await capture.collector.allGameIDs
    272         #expect(affected.contains(capture.gameID))
    273         let cell = try cellFromMoves(
    274             persistence: persistence,
    275             gameID: capture.gameID,
    276             row: 0,
    277             col: 0
    278         )
    279         #expect(cell?.letter == "A")
    280         #expect(cell?.authorID == "alice")
    281         #expect(game.squares[0][0].letterAuthorID == "alice")
    282     }
    283 
    284     @Test("Reveal of an already-correct cell emits a move carrying the preserved author")
    285     func revealCorrectEmitsMoveWithPreservedAuthor() async throws {
    286         let (game, capture, updater, persistence) = try makeMutatorWithUpdater(actingAuthorID: "bob")
    287 
    288         // Cell (0,0)'s solution is "A". Alice has already filled it in.
    289         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
    290 
    291         let mutator = makeMutator(
    292             game: game,
    293             updater: updater,
    294             gameID: capture.gameID,
    295             actingAuthorID: "bob"
    296         )
    297         mutator.revealCells([game.puzzle.cells[0][0]])
    298 
    299         try await waitForEmittedGameID(
    300             capture.gameID,
    301             updater: updater,
    302             collector: capture.collector
    303         )
    304 
    305         let affected = await capture.collector.allGameIDs
    306         #expect(affected.contains(capture.gameID))
    307         let cell = try cellFromMoves(
    308             persistence: persistence,
    309             gameID: capture.gameID,
    310             row: 0,
    311             col: 0
    312         )
    313         #expect(cell?.authorID == "alice")
    314         #expect(game.squares[0][0].letterAuthorID == "alice")
    315         // The cell was already correct, so it is not locked into `.revealed`.
    316         #expect(game.squares[0][0].mark == .none)
    317     }
    318 
    319     // MARK: - Test scaffolding for emission tests
    320 
    321     actor GameIDCollector {
    322         private(set) var allGameIDs: Set<UUID> = []
    323         func append(_ ids: Set<UUID>) { allGameIDs.formUnion(ids) }
    324     }
    325 
    326     /// Polls `updater.flush()` until the collector sees `gameID`. `setLetter`
    327     /// and friends spawn an internal Task to call `updater.enqueue`; if the
    328     /// test flushed before that Task landed it would see an empty buffer.
    329     /// Each iteration re-flushes so the first flush after the enqueue lands
    330     /// reaches the sink; total wall-clock is typically <10ms versus the
    331     /// previous fixed 50ms grace period.
    332     private func waitForEmittedGameID(
    333         _ gameID: UUID,
    334         updater: MovesUpdater,
    335         collector: GameIDCollector,
    336         timeout: Duration = .seconds(2)
    337     ) async throws {
    338         let deadline = ContinuousClock.now.advanced(by: timeout)
    339         while ContinuousClock.now < deadline {
    340             await updater.flush()
    341             if await collector.allGameIDs.contains(gameID) { return }
    342             try await Task.sleep(for: .milliseconds(5))
    343         }
    344     }
    345 
    346     struct UpdaterHarness {
    347         let collector: GameIDCollector
    348         let gameID: UUID
    349     }
    350 
    351     /// Builds a `Game` plus a `MovesUpdater` whose sink is captured. Returns
    352     /// the game, the capture harness, the updater, and the persistence
    353     /// controller (so the test can read back the persisted MovesEntity).
    354     private func makeMutatorWithUpdater(
    355         actingAuthorID: String
    356     ) throws -> (Game, UpdaterHarness, MovesUpdater, PersistenceController) {
    357         let (game, _, entity, persistence) = try makeTestGame()
    358         let collector = GameIDCollector()
    359         let updater = MovesUpdater(
    360             debounceInterval: .seconds(10),
    361             persistence: persistence,
    362             writerAuthorIDProvider: { actingAuthorID },
    363             sink: { ids, _ in await collector.append(ids) }
    364         )
    365         let gameID = entity.id ?? UUID()
    366         return (game, UpdaterHarness(collector: collector, gameID: gameID), updater, persistence)
    367     }
    368 
    369     private func makeMutator(
    370         game: Game,
    371         updater: MovesUpdater,
    372         gameID: UUID,
    373         actingAuthorID: String
    374     ) -> GameMutator {
    375         GameMutator(
    376             game: game,
    377             gameID: gameID,
    378             movesUpdater: updater,
    379             authorIDProvider: { actingAuthorID }
    380         )
    381     }
    382 
    383     /// Reads back the per-cell entry at `(row, col)` from the local-device
    384     /// MovesEntity for `gameID`. Used by emission tests to verify what got
    385     /// persisted into the cells blob.
    386     private func cellFromMoves(
    387         persistence: PersistenceController,
    388         gameID: UUID,
    389         row: Int,
    390         col: Int
    391     ) throws -> TimestampedCell? {
    392         let ctx = persistence.viewContext
    393         let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    394         req.predicate = NSPredicate(
    395             format: "game.id == %@ AND deviceID == %@",
    396             gameID as CVarArg,
    397             RecordSerializer.localDeviceID
    398         )
    399         req.fetchLimit = 1
    400         ctx.refreshAllObjects()
    401         guard let entity = try ctx.fetch(req).first,
    402               let data = entity.cells
    403         else { return nil }
    404         let cells = try MovesCodec.decode(data)
    405         return cells[GridPosition(row: row, col: col)]
    406     }
    407 }