MovesJournalTests.swift (11100B)
1 import CoreData 2 import Foundation 3 import Testing 4 5 @testable import Crossmate 6 7 /// Undo/redo behaviour driven through `GameMutator`, which records every local 8 /// move into the `MovesJournal` that `makeTestGame` wires up. Assertions read 9 /// the in-memory `Game` — the journal's in-memory log is authoritative for the 10 /// session, so no waiting on background persistence is needed. 11 @Suite("MovesJournal undo/redo", .serialized) 12 @MainActor 13 struct MovesJournalTests { 14 15 // MARK: - Single-cell undo / redo 16 17 @Test("undo reverts a typed letter") 18 func undoRevertsTypedLetter() throws { 19 let (game, mutator, _, _) = try makeTestGame() 20 21 mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false) 22 #expect(mutator.canUndo) 23 24 mutator.undo() 25 #expect(game.squares[0][0].entry == "") 26 #expect(!mutator.canUndo) 27 #expect(mutator.canRedo) 28 } 29 30 @Test("redo re-applies an undone letter") 31 func redoReappliesLetter() throws { 32 let (game, mutator, _, _) = try makeTestGame() 33 34 mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false) 35 mutator.undo() 36 mutator.redo() 37 38 #expect(game.squares[0][0].entry == "A") 39 #expect(mutator.canUndo) 40 #expect(!mutator.canRedo) 41 } 42 43 @Test("undo restores the previous letter, not just empty") 44 func undoRestoresPreviousLetter() throws { 45 let (game, mutator, _, _) = try makeTestGame() 46 47 mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false) 48 mutator.setLetter("B", atRow: 0, atCol: 0, pencil: false) // overwrite same cell 49 50 mutator.undo() 51 #expect(game.squares[0][0].entry == "A") 52 53 mutator.undo() 54 #expect(game.squares[0][0].entry == "") 55 } 56 57 @Test("undo restores a collaborator letter that was overwritten locally") 58 func undoRestoresObservedCollaboratorLetter() throws { 59 let (game, mutator, _, _) = try makeTestGame() 60 61 // Remote changes bypass this device's journal, but they are visible in 62 // the live grid by the time the local overwrite happens. 63 game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice") 64 65 mutator.setLetter("B", atRow: 0, atCol: 0, pencil: false) 66 #expect(game.squares[0][0].entry == "B") 67 68 mutator.undo() 69 #expect(game.squares[0][0].entry == "A") 70 #expect(game.squares[0][0].letterAuthorID == "alice") 71 } 72 73 @Test("undo walks back across multiple cells in order") 74 func undoWalksBackMultipleCells() throws { 75 let (game, mutator, _, _) = try makeTestGame() 76 77 mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false) 78 mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false) 79 80 mutator.undo() 81 #expect(game.squares[0][1].entry == "") 82 #expect(game.squares[0][0].entry == "A") 83 84 mutator.undo() 85 #expect(game.squares[0][0].entry == "") 86 #expect(!mutator.canUndo) 87 } 88 89 @Test("a new edit after undo clears the redo branch") 90 func newEditClearsRedo() throws { 91 let (_, mutator, _, _) = try makeTestGame() 92 93 mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false) 94 mutator.undo() 95 #expect(mutator.canRedo) 96 97 mutator.setLetter("C", atRow: 0, atCol: 1, pencil: false) 98 #expect(!mutator.canRedo) 99 } 100 101 // MARK: - Cursor direction follows the entry 102 103 @Test("undo and redo report the direction the letter was typed in") 104 func undoRedoCarryEntryDirection() throws { 105 let (_, mutator, _, _) = try makeTestGame() 106 107 mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, direction: .down) 108 109 let undoLanding = mutator.undo() 110 #expect(undoLanding?.position == GridPosition(row: 0, col: 0)) 111 #expect(undoLanding?.direction == .down) 112 113 let redoLanding = mutator.redo() 114 #expect(redoLanding?.position == GridPosition(row: 0, col: 0)) 115 #expect(redoLanding?.direction == .down) 116 } 117 118 @Test("a bulk clear undo reports no single direction") 119 func clearUndoHasNoDirection() throws { 120 let (game, mutator, _, _) = try makeTestGame() 121 122 mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, direction: .across) 123 mutator.clearCells([game.puzzle.cells[0][0]]) 124 125 // Undoing the clear restores the letter but offers no cursor target. 126 let landing = mutator.undo() 127 #expect(landing == nil) 128 #expect(game.squares[0][0].entry == "A") 129 } 130 131 @Test("undo reorients the cursor to how the letter was typed") 132 func undoReorientsCursor() throws { 133 let (game, mutator, _, _) = try makeTestGame() 134 let session = PlayerSession(game: game, mutator: mutator) 135 136 // Type into the (0,0) crossing cell while pointing down. 137 session.select(row: 0, col: 0) 138 session.setDirection(.down) 139 session.enter("X") 140 141 // Move away and face across — (0,0) has an across word too, so the 142 // keep-current fallback alone would *not* flip back to down. 143 session.select(row: 2, col: 0) 144 session.setDirection(.across) 145 #expect(session.direction == .across) 146 147 session.undo() 148 #expect(session.selectedRow == 0) 149 #expect(session.selectedCol == 0) 150 #expect(session.direction == .down) 151 } 152 153 // MARK: - Checks and reveals are not undoable 154 155 @Test("checking a cell creates no undo step") 156 func checkIsNotUndoable() throws { 157 let (game, mutator, _, _) = try makeTestGame() 158 159 mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false) // (0,0) solution is A 160 mutator.checkCells([game.puzzle.cells[0][0]]) 161 #expect(game.squares[0][0].mark == .pen(checked: .right)) 162 163 // The only undoable thing is the letter; one undo removes it (and the 164 // check with it), and there is nothing left to undo. 165 mutator.undo() 166 #expect(game.squares[0][0].entry == "") 167 #expect(!mutator.canUndo) 168 } 169 170 @Test("typing then checking still lets the letter be undone") 171 func undoAfterCheckRemovesLetter() throws { 172 let (game, mutator, _, _) = try makeTestGame() 173 174 mutator.setLetter("Z", atRow: 0, atCol: 0, pencil: false) // wrong, so it checks .wrong 175 mutator.checkCells([game.puzzle.cells[0][0]]) 176 #expect(game.squares[0][0].mark == .pen(checked: .wrong)) 177 178 // The check changed the mark but not the letter, so the letter-only 179 // guard still lets the typed letter be undone. 180 mutator.undo() 181 #expect(game.squares[0][0].entry == "") 182 } 183 184 @Test("revealing creates no undo step and undo leaves it alone") 185 func revealIsNotUndoable() throws { 186 let (game, mutator, _, _) = try makeTestGame() 187 188 let cells = [game.puzzle.cells[0][0], game.puzzle.cells[0][1]] 189 mutator.revealCells(cells) 190 #expect(game.squares[0][0].mark == .revealed) 191 #expect(!mutator.canUndo) 192 193 mutator.undo() // no-op 194 #expect(game.squares[0][0].mark == .revealed) 195 #expect(game.squares[0][0].entry == "A") 196 } 197 198 // MARK: - Bulk steps 199 200 @Test("undo reverses a clear-cells gesture as a single step") 201 func undoBulkClearIsOneStep() throws { 202 let (game, mutator, _, _) = try makeTestGame() 203 204 mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false) 205 mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false) 206 let cells = [game.puzzle.cells[0][0], game.puzzle.cells[0][1]] 207 mutator.clearCells(cells) 208 #expect(game.squares[0][0].entry == "") 209 #expect(game.squares[0][1].entry == "") 210 211 mutator.undo() // one undo restores the whole cleared gesture 212 213 #expect(game.squares[0][0].entry == "A") 214 #expect(game.squares[0][1].entry == "B") 215 } 216 217 // MARK: - Recording (replay) vs undoability, at the journal level 218 219 @Test("checks and reveals are recorded for replay but are not undoable") 220 func checksAndRevealsRecordedNotUndoable() { 221 let journal = MovesJournal(persistence: makeTestPersistence()) 222 let gameID = UUID() 223 let pos = GridPosition(row: 0, col: 0) 224 225 journal.record( 226 gameID: gameID, position: pos, 227 state: JournalCellState(letter: "A", mark: .pen(checked: .right), cellAuthorID: nil), 228 actingAuthorID: nil, kind: .check, targetSeq: nil, batchID: nil 229 ) 230 journal.record( 231 gameID: gameID, position: GridPosition(row: 0, col: 1), 232 state: JournalCellState(letter: "B", mark: .revealed, cellAuthorID: nil), 233 actingAuthorID: nil, kind: .reveal, targetSeq: nil, batchID: nil 234 ) 235 236 #expect(journal.recordedEntries(gameID: gameID).count == 2) // both logged 237 #expect(!journal.canUndo(gameID: gameID)) // neither undoable 238 } 239 240 @Test("input and clear are both recorded and undoable") 241 func inputAndClearAreUndoable() { 242 let journal = MovesJournal(persistence: makeTestPersistence()) 243 let gameID = UUID() 244 let pos = GridPosition(row: 0, col: 0) 245 246 journal.record( 247 gameID: gameID, position: pos, 248 state: JournalCellState(letter: "A", mark: .none, cellAuthorID: nil), 249 actingAuthorID: nil, kind: .input, targetSeq: nil, batchID: nil 250 ) 251 journal.record( 252 gameID: gameID, position: pos, 253 state: JournalCellState(letter: "", mark: .none, cellAuthorID: nil), 254 actingAuthorID: nil, kind: .clear, targetSeq: nil, batchID: nil 255 ) 256 257 #expect(journal.recordedEntries(gameID: gameID).count == 2) 258 #expect(journal.canUndo(gameID: gameID)) // clear (top) is undoable 259 } 260 261 // MARK: - Supersession guard 262 263 @Test("undo skips a cell a collaborator changed since") 264 func undoSkipsSupersededCell() throws { 265 let (game, mutator, _, _) = try makeTestGame() 266 267 mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false) 268 // Simulate a remote edit landing on the same cell: it writes straight 269 // into `Game` and is not journaled (remote changes bypass GameMutator). 270 game.setLetter("Z", atRow: 0, atCol: 0, pencil: false) 271 272 mutator.undo() 273 274 // The undo must not clobber the collaborator's current letter. 275 #expect(game.squares[0][0].entry == "Z") 276 // The superseded step is consumed, so there's nothing left to undo. 277 #expect(!mutator.canUndo) 278 } 279 280 @Test("undo passes over a fully superseded step to an earlier one") 281 func undoSkipsToEarlierStep() throws { 282 let (game, mutator, _, _) = try makeTestGame() 283 284 mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false) 285 mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false) 286 // Collaborator overwrites the most recent edit's cell. 287 game.setLetter("Z", atRow: 0, atCol: 1, pencil: false) 288 289 mutator.undo() // (0,1) is superseded → skip it and undo (0,0) 290 291 #expect(game.squares[0][1].entry == "Z") // untouched 292 #expect(game.squares[0][0].entry == "") // earlier edit reverted 293 #expect(!mutator.canUndo) 294 } 295 }