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 }