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 }