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 }