crossmate

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

PlayerSessionNavigationTests.swift (9235B)


      1 import Foundation
      2 import Testing
      3 
      4 @testable import Crossmate
      5 
      6 @Suite("PlayerSession navigation", .serialized)
      7 @MainActor
      8 struct PlayerSessionNavigationTests {
      9     @Test("Published selection is the cursor track start, not the local reticle")
     10     func publishesCursorTrackStart() throws {
     11         let session = try makeNavigationSession()
     12         var published: [PlayerSelection] = []
     13         session.onSelectionChanged = { published.append($0) }
     14 
     15         session.select(row: 0, col: 2)
     16         session.setDirection(.across)
     17 
     18         #expect(session.selectedRow == 0)
     19         #expect(session.selectedCol == 2)
     20         #expect(published.last == PlayerSelection(row: 0, col: 0, direction: .across))
     21     }
     22 
     23     @Test("Moving within one answer publishes the cursor track once")
     24     func movingWithinAnswerPublishesCursorTrackOnce() throws {
     25         let session = try makeNavigationSession()
     26         var published: [PlayerSelection] = []
     27         session.onSelectionChanged = { published.append($0) }
     28 
     29         session.select(row: 0, col: 1)
     30         session.select(row: 0, col: 2)
     31 
     32         #expect(published == [PlayerSelection(row: 0, col: 0, direction: .across)])
     33     }
     34 
     35     @Test("Next clue from final across moves to first down")
     36     func nextClueFromFinalAcrossMovesToFirstDown() throws {
     37         let session = try makeNavigationSession()
     38         let finalAcross = try #require(session.puzzle.acrossClues.last)
     39         let firstDown = try #require(session.puzzle.downClues.first)
     40 
     41         session.selectClue(direction: .across, number: finalAcross.number)
     42         session.goToNextClue()
     43 
     44         #expect(session.direction == .down)
     45         #expect(session.currentClue()?.number == firstDown.number)
     46     }
     47 
     48     @Test("Previous clue from first down moves to final across")
     49     func previousClueFromFirstDownMovesToFinalAcross() throws {
     50         let session = try makeNavigationSession()
     51         let finalAcross = try #require(session.puzzle.acrossClues.last)
     52         let firstDown = try #require(session.puzzle.downClues.first)
     53 
     54         session.selectClue(direction: .down, number: firstDown.number)
     55         session.goToPreviousClue()
     56 
     57         #expect(session.direction == .across)
     58         #expect(session.currentClue()?.number == finalAcross.number)
     59     }
     60 
     61     @Test("Next clue from final down moves to first across")
     62     func nextClueFromFinalDownMovesToFirstAcross() throws {
     63         let session = try makeNavigationSession()
     64         let firstAcross = try #require(session.puzzle.acrossClues.first)
     65         let finalDown = try #require(session.puzzle.downClues.last)
     66 
     67         session.selectClue(direction: .down, number: finalDown.number)
     68         session.goToNextClue()
     69 
     70         #expect(session.direction == .across)
     71         #expect(session.currentClue()?.number == firstAcross.number)
     72     }
     73 
     74     @Test("Typing past final across moves to first down")
     75     func typingPastFinalAcrossMovesToFirstDown() throws {
     76         let session = try makeNavigationSession()
     77         let finalAcross = try #require(session.puzzle.acrossClues.last)
     78         let firstDown = try #require(session.puzzle.downClues.first)
     79 
     80         session.selectClue(direction: .across, number: finalAcross.number)
     81         session.select(row: 2, col: 2)
     82         session.setDirection(.across)
     83         session.enter("H")
     84 
     85         #expect(session.direction == .down)
     86         #expect(session.currentClue()?.number == firstDown.number)
     87     }
     88 
     89     @Test("Typing past final down moves to first across")
     90     func typingPastFinalDownMovesToFirstAcross() throws {
     91         let session = try makeNavigationSession()
     92         let firstAcross = try #require(session.puzzle.acrossClues.first)
     93         let finalDown = try #require(session.puzzle.downClues.last)
     94 
     95         session.selectClue(direction: .down, number: finalDown.number)
     96         session.select(row: 2, col: 2)
     97         session.setDirection(.down)
     98         session.enter("H")
     99 
    100         #expect(session.direction == .across)
    101         #expect(session.currentClue()?.number == firstAcross.number)
    102     }
    103 
    104     @Test("Overwriting a full wrong grid publishes a fresh error completion event")
    105     func overwriteFullWrongGridPublishesFreshErrorEvent() throws {
    106         let session = try makeNavigationSession()
    107 
    108         let letters = [
    109             (0, 0, "A"), (0, 1, "B"), (0, 2, "C"),
    110             (1, 0, "D"),             (1, 2, "E"),
    111             (2, 0, "F"), (2, 1, "G"), (2, 2, "Z")
    112         ]
    113         for (row, col, letter) in letters {
    114             session.select(row: row, col: col)
    115             session.enter(letter)
    116         }
    117         let firstEvent = try #require(session.completionEvent)
    118 
    119         session.select(row: 2, col: 2)
    120         session.enter("Y")
    121         let secondEvent = try #require(session.completionEvent)
    122 
    123         #expect(session.game.completionState == .filledWithErrors)
    124         #expect(firstEvent.state == .filledWithErrors)
    125         #expect(firstEvent.origin == .local)
    126         #expect(secondEvent.state == .filledWithErrors)
    127         #expect(secondEvent.origin == .local)
    128         #expect(secondEvent.sequence == firstEvent.sequence + 1)
    129     }
    130 
    131     @Test("Solving with local input publishes a local solved completion event")
    132     func localSolvePublishesLocalSolvedEvent() throws {
    133         let session = try makeNavigationSession()
    134 
    135         let solution = [
    136             (0, 0, "A"), (0, 1, "B"), (0, 2, "C"),
    137             (1, 0, "D"),             (1, 2, "E"),
    138             (2, 0, "F"), (2, 1, "G"), (2, 2, "H")
    139         ]
    140         for (row, col, letter) in solution {
    141             session.select(row: row, col: col)
    142             session.enter(letter)
    143         }
    144 
    145         let event = try #require(session.completionEvent)
    146         #expect(session.game.completionState == .solved)
    147         // The win-push path keys on (.local, .solved); a self-solve must land
    148         // there rather than the silent observed path.
    149         #expect(event.state == .solved)
    150         #expect(event.origin == .local)
    151     }
    152 
    153     @Test("Rebus commit trims surrounding whitespace from non-blank entries")
    154     func rebusCommitTrimsSurroundingWhitespace() throws {
    155         let session = try makeNavigationSession()
    156 
    157         session.startRebus()
    158         session.rebusBuffer = " PHI "
    159         session.commitRebus()
    160 
    161         #expect(session.game.squares[0][0].entry == "PHI")
    162         #expect(!session.isRebusActive)
    163         #expect(session.rebusBuffer.isEmpty)
    164     }
    165 
    166     @Test("Rebus commit preserves all-whitespace entries")
    167     func rebusCommitPreservesAllWhitespace() throws {
    168         let session = try makeNavigationSession()
    169 
    170         session.startRebus()
    171         session.rebusBuffer = "   "
    172         session.commitRebus()
    173 
    174         #expect(session.game.squares[0][0].entry == "   ")
    175         #expect(!session.isRebusActive)
    176         #expect(session.rebusBuffer.isEmpty)
    177     }
    178 
    179     @Test("Backspace from first down moves to final across end")
    180     func backspaceFromFirstDownMovesToFinalAcrossEnd() throws {
    181         let session = try makeNavigationSession()
    182         let finalAcross = try #require(session.puzzle.acrossClues.last)
    183         let firstDown = try #require(session.puzzle.downClues.first)
    184 
    185         session.selectClue(direction: .down, number: firstDown.number)
    186         session.deleteBackward()
    187 
    188         #expect(session.direction == .across)
    189         #expect(session.currentClue()?.number == finalAcross.number)
    190         #expect(session.selectedRow == 2)
    191         #expect(session.selectedCol == 2)
    192     }
    193 
    194     @Test("Backspace from first across moves to final down end")
    195     func backspaceFromFirstAcrossMovesToFinalDownEnd() throws {
    196         let session = try makeNavigationSession()
    197         let firstAcross = try #require(session.puzzle.acrossClues.first)
    198         let finalDown = try #require(session.puzzle.downClues.last)
    199 
    200         session.selectClue(direction: .across, number: firstAcross.number)
    201         session.deleteBackward()
    202 
    203         #expect(session.direction == .down)
    204         #expect(session.currentClue()?.number == finalDown.number)
    205         #expect(session.selectedRow == 2)
    206         #expect(session.selectedCol == 2)
    207     }
    208 
    209     @Test("Backspace from final across start moves to previous across end")
    210     func backspaceFromFinalAcrossStartMovesToPreviousAcrossEnd() throws {
    211         let session = try makeNavigationSession()
    212         let firstAcross = try #require(session.puzzle.acrossClues.first)
    213         let finalAcross = try #require(session.puzzle.acrossClues.last)
    214 
    215         session.selectClue(direction: .across, number: finalAcross.number)
    216         session.deleteBackward()
    217 
    218         #expect(session.direction == .across)
    219         #expect(session.currentClue()?.number == firstAcross.number)
    220         #expect(session.selectedRow == 0)
    221         #expect(session.selectedCol == 2)
    222     }
    223 
    224     private func makeNavigationSession() throws -> PlayerSession {
    225         let source = """
    226         Title: Navigation Test
    227         Author: Test
    228 
    229 
    230         ABC
    231         D#E
    232         FGH
    233 
    234 
    235         A1. First across ~ ABC
    236         A3. Final across ~ FGH
    237         D1. First down ~ ADF
    238         D2. Final down ~ CEH
    239         """
    240 
    241         let puzzle = Puzzle(xd: try XD.parse(source))
    242         let game = Game(puzzle: puzzle)
    243         let mutator = GameMutator(game: game, gameID: UUID(), movesUpdater: nil)
    244         return PlayerSession(game: game, mutator: mutator)
    245     }
    246 }