crossmate

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

commit b190f280d16de7de0a0e1cd3b2a1b0cbcd516b1f
parent e214e94eae963a12f441679a52c1ce68daad627a
Author: Michael Camilleri <[email protected]>
Date:   Thu,  2 Jul 2026 19:30:50 +0900

Require typeable puzzle answers

This commit rejects imported puzzle answers that cannot be entered with
Crossmate's ASCII keyboard. Non-ASCII clue prose remains valid, but each
fillable cell now needs either an ASCII canonical solution or an ASCII
accepted alternative after the XD accepted-answer projection has run.

This keeps puzzles with emoji or other Unicode answer glyphs playable
when they provide an ASCII entry such as PHI or FIRE, while failing
closed for answers that would otherwise leave the user with no way to
solve the cell.

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

Diffstat:
MCrossmate/Models/XD.swift | 34+++++++++++++++++++++++++++++++++-
MTests/Unit/NYTToXDConverterTests.swift | 4++--
MTests/Unit/XDAcceptTests.swift | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
3 files changed, 132 insertions(+), 10 deletions(-)

diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift @@ -124,6 +124,7 @@ struct XD: Sendable { case sourceTooLarge case malformedClue(String) case unknownGridCharacter(Character) + case unsupportedAnswerCharacter(Character) case clueAnswerMismatch(String) case ambiguousClueAnswer(String) case missingInferredSolution(row: Int, col: Int) @@ -142,6 +143,8 @@ struct XD: Sendable { return "malformed .xd clue: \(line)" case .unknownGridCharacter(let ch): return "unknown .xd grid character: \(ch)" + case .unsupportedAnswerCharacter(let ch): + return "unsupported non-ASCII answer character: \(ch)" case .clueAnswerMismatch(let clue): return ".xd clue answer does not match grid: \(clue)" case .ambiguousClueAnswer(let clue): @@ -168,7 +171,7 @@ struct XD: Sendable { return "is too large for Crossmate to open" case .malformedClue: return "has a clue Crossmate couldn't read" - case .unknownGridCharacter: + case .unknownGridCharacter, .unsupportedAnswerCharacter: return "contains a character Crossmate doesn't support" case .clueAnswerMismatch: return "has an answer that doesn't match its grid" @@ -208,6 +211,7 @@ struct XD: Sendable { let numbering = Numbering.build(rawCells) let solvedCells = try applyClueAnswers(cells: rawCells, across: across, down: down, numbering: numbering) let cells = applyAcceptedAnswers(cells: solvedCells, across: across, down: down, numbering: numbering) + try validateASCIIEntryOptions(cells: cells) return XD( title: metadata.first("Title"), @@ -225,6 +229,34 @@ struct XD: Sendable { ) } + private static func validateASCIIEntryOptions(cells: [[Cell]]) throws { + for row in cells { + for cell in row { + guard case .open(let solution, let acceptedSolutions, _) = cell else { continue } + if let solution, isASCIIAnswer(solution) { continue } + if acceptedSolutions.contains(where: isASCIIAnswer) { continue } + if let solution, let ch = firstNonASCIICharacter(in: solution) { + throw ParseError.unsupportedAnswerCharacter(ch) + } + for accepted in acceptedSolutions { + if let ch = firstNonASCIICharacter(in: accepted) { + throw ParseError.unsupportedAnswerCharacter(ch) + } + } + } + } + } + + private static func isASCIIAnswer(_ value: String) -> Bool { + !value.isEmpty && value.unicodeScalars.allSatisfy(\.isASCII) + } + + private static func firstNonASCIICharacter(in value: String) -> Character? { + value.first { character in + character.unicodeScalars.contains { !$0.isASCII } + } + } + // MARK: - Sections /// Splits the source into top-level sections. Per the .xd spec, sections diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift @@ -202,7 +202,7 @@ struct NYTToXDConverterTests { func moreAnswersEmitAcceptMetadata() throws { let data = try puzzleJSON( relatives: [nil, nil, nil, nil, nil, nil], - letters: ["A", "B", "C", "D", "Φ", "F", "G", "H", "I"], + letters: ["A", "B", "C", "D", "IO", "F", "G", "H", "I"], moreAnswersByCell: [4: ["PHI", "I/O", "NEW YORK", "BACK\\SLASH"]] ) let xd = try NYTToXDConverter.convert(jsonData: data) @@ -212,7 +212,7 @@ struct NYTToXDConverterTests { let puzzle = Puzzle(xd: try XD.parse(xd)) let cell = puzzle.cells[1][1] - #expect(cell.solution == "Φ") + #expect(cell.solution == "IO") #expect(cell.accepts("PHI")) #expect(cell.accepts("I/O")) #expect(cell.accepts("NEW YORK")) diff --git a/Tests/Unit/XDAcceptTests.swift b/Tests/Unit/XDAcceptTests.swift @@ -258,8 +258,8 @@ struct XDAcceptTests { #expect(!cell.accepts("NYC")) } - @Test("Accepted answers are case-insensitive and NFC-normalized") - func acceptedAnswersNormalizeForCompletion() throws { + @Test("Accepted answers are case-insensitive") + func acceptedAnswersNormalizeCaseForCompletion() throws { let source = """ Title: Normalize Test Rebus: 1=PHI @@ -269,33 +269,123 @@ struct XDAcceptTests { A1. Greek letter represented in the puzzle. ~ PHI - A1 ^Accept: CAF\\É + A1 ^Accept: PHI """ let game = Game(puzzle: Puzzle(xd: try XD.parse(source))) - game.setLetter("cafe\u{301}", atRow: 0, atCol: 0, pencil: false) + game.setLetter("phi", atRow: 0, atCol: 0, pencil: false) #expect(game.completionState == .solved) } + @Test("Non-ASCII clue prose is allowed") + func nonASCIIClueProseIsAllowed() throws { + let puzzle = Puzzle(xd: try XD.parse(""" + Title: Clue Emoji + + + A + + + A1. Emoji clue is fine 🔥 ~ A + D1. Accented clue is fine café ~ A + """)) + + #expect(puzzle.acrossClues[0].text == "Emoji clue is fine 🔥") + #expect(puzzle.downClues[0].text == "Accented clue is fine café") + #expect(puzzle.cells[0][0].solution == "A") + } + + @Test("Non-ASCII answer content is rejected without an ASCII alternative") + func nonASCIIAnswerContentIsRejectedWithoutASCIIAlternative() { + #expect(throws: XD.ParseError.self) { + try XD.parse(""" + Title: Emoji Answer + Rebus: 1=🔥 + + + 1A + BA + + + A1. Hot stuff ~ 🔥A + D1. Hot stuff ~ 🔥B + """) + } + + #expect(throws: XD.ParseError.self) { + try XD.parse(""" + Title: Unicode Grid + + + ΦA + BA + + + A1. Greek letter ~ ΦA + D1. Greek letter ~ ΦB + """) + } + } + + @Test("Non-ASCII answer content is allowed with an ASCII alternative") + func nonASCIIAnswerContentIsAllowedWithASCIIAlternative() throws { + let emojiPuzzle = Puzzle(xd: try XD.parse(""" + Title: Emoji Answer + Rebus: 1=🔥 + + + 1A + BA + + + A1. Hot stuff ~ 🔥A + A1 ^Accept: FIREA + D1. Hot stuff ~ 🔥B + D1 ^Accept: FIREB + """)) + + let emojiCell = emojiPuzzle.cells[0][0] + #expect(emojiCell.solution == "🔥") + #expect(emojiCell.accepts("FIRE")) + + let greekPuzzle = Puzzle(xd: try XD.parse(""" + Title: Unicode Grid + + + ΦA + BA + + + A1. Greek letter ~ ΦA + A1 ^Accept: PHIA + D1. Greek letter ~ ΦB + D1 ^Accept: PHIB + """)) + + let greekCell = greekPuzzle.cells[0][0] + #expect(greekCell.solution == "Φ") + #expect(greekCell.accepts("PHI")) + } + @Test("Whole-clue accepted answers project onto changed cells") func acceptedClueAnswersProjectOntoCells() throws { let source = """ Title: Projection Test - Rebus: 1=Φ + Rebus: 1=IO P1NG - A1. Paddle sport starter. ~ PΦNG + A1. Paddle sport starter. ~ PIONG A1 ^Accept: PPHING PI/ONG """ let puzzle = Puzzle(xd: try XD.parse(source)) let cell = puzzle.cells[0][1] - #expect(cell.solution == "Φ") + #expect(cell.solution == "IO") #expect(cell.accepts("PHI")) #expect(cell.accepts("I/O")) #expect(!puzzle.cells[0][0].accepts("PHI"))