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 }