crossmate

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

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 }