crossmate

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

commit 46a8f86ec2037ad71368553fd944841dae1c72b2
parent e4f3eda2a2463b946a69887865b437b1c387fbdd
Author: Michael Camilleri <[email protected]>
Date:   Mon, 11 May 2026 01:13:38 +0900

Fix movement when typing last letter in a direction

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Models/PlayerSession.swift | 15+++++----------
ATests/Unit/PlayerSessionNavigationTests.swift | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 109 insertions(+), 10 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 00F2108848ADC7B4BF3AA0AE /* PlayerSessionNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46801B570FC0B2C791ECDED3 /* PlayerSessionNavigationTests.swift */; }; 014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */; }; 0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */; }; 02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */; }; @@ -131,6 +132,7 @@ 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerPreferences.swift; sourceTree = "<group>"; }; 462CE0FD356F6137C9BFD30F /* ImportService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportService.swift; sourceTree = "<group>"; }; 465F2BB469EFE84CF3733398 /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; }; + 46801B570FC0B2C791ECDED3 /* PlayerSessionNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSessionNavigationTests.swift; sourceTree = "<group>"; }; 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationStateTests.swift; sourceTree = "<group>"; }; 4803C2E84FC5B3BFE593171D /* NameBroadcaster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBroadcaster.swift; sourceTree = "<group>"; }; 4AF633D73818BD59F759FAC4 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; }; @@ -251,6 +253,7 @@ ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */, C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */, 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */, + 46801B570FC0B2C791ECDED3 /* PlayerSessionNavigationTests.swift */, ACD511B95227C7D57F32A2AC /* PresencePublisherTests.swift */, FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */, B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */, @@ -511,6 +514,7 @@ C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */, 014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */, 04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */, + 00F2108848ADC7B4BF3AA0AE /* PlayerSessionNavigationTests.swift in Sources */, 090039C84FAE212D5B0EA03F /* PresencePublisherTests.swift in Sources */, F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */, 7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */, diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift @@ -384,10 +384,10 @@ final class PlayerSession { let r = selectedRow + dr let c = selectedCol + dc // If we're still inside the current word, step one cell. Otherwise - // we've hit the end of the word, so jump to the next clue in the same - // direction by number. Without that, we'd fall through the block into - // a different word in the same row/column — which for down clues is - // almost never the next clue by number. + // we've hit the end of the word, so jump to the next clue in the full + // clue list. Without that, we'd fall through the block into a different + // word in the same row/column — which for down clues is almost never + // the next clue by number. if isValid(row: r, col: c) && !puzzle.cells[r][c].isBlock { selectedRow = r selectedCol = c @@ -397,12 +397,7 @@ final class PlayerSession { } private func advanceToNextClue() { - let clues = direction == .across ? puzzle.acrossClues : puzzle.downClues - guard !clues.isEmpty else { return } - let currentNumber = currentClueNumber() - let currentIndex = clues.firstIndex { $0.number == currentNumber } ?? -1 - let nextIndex = (currentIndex + 1) % clues.count - moveToClueStart(number: clues[nextIndex].number) + moveClue(by: +1) } private func retreat() { diff --git a/Tests/Unit/PlayerSessionNavigationTests.swift b/Tests/Unit/PlayerSessionNavigationTests.swift @@ -0,0 +1,100 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("PlayerSession navigation", .serialized) +@MainActor +struct PlayerSessionNavigationTests { + @Test("Next clue from final across moves to first down") + func nextClueFromFinalAcrossMovesToFirstDown() throws { + let session = try makeNavigationSession() + let finalAcross = try #require(session.puzzle.acrossClues.last) + let firstDown = try #require(session.puzzle.downClues.first) + + session.selectClue(direction: .across, number: finalAcross.number) + session.goToNextClue() + + #expect(session.direction == .down) + #expect(session.currentClue()?.number == firstDown.number) + } + + @Test("Previous clue from first down moves to final across") + func previousClueFromFirstDownMovesToFinalAcross() throws { + let session = try makeNavigationSession() + let finalAcross = try #require(session.puzzle.acrossClues.last) + let firstDown = try #require(session.puzzle.downClues.first) + + session.selectClue(direction: .down, number: firstDown.number) + session.goToPreviousClue() + + #expect(session.direction == .across) + #expect(session.currentClue()?.number == finalAcross.number) + } + + @Test("Next clue from final down moves to first across") + func nextClueFromFinalDownMovesToFirstAcross() throws { + let session = try makeNavigationSession() + let firstAcross = try #require(session.puzzle.acrossClues.first) + let finalDown = try #require(session.puzzle.downClues.last) + + session.selectClue(direction: .down, number: finalDown.number) + session.goToNextClue() + + #expect(session.direction == .across) + #expect(session.currentClue()?.number == firstAcross.number) + } + + @Test("Typing past final across moves to first down") + func typingPastFinalAcrossMovesToFirstDown() throws { + let session = try makeNavigationSession() + let finalAcross = try #require(session.puzzle.acrossClues.last) + let firstDown = try #require(session.puzzle.downClues.first) + + session.selectClue(direction: .across, number: finalAcross.number) + session.select(row: 2, col: 2) + session.setDirection(.across) + session.enter("H") + + #expect(session.direction == .down) + #expect(session.currentClue()?.number == firstDown.number) + } + + @Test("Typing past final down moves to first across") + func typingPastFinalDownMovesToFirstAcross() throws { + let session = try makeNavigationSession() + let firstAcross = try #require(session.puzzle.acrossClues.first) + let finalDown = try #require(session.puzzle.downClues.last) + + session.selectClue(direction: .down, number: finalDown.number) + session.select(row: 2, col: 2) + session.setDirection(.down) + session.enter("H") + + #expect(session.direction == .across) + #expect(session.currentClue()?.number == firstAcross.number) + } + + private func makeNavigationSession() throws -> PlayerSession { + let source = """ + Title: Navigation Test + Author: Test + + + ABC + D#E + FGH + + + A1. First across ~ ABC + A3. Final across ~ FGH + D1. First down ~ ADF + D2. Final down ~ CEH + """ + + let puzzle = Puzzle(xd: try XD.parse(source)) + let game = Game(puzzle: puzzle) + let mutator = GameMutator(game: game, gameID: UUID(), movesUpdater: nil) + return PlayerSession(game: game, mutator: mutator) + } +}