crossmate

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

commit 1da61558ecc3bc3ebf3de174725d65beab54b35b
parent 376ab6278e48755d8ea77e153086ec2447b105d6
Author: Michael Camilleri <[email protected]>
Date:   Wed, 22 Jul 2026 14:14:40 +0900

Let completed puzzles navigate filled clues

A completed puzzle with 'Skip Filled Squares' enabled left the Clue Bar
and iPad Word controls unable to move because every destination was
filtered out.

This commit makes explicit clue and word navigation literal once the
grid is solved or the game's completion is latched, while preserving
filled-cell skipping during active play.

Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>

Diffstat:
MCrossmate/Models/PlayerSession.swift | 17+++++++++++++----
MTests/Unit/PlayerSessionNavigationTests.swift | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 73 insertions(+), 4 deletions(-)

diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift @@ -279,7 +279,7 @@ final class PlayerSession { ordered, from: currentIndex, by: offset, - skipsFilledCells: override ?? preferences?.skipsFilledCells ?? true + skipsFilledCells: override ?? skipsFilledCellsDuringNavigation ) } @@ -297,13 +297,22 @@ final class PlayerSession { clues.map { (direction, $0) }, from: currentIndex, by: offset, - skipsFilledCells: preferences?.skipsFilledCells ?? true + skipsFilledCells: skipsFilledCellsDuringNavigation ) } + /// A solved puzzle has no blank destination to skip to, so explicit clue + /// and word controls become literal even before its durable completion + /// flag is latched. Completed games use the same behaviour defensively if + /// their restored grid has not yet been sealed to the solution. + private var skipsFilledCellsDuringNavigation: Bool { + (preferences?.skipsFilledCells ?? true) + && game.completionState != .solved + && !mutator.isCompleted + } + /// Moves through a clue sequence, skipping filled starting squares and - /// completed clues when that preference is enabled. A fully filled grid - /// has no eligible destination, so the cursor remains where it is. + /// completed clues when that preference is enabled for an active puzzle. private func moveAmongClues( _ clues: [(Puzzle.Direction, Puzzle.Clue)], from currentIndex: Int, diff --git a/Tests/Unit/PlayerSessionNavigationTests.swift b/Tests/Unit/PlayerSessionNavigationTests.swift @@ -234,6 +234,54 @@ struct PlayerSessionNavigationTests { #expect(session.selectedCol == 0) } + @Test("Completed puzzle clue controls ignore Skip Filled Squares") + func completedPuzzleClueControlsIgnoreSkipFilledSquares() throws { + let preferences = try makePreferences() + preferences.skipsFilledCells = true + let session = try makeNavigationSession(preferences: preferences) + solve(session) + session.mutator.isCompleted = true + session.selectClue(direction: .across, number: 1) + + session.goToNextClue() + + #expect(session.direction == .across) + #expect(session.currentClue()?.number == 3) + #expect(session.selectedRow == 2) + #expect(session.selectedCol == 0) + + session.goToPreviousClue() + + #expect(session.direction == .across) + #expect(session.currentClue()?.number == 1) + #expect(session.selectedRow == 0) + #expect(session.selectedCol == 0) + } + + @Test("Completed puzzle word controls ignore Skip Filled Squares") + func completedPuzzleWordControlsIgnoreSkipFilledSquares() throws { + let preferences = try makePreferences() + preferences.skipsFilledCells = true + let session = try makeNavigationSession(preferences: preferences) + solve(session) + session.mutator.isCompleted = true + session.selectClue(direction: .across, number: 1) + + session.goToNextWord() + + #expect(session.direction == .across) + #expect(session.currentClue()?.number == 3) + #expect(session.selectedRow == 2) + #expect(session.selectedCol == 0) + + session.goToPreviousWord() + + #expect(session.direction == .across) + #expect(session.currentClue()?.number == 1) + #expect(session.selectedRow == 0) + #expect(session.selectedCol == 0) + } + @Test("Next letter stays literal at a word boundary") func nextLetterStaysLiteralAtWordBoundary() throws { let session = try makeNavigationSession() @@ -448,4 +496,16 @@ struct PlayerSessionNavigationTests { let defaults = try #require(UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")) return PlayerPreferences(local: defaults, cloud: nil) } + + private func solve(_ session: PlayerSession) { + let solution = [ + (0, 0, "A"), (0, 1, "B"), (0, 2, "C"), + (1, 0, "D"), (1, 2, "E"), + (2, 0, "F"), (2, 1, "G"), (2, 2, "H") + ] + for (row, col, letter) in solution { + session.mutator.setLetter(letter, atRow: row, atCol: col, pencil: false) + } + #expect(session.game.completionState == .solved) + } }