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 }