crossmate

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

GameMutatorTests.swift (12040B)


      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(checkedWrong: false))
     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     // MARK: - Bulk mutations
     46 
     47     @Test("checkCells marks wrong entries via mutator")
     48     func checkCellsMarksWrong() throws {
     49         let (game, mutator, _, _) = try makeTestGame()
     50 
     51         // Cell (0,0) has solution "A", enter "Z"
     52         mutator.setLetter("Z", atRow: 0, atCol: 0, pencil: false)
     53         mutator.checkCells([game.puzzle.cells[0][0]])
     54 
     55         #expect(game.squares[0][0].mark == .pen(checkedWrong: true))
     56     }
     57 
     58     @Test("revealCells sets entry to solution and marks revealed")
     59     func revealCellsSetsAnswer() throws {
     60         let (game, mutator, _, _) = try makeTestGame()
     61 
     62         mutator.revealCells([game.puzzle.cells[0][0]])
     63 
     64         #expect(game.squares[0][0].entry == "A")
     65         #expect(game.squares[0][0].mark == .revealed)
     66     }
     67 
     68     @Test("revealCells leaves correct entries unmarked")
     69     func revealCellsSkipsCorrectEntries() throws {
     70         let (game, mutator, _, _) = try makeTestGame()
     71 
     72         // Cell (0,0) has solution "A" — user already entered it correctly in pen.
     73         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
     74         mutator.revealCells([game.puzzle.cells[0][0]])
     75 
     76         #expect(game.squares[0][0].entry == "A")
     77         #expect(game.squares[0][0].mark == .none)
     78     }
     79 
     80     @Test("revealCells preserves pencil mark on correct entries")
     81     func revealCellsPreservesPencilOnCorrect() throws {
     82         let (game, mutator, _, _) = try makeTestGame()
     83 
     84         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: true)
     85         mutator.revealCells([game.puzzle.cells[0][0]])
     86 
     87         #expect(game.squares[0][0].entry == "A")
     88         #expect(game.squares[0][0].mark == .pencil(checkedWrong: false))
     89     }
     90 
     91     @Test("revealCells overwrites wrong entries and marks them revealed")
     92     func revealCellsOverwritesWrong() throws {
     93         let (game, mutator, _, _) = try makeTestGame()
     94 
     95         mutator.setLetter("Z", atRow: 0, atCol: 0, pencil: false)
     96         mutator.revealCells([game.puzzle.cells[0][0]])
     97 
     98         #expect(game.squares[0][0].entry == "A")
     99         #expect(game.squares[0][0].mark == .revealed)
    100     }
    101 
    102     @Test("clearCells clears non-revealed cells")
    103     func clearCellsClearsNonRevealed() throws {
    104         let (game, mutator, _, _) = try makeTestGame()
    105 
    106         mutator.setLetter("X", atRow: 0, atCol: 0, pencil: false)
    107         mutator.clearCells([game.puzzle.cells[0][0]])
    108 
    109         #expect(game.squares[0][0].entry == "")
    110         #expect(game.squares[0][0].mark == .none)
    111     }
    112 
    113     // MARK: - Completion
    114 
    115     @Test("completion state updates incrementally as entries change")
    116     func completionStateUpdatesIncrementally() throws {
    117         let (game, mutator, _, _) = try makeTestGame()
    118 
    119         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
    120         mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false)
    121         mutator.setLetter("C", atRow: 0, atCol: 2, pencil: false)
    122         mutator.setLetter("D", atRow: 1, atCol: 0, pencil: false)
    123         mutator.setLetter("E", atRow: 1, atCol: 2, pencil: false)
    124         mutator.setLetter("F", atRow: 2, atCol: 0, pencil: false)
    125         mutator.setLetter("G", atRow: 2, atCol: 1, pencil: false)
    126 
    127         #expect(game.completionState == .incomplete)
    128 
    129         mutator.setLetter("Z", atRow: 2, atCol: 2, pencil: false)
    130         #expect(game.completionState == .filledWithErrors)
    131 
    132         mutator.setLetter("H", atRow: 2, atCol: 2, pencil: false)
    133         #expect(game.completionState == .solved)
    134 
    135         mutator.clearLetter(atRow: 0, atCol: 0)
    136         #expect(game.completionState == .incomplete)
    137     }
    138 
    139     @Test("reveal and clear keep completion cache in sync")
    140     func revealAndClearKeepCompletionCacheInSync() throws {
    141         let (game, mutator, _, _) = try makeTestGame()
    142 
    143         mutator.revealCells(game.puzzle.cells.flatMap { $0 })
    144         #expect(game.completionState == .solved)
    145 
    146         mutator.clearCells(game.puzzle.cells.flatMap { $0 })
    147         #expect(game.completionState == .solved)
    148     }
    149 
    150     // MARK: - Author preservation
    151 
    152     @Test("setLetter preserves the existing author when the letter is unchanged")
    153     func setLetterSameLetterPreservesAuthor() throws {
    154         let (game, _, _, _) = try makeTestGame()
    155         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
    156 
    157         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "bob")
    158 
    159         #expect(game.squares[0][0].entry == "A")
    160         #expect(game.squares[0][0].letterAuthorID == "alice")
    161     }
    162 
    163     @Test("setLetter normalises case before deciding whether the letter changed")
    164     func setLetterSameLetterCaseInsensitivePreservesAuthor() throws {
    165         let (game, _, _, _) = try makeTestGame()
    166         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
    167 
    168         game.setLetter("a", atRow: 0, atCol: 0, pencil: false, authorID: "bob")
    169 
    170         #expect(game.squares[0][0].letterAuthorID == "alice")
    171     }
    172 
    173     @Test("setLetter overwrites the author when the letter changes")
    174     func setLetterDifferentLetterOverwritesAuthor() throws {
    175         let (game, _, _, _) = try makeTestGame()
    176         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
    177 
    178         game.setLetter("B", atRow: 0, atCol: 0, pencil: false, authorID: "bob")
    179 
    180         #expect(game.squares[0][0].entry == "B")
    181         #expect(game.squares[0][0].letterAuthorID == "bob")
    182     }
    183 
    184     @Test("revealCells preserves the existing author for cells already correct")
    185     func revealCellsPreservesAuthorOnCorrect() throws {
    186         let (game, _, _, _) = try makeTestGame()
    187         // Cell (0,0) has solution "A".
    188         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
    189 
    190         game.revealCells([game.puzzle.cells[0][0]])
    191 
    192         #expect(game.squares[0][0].entry == "A")
    193         #expect(game.squares[0][0].letterAuthorID == "alice")
    194     }
    195 
    196     // MARK: - Move emission carries the cell-effective author
    197 
    198     @Test("Same-letter rewrite emits a move carrying the preserved author, not the acting user")
    199     func sameLetterEmitsMoveWithPreservedAuthor() async throws {
    200         let (game, capture, updater, persistence) = try makeMutatorWithUpdater(actingAuthorID: "bob")
    201 
    202         // Alice already entered "A" — seed via Game directly so the updater
    203         // only sees Bob's subsequent action.
    204         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
    205 
    206         let mutator = makeMutator(
    207             game: game,
    208             updater: updater,
    209             gameID: capture.gameID,
    210             actingAuthorID: "bob"
    211         )
    212         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
    213 
    214         try await Task.sleep(for: .milliseconds(50))
    215         await updater.flush()
    216 
    217         let affected = await capture.collector.allGameIDs
    218         #expect(affected.contains(capture.gameID))
    219         let cell = try cellFromMoves(
    220             persistence: persistence,
    221             gameID: capture.gameID,
    222             row: 0,
    223             col: 0
    224         )
    225         #expect(cell?.letter == "A")
    226         #expect(cell?.authorID == "alice")
    227         #expect(game.squares[0][0].letterAuthorID == "alice")
    228     }
    229 
    230     @Test("Reveal of an already-correct cell emits a move carrying the preserved author")
    231     func revealCorrectEmitsMoveWithPreservedAuthor() async throws {
    232         let (game, capture, updater, persistence) = try makeMutatorWithUpdater(actingAuthorID: "bob")
    233 
    234         // Cell (0,0)'s solution is "A". Alice has already filled it in.
    235         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
    236 
    237         let mutator = makeMutator(
    238             game: game,
    239             updater: updater,
    240             gameID: capture.gameID,
    241             actingAuthorID: "bob"
    242         )
    243         mutator.revealCells([game.puzzle.cells[0][0]])
    244 
    245         try await Task.sleep(for: .milliseconds(50))
    246         await updater.flush()
    247 
    248         let affected = await capture.collector.allGameIDs
    249         #expect(affected.contains(capture.gameID))
    250         let cell = try cellFromMoves(
    251             persistence: persistence,
    252             gameID: capture.gameID,
    253             row: 0,
    254             col: 0
    255         )
    256         #expect(cell?.authorID == "alice")
    257         #expect(game.squares[0][0].letterAuthorID == "alice")
    258         // The cell was already correct, so it is not locked into `.revealed`.
    259         #expect(game.squares[0][0].mark == .none)
    260     }
    261 
    262     // MARK: - Test scaffolding for emission tests
    263 
    264     actor GameIDCollector {
    265         private(set) var allGameIDs: Set<UUID> = []
    266         func append(_ ids: Set<UUID>) { allGameIDs.formUnion(ids) }
    267     }
    268 
    269     struct UpdaterHarness {
    270         let collector: GameIDCollector
    271         let gameID: UUID
    272     }
    273 
    274     /// Builds a `Game` plus a `MovesUpdater` whose sink is captured. Returns
    275     /// the game, the capture harness, the updater, and the persistence
    276     /// controller (so the test can read back the persisted MovesEntity).
    277     private func makeMutatorWithUpdater(
    278         actingAuthorID: String
    279     ) throws -> (Game, UpdaterHarness, MovesUpdater, PersistenceController) {
    280         let (game, _, entity, persistence) = try makeTestGame()
    281         let collector = GameIDCollector()
    282         let updater = MovesUpdater(
    283             debounceInterval: .seconds(10),
    284             persistence: persistence,
    285             writerAuthorIDProvider: { actingAuthorID },
    286             sink: { await collector.append($0) }
    287         )
    288         let gameID = entity.id ?? UUID()
    289         return (game, UpdaterHarness(collector: collector, gameID: gameID), updater, persistence)
    290     }
    291 
    292     private func makeMutator(
    293         game: Game,
    294         updater: MovesUpdater,
    295         gameID: UUID,
    296         actingAuthorID: String
    297     ) -> GameMutator {
    298         GameMutator(
    299             game: game,
    300             gameID: gameID,
    301             movesUpdater: updater,
    302             authorIDProvider: { actingAuthorID }
    303         )
    304     }
    305 
    306     /// Reads back the per-cell entry at `(row, col)` from the local-device
    307     /// MovesEntity for `gameID`. Used by emission tests to verify what got
    308     /// persisted into the cells blob.
    309     private func cellFromMoves(
    310         persistence: PersistenceController,
    311         gameID: UUID,
    312         row: Int,
    313         col: Int
    314     ) throws -> TimestampedCell? {
    315         let ctx = persistence.viewContext
    316         let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    317         req.predicate = NSPredicate(
    318             format: "game.id == %@ AND deviceID == %@",
    319             gameID as CVarArg,
    320             RecordSerializer.localDeviceID
    321         )
    322         req.fetchLimit = 1
    323         ctx.refreshAllObjects()
    324         guard let entity = try ctx.fetch(req).first,
    325               let data = entity.cells
    326         else { return nil }
    327         let cells = try MovesCodec.decode(data)
    328         return cells[GridPosition(row: row, col: col)]
    329     }
    330 }