crossmate

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

PlayerSessionNavigationTests.swift (20576B)


      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.mutator.setLetter("F", atRow: 2, atCol: 0, pencil: false)
     82         session.mutator.setLetter("G", atRow: 2, atCol: 1, pencil: false)
     83         session.select(row: 2, col: 2)
     84         session.setDirection(.across)
     85         session.enter("H")
     86 
     87         #expect(session.direction == .down)
     88         #expect(session.currentClue()?.number == firstDown.number)
     89     }
     90 
     91     @Test("Typing past final down moves to first across")
     92     func typingPastFinalDownMovesToFirstAcross() throws {
     93         let session = try makeNavigationSession()
     94         let firstAcross = try #require(session.puzzle.acrossClues.first)
     95         let finalDown = try #require(session.puzzle.downClues.last)
     96 
     97         session.selectClue(direction: .down, number: finalDown.number)
     98         session.mutator.setLetter("C", atRow: 0, atCol: 2, pencil: false)
     99         session.mutator.setLetter("E", atRow: 1, atCol: 2, pencil: false)
    100         session.select(row: 2, col: 2)
    101         session.setDirection(.down)
    102         session.enter("H")
    103 
    104         #expect(session.direction == .across)
    105         #expect(session.currentClue()?.number == firstAcross.number)
    106     }
    107 
    108     @Test("Typing skips a filled cell by default")
    109     func typingSkipsFilledCellByDefault() throws {
    110         let session = try makeNavigationSession()
    111         session.mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false)
    112 
    113         session.enter("A")
    114 
    115         #expect(session.selectedRow == 0)
    116         #expect(session.selectedCol == 2)
    117     }
    118 
    119     @Test("Typing advances to a filled cell when skipping is disabled")
    120     func typingDoesNotSkipFilledCellWhenDisabled() throws {
    121         let defaults = try #require(UserDefaults(suiteName: "test-pref-\(UUID().uuidString)"))
    122         let preferences = PlayerPreferences(local: defaults, cloud: nil)
    123         preferences.skipsFilledCells = false
    124         let session = try makeNavigationSession(preferences: preferences)
    125         session.mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false)
    126 
    127         session.enter("A")
    128 
    129         #expect(session.selectedRow == 0)
    130         #expect(session.selectedCol == 1)
    131     }
    132 
    133     @Test("Typing at word end wraps to its first blank")
    134     func typingAtWordEndWrapsToFirstBlank() throws {
    135         let preferences = try makePreferences()
    136         preferences.answerEndMovement = .wrapInCurrentAnswer
    137         let session = try makeNavigationSession(preferences: preferences)
    138         session.mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
    139         session.select(row: 0, col: 2)
    140         session.setDirection(.across)
    141 
    142         session.enter("C")
    143 
    144         #expect(session.direction == .across)
    145         #expect(session.selectedRow == 0)
    146         #expect(session.selectedCol == 1)
    147     }
    148 
    149     @Test("Moving to the next answer leaves an earlier blank behind")
    150     func movingToNextAnswerLeavesEarlierBlank() throws {
    151         let preferences = try makePreferences()
    152         preferences.answerEndMovement = .moveToNextAnswer
    153         let session = try makeNavigationSession(preferences: preferences)
    154         session.mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false)
    155         session.select(row: 0, col: 2)
    156         session.setDirection(.across)
    157 
    158         session.enter("C")
    159 
    160         #expect(session.direction == .across)
    161         #expect(session.currentClue()?.number == 3)
    162         #expect(session.selectedRow == 2)
    163         #expect(session.selectedCol == 0)
    164     }
    165 
    166     @Test("Doing nothing leaves the cursor on the last fill")
    167     func doingNothingLeavesCursorOnLastFill() throws {
    168         let preferences = try makePreferences()
    169         preferences.answerEndMovement = .doNothing
    170         let session = try makeNavigationSession(preferences: preferences)
    171         session.mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false)
    172         session.select(row: 0, col: 2)
    173         session.setDirection(.across)
    174 
    175         session.enter("C")
    176 
    177         #expect(session.direction == .across)
    178         #expect(session.selectedRow == 0)
    179         #expect(session.selectedCol == 2)
    180     }
    181 
    182     @Test("Wrapping with skipping stays put when no blanks remain")
    183     func wrappingWithSkippingStaysPutWithoutBlanks() throws {
    184         let preferences = try makePreferences()
    185         preferences.answerEndMovement = .wrapInCurrentAnswer
    186         let session = try makeNavigationSession(preferences: preferences)
    187         session.mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
    188         session.mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false)
    189         session.select(row: 0, col: 2)
    190         session.setDirection(.across)
    191 
    192         session.enter("C")
    193 
    194         #expect(session.direction == .across)
    195         #expect(session.selectedRow == 0)
    196         #expect(session.selectedCol == 2)
    197     }
    198 
    199     @Test("Wrapping without skipping returns to the first square")
    200     func wrappingWithoutSkippingReturnsToFirstSquare() throws {
    201         let preferences = try makePreferences()
    202         preferences.skipsFilledCells = false
    203         preferences.answerEndMovement = .wrapInCurrentAnswer
    204         let session = try makeNavigationSession(preferences: preferences)
    205         session.mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
    206         session.select(row: 0, col: 2)
    207         session.setDirection(.across)
    208 
    209         session.enter("C")
    210 
    211         #expect(session.direction == .across)
    212         #expect(session.selectedRow == 0)
    213         #expect(session.selectedCol == 0)
    214     }
    215 
    216     @Test("Next clue selects its first blank square")
    217     func nextClueSelectsFirstBlankSquare() throws {
    218         let session = try makeNavigationSession()
    219         session.mutator.setLetter("F", atRow: 2, atCol: 0, pencil: false)
    220 
    221         session.goToNextClue()
    222 
    223         #expect(session.direction == .across)
    224         #expect(session.currentClue()?.number == 3)
    225         #expect(session.selectedRow == 2)
    226         #expect(session.selectedCol == 1)
    227     }
    228 
    229     @Test("Previous clue selects its first blank square")
    230     func previousClueSelectsFirstBlankSquare() throws {
    231         let session = try makeNavigationSession()
    232         session.mutator.setLetter("C", atRow: 0, atCol: 2, pencil: false)
    233 
    234         session.goToPreviousClue()
    235 
    236         #expect(session.direction == .down)
    237         #expect(session.currentClue()?.number == 2)
    238         #expect(session.selectedRow == 1)
    239         #expect(session.selectedCol == 2)
    240     }
    241 
    242     @Test("Next clue skips a completed word")
    243     func nextClueSkipsCompletedWord() throws {
    244         let session = try makeNavigationSession()
    245         session.mutator.setLetter("F", atRow: 2, atCol: 0, pencil: false)
    246         session.mutator.setLetter("G", atRow: 2, atCol: 1, pencil: false)
    247         session.mutator.setLetter("H", atRow: 2, atCol: 2, pencil: false)
    248 
    249         session.goToNextClue()
    250 
    251         #expect(session.direction == .down)
    252         #expect(session.currentClue()?.number == 1)
    253         #expect(session.selectedRow == 0)
    254         #expect(session.selectedCol == 0)
    255     }
    256 
    257     @Test("Next clue can land on a filled square when skipping is disabled")
    258     func nextClueCanLandOnFilledSquareWhenSkippingDisabled() throws {
    259         let preferences = try makePreferences()
    260         preferences.skipsFilledCells = false
    261         let session = try makeNavigationSession(preferences: preferences)
    262         session.mutator.setLetter("F", atRow: 2, atCol: 0, pencil: false)
    263 
    264         session.goToNextClue()
    265 
    266         #expect(session.direction == .across)
    267         #expect(session.currentClue()?.number == 3)
    268         #expect(session.selectedRow == 2)
    269         #expect(session.selectedCol == 0)
    270     }
    271 
    272     @Test("Completed puzzle clue controls ignore Skip Filled Squares")
    273     func completedPuzzleClueControlsIgnoreSkipFilledSquares() throws {
    274         let preferences = try makePreferences()
    275         preferences.skipsFilledCells = true
    276         let session = try makeNavigationSession(preferences: preferences)
    277         solve(session)
    278         session.mutator.isCompleted = true
    279         session.selectClue(direction: .across, number: 1)
    280 
    281         session.goToNextClue()
    282 
    283         #expect(session.direction == .across)
    284         #expect(session.currentClue()?.number == 3)
    285         #expect(session.selectedRow == 2)
    286         #expect(session.selectedCol == 0)
    287 
    288         session.goToPreviousClue()
    289 
    290         #expect(session.direction == .across)
    291         #expect(session.currentClue()?.number == 1)
    292         #expect(session.selectedRow == 0)
    293         #expect(session.selectedCol == 0)
    294     }
    295 
    296     @Test("Completed puzzle word controls ignore Skip Filled Squares")
    297     func completedPuzzleWordControlsIgnoreSkipFilledSquares() throws {
    298         let preferences = try makePreferences()
    299         preferences.skipsFilledCells = true
    300         let session = try makeNavigationSession(preferences: preferences)
    301         solve(session)
    302         session.mutator.isCompleted = true
    303         session.selectClue(direction: .across, number: 1)
    304 
    305         session.goToNextWord()
    306 
    307         #expect(session.direction == .across)
    308         #expect(session.currentClue()?.number == 3)
    309         #expect(session.selectedRow == 2)
    310         #expect(session.selectedCol == 0)
    311 
    312         session.goToPreviousWord()
    313 
    314         #expect(session.direction == .across)
    315         #expect(session.currentClue()?.number == 1)
    316         #expect(session.selectedRow == 0)
    317         #expect(session.selectedCol == 0)
    318     }
    319 
    320     @Test("Next letter stays literal at a word boundary")
    321     func nextLetterStaysLiteralAtWordBoundary() throws {
    322         let session = try makeNavigationSession()
    323         session.mutator.setLetter("F", atRow: 2, atCol: 0, pencil: false)
    324         session.select(row: 0, col: 2)
    325         session.setDirection(.across)
    326 
    327         session.goToNextLetter()
    328 
    329         #expect(session.direction == .across)
    330         #expect(session.currentClue()?.number == 3)
    331         #expect(session.selectedRow == 2)
    332         #expect(session.selectedCol == 0)
    333     }
    334 
    335     @Test("Previous letter stays literal at a word boundary")
    336     func previousLetterStaysLiteralAtWordBoundary() throws {
    337         let session = try makeNavigationSession()
    338         session.mutator.setLetter("C", atRow: 0, atCol: 2, pencil: false)
    339         session.selectClue(direction: .across, number: 3)
    340 
    341         session.goToPreviousLetter()
    342 
    343         #expect(session.direction == .across)
    344         #expect(session.currentClue()?.number == 1)
    345         #expect(session.selectedRow == 0)
    346         #expect(session.selectedCol == 2)
    347     }
    348 
    349     @Test("Overwriting a full wrong grid publishes a fresh error completion event")
    350     func overwriteFullWrongGridPublishesFreshErrorEvent() throws {
    351         let session = try makeNavigationSession()
    352 
    353         let letters = [
    354             (0, 0, "A"), (0, 1, "B"), (0, 2, "C"),
    355             (1, 0, "D"),             (1, 2, "E"),
    356             (2, 0, "F"), (2, 1, "G"), (2, 2, "Z")
    357         ]
    358         for (row, col, letter) in letters {
    359             session.select(row: row, col: col)
    360             session.enter(letter)
    361         }
    362         let firstEvent = try #require(session.completionEvent)
    363 
    364         session.select(row: 2, col: 2)
    365         session.enter("Y")
    366         let secondEvent = try #require(session.completionEvent)
    367 
    368         #expect(session.game.completionState == .filledWithErrors)
    369         #expect(firstEvent.state == .filledWithErrors)
    370         #expect(firstEvent.origin == .local)
    371         #expect(secondEvent.state == .filledWithErrors)
    372         #expect(secondEvent.origin == .local)
    373         #expect(secondEvent.sequence == firstEvent.sequence + 1)
    374     }
    375 
    376     @Test("Solving with local input publishes a local solved completion event")
    377     func localSolvePublishesLocalSolvedEvent() throws {
    378         let session = try makeNavigationSession()
    379 
    380         let solution = [
    381             (0, 0, "A"), (0, 1, "B"), (0, 2, "C"),
    382             (1, 0, "D"),             (1, 2, "E"),
    383             (2, 0, "F"), (2, 1, "G"), (2, 2, "H")
    384         ]
    385         for (row, col, letter) in solution {
    386             session.select(row: row, col: col)
    387             session.enter(letter)
    388         }
    389 
    390         let event = try #require(session.completionEvent)
    391         #expect(session.game.completionState == .solved)
    392         // The win-push path keys on (.local, .solved); a self-solve must land
    393         // there rather than the silent observed path.
    394         #expect(event.state == .solved)
    395         #expect(event.origin == .local)
    396     }
    397 
    398     @Test("Rebus commit trims surrounding whitespace from non-blank entries")
    399     func rebusCommitTrimsSurroundingWhitespace() throws {
    400         let session = try makeNavigationSession()
    401 
    402         session.startRebus()
    403         session.rebusBuffer = " PHI "
    404         session.commitRebus()
    405 
    406         #expect(session.game.squares[0][0].entry == "PHI")
    407         #expect(!session.isRebusActive)
    408         #expect(session.rebusBuffer.isEmpty)
    409     }
    410 
    411     @Test("Rebus commit treats all-whitespace entries as empty")
    412     func rebusCommitTreatsAllWhitespaceAsEmpty() throws {
    413         let session = try makeNavigationSession()
    414 
    415         session.startRebus()
    416         session.rebusBuffer = "   "
    417         session.commitRebus()
    418 
    419         #expect(session.game.squares[0][0].entry.isEmpty)
    420         #expect(session.game.completionState == .incomplete)
    421         #expect(!session.isRebusActive)
    422         #expect(session.rebusBuffer.isEmpty)
    423     }
    424 
    425     @Test("Whitespace rebus commit leaves gap cells solved")
    426     func whitespaceRebusCommitLeavesGapCellsSolved() throws {
    427         let source = #"""
    428         Title: Gap
    429         CmVer: 5
    430         Rebus: 1=\space
    431 
    432 
    433         TO1BE
    434 
    435 
    436         A1. Repeated part of a soliloquy ~ TO BE
    437         """#
    438         let puzzle = Puzzle(xd: try XD.parse(source))
    439         let game = Game(puzzle: puzzle)
    440         let mutator = GameMutator(game: game, gameID: UUID(), movesUpdater: nil)
    441         let session = PlayerSession(game: game, mutator: mutator)
    442 
    443         for (col, letter) in [(0, "T"), (1, "O"), (3, "B"), (4, "E")] {
    444             session.select(row: 0, col: col)
    445             session.enter(letter)
    446         }
    447         #expect(game.completionState == .solved)
    448 
    449         session.select(row: 0, col: 2)
    450         session.startRebus()
    451         session.rebusBuffer = "   "
    452         session.commitRebus()
    453 
    454         #expect(game.squares[0][2].entry.isEmpty)
    455         #expect(game.completionState == .solved)
    456         #expect(!session.isRebusActive)
    457         #expect(session.rebusBuffer.isEmpty)
    458     }
    459 
    460     @Test("Backspace from first down moves to final across end")
    461     func backspaceFromFirstDownMovesToFinalAcrossEnd() throws {
    462         let session = try makeNavigationSession()
    463         let finalAcross = try #require(session.puzzle.acrossClues.last)
    464         let firstDown = try #require(session.puzzle.downClues.first)
    465 
    466         session.selectClue(direction: .down, number: firstDown.number)
    467         session.deleteBackward()
    468 
    469         #expect(session.direction == .across)
    470         #expect(session.currentClue()?.number == finalAcross.number)
    471         #expect(session.selectedRow == 2)
    472         #expect(session.selectedCol == 2)
    473     }
    474 
    475     @Test("Backspace from first across moves to final down end")
    476     func backspaceFromFirstAcrossMovesToFinalDownEnd() throws {
    477         let session = try makeNavigationSession()
    478         let firstAcross = try #require(session.puzzle.acrossClues.first)
    479         let finalDown = try #require(session.puzzle.downClues.last)
    480 
    481         session.selectClue(direction: .across, number: firstAcross.number)
    482         session.deleteBackward()
    483 
    484         #expect(session.direction == .down)
    485         #expect(session.currentClue()?.number == finalDown.number)
    486         #expect(session.selectedRow == 2)
    487         #expect(session.selectedCol == 2)
    488     }
    489 
    490     @Test("Backspace from final across start moves to previous across end")
    491     func backspaceFromFinalAcrossStartMovesToPreviousAcrossEnd() throws {
    492         let session = try makeNavigationSession()
    493         let firstAcross = try #require(session.puzzle.acrossClues.first)
    494         let finalAcross = try #require(session.puzzle.acrossClues.last)
    495 
    496         session.selectClue(direction: .across, number: finalAcross.number)
    497         session.deleteBackward()
    498 
    499         #expect(session.direction == .across)
    500         #expect(session.currentClue()?.number == firstAcross.number)
    501         #expect(session.selectedRow == 0)
    502         #expect(session.selectedCol == 2)
    503     }
    504 
    505     private func makeNavigationSession(
    506         preferences: PlayerPreferences? = nil
    507     ) throws -> PlayerSession {
    508         let source = """
    509         Title: Navigation Test
    510         Author: Test
    511 
    512 
    513         ABC
    514         D#E
    515         FGH
    516 
    517 
    518         A1. First across ~ ABC
    519         A3. Final across ~ FGH
    520         D1. First down ~ ADF
    521         D2. Final down ~ CEH
    522         """
    523 
    524         let puzzle = Puzzle(xd: try XD.parse(source))
    525         let game = Game(puzzle: puzzle)
    526         let mutator = GameMutator(game: game, gameID: UUID(), movesUpdater: nil)
    527         return PlayerSession(game: game, mutator: mutator, preferences: preferences)
    528     }
    529 
    530     private func makePreferences() throws -> PlayerPreferences {
    531         let defaults = try #require(UserDefaults(suiteName: "test-pref-\(UUID().uuidString)"))
    532         return PlayerPreferences(local: defaults, cloud: nil)
    533     }
    534 
    535     private func solve(_ session: PlayerSession) {
    536         let solution = [
    537             (0, 0, "A"), (0, 1, "B"), (0, 2, "C"),
    538             (1, 0, "D"),             (1, 2, "E"),
    539             (2, 0, "F"), (2, 1, "G"), (2, 2, "H")
    540         ]
    541         for (row, col, letter) in solution {
    542             session.mutator.setLetter(letter, atRow: row, atCol: col, pencil: false)
    543         }
    544         #expect(session.game.completionState == .solved)
    545     }
    546 }