crossmate

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

commit cfdf5f4db73b4e3b33378e6466d8b4e8cce87417
parent d82f0f161206435992ecb542f9e378dece34d17a
Author: Michael Camilleri <[email protected]>
Date:   Thu,  7 May 2026 00:51:15 +0900

Support XD clue metadata accepted answers

This commit adapts the XD parser to handle clue metadata lines generically.
The ^Accept: field is treated as metadata-derived accepted answers while
leaving the canonical answer from the clue unchanged.

In conjunction with this, conversion from NYT's JSON format to Crossmate's XD
format now emits ^Accept: metadata for cells with moreAnswers, and answer
validation uses accepted answers when checking entries, completion, reveals,
and success-panel contribution counts.

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

Diffstat:
MCrossmate/Models/Game.swift | 10+++++-----
MCrossmate/Models/Puzzle.swift | 21++++++++++++++++++---
MCrossmate/Models/XD.swift | 277+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
MCrossmate/Services/NYTToXDConverter.swift | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MCrossmate/Views/SuccessPanel.swift | 2+-
MTests/Unit/NYTToXDConverterTests.swift | 36+++++++++++++++++++++++++++++++++---
ATests/Unit/XDAcceptTests.swift | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 503 insertions(+), 27 deletions(-)

diff --git a/Crossmate/Models/Game.swift b/Crossmate/Models/Game.swift @@ -69,12 +69,12 @@ final class Game { func checkCells(_ cells: [Puzzle.Cell]) { for cell in cells { guard !cell.isBlock else { continue } - guard let solution = cell.solution else { continue } + guard cell.solution != nil else { continue } let entry = squares[cell.row][cell.col].entry guard !entry.isEmpty else { continue } guard !squares[cell.row][cell.col].mark.isRevealed else { continue } - let isWrong = entry != solution.uppercased() + let isWrong = !cell.accepts(entry) switch squares[cell.row][cell.col].mark { case .pencil: squares[cell.row][cell.col].mark = .pencil(checkedWrong: isWrong) @@ -101,7 +101,7 @@ final class Game { guard let solution = cell.solution else { continue } let expected = solution.uppercased() let oldEntry = squares[cell.row][cell.col].entry - if oldEntry == expected { continue } + if cell.accepts(oldEntry) { continue } squares[cell.row][cell.col].entry = expected squares[cell.row][cell.col].mark = .revealed squares[cell.row][cell.col].letterAuthorID = nil @@ -182,7 +182,7 @@ final class Game { } private func isWrongEntry(_ entry: String, for cell: Puzzle.Cell) -> Bool { - guard !entry.isEmpty, let solution = cell.solution else { return false } - return entry != solution.uppercased() + guard !entry.isEmpty, cell.solution != nil else { return false } + return !cell.accepts(entry) } } diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift @@ -51,6 +51,20 @@ struct Puzzle: Sendable { let isSpecial: Bool let number: Int? let solution: String? + let acceptedSolutions: Set<String> + + func accepts(_ entry: String) -> Bool { + guard !entry.isEmpty else { return false } + let normalizedEntry = Self.normalizedAnswer(entry) + if let solution, normalizedEntry == Self.normalizedAnswer(solution) { + return true + } + return acceptedSolutions.contains(normalizedEntry) + } + + static func normalizedAnswer(_ value: String) -> String { + value.precomposedStringWithCanonicalMapping.uppercased() + } } struct Clue: Sendable, Hashable, Identifiable { @@ -86,8 +100,8 @@ struct Puzzle: Sendable { for c in 0..<xd.width { switch xd.cells[r][c] { case .block: - rowCells.append(Cell(row: r, col: c, isBlock: true, isSpecial: false, number: nil, solution: nil)) - case .open(let solution, let isSpecial): + rowCells.append(Cell(row: r, col: c, isBlock: true, isSpecial: false, number: nil, solution: nil, acceptedSolutions: [])) + case .open(let solution, let acceptedSolutions, let isSpecial): let leftBlock = c == 0 || Self.isBlock(xd.cells, r, c - 1) let rightOpen = c + 1 < xd.width && !Self.isBlock(xd.cells, r, c + 1) let topBlock = r == 0 || Self.isBlock(xd.cells, r - 1, c) @@ -100,7 +114,8 @@ struct Puzzle: Sendable { } else { number = nil } - rowCells.append(Cell(row: r, col: c, isBlock: false, isSpecial: isSpecial, number: number, solution: solution)) + let normalizedAccepted = Set(acceptedSolutions.map { Cell.normalizedAnswer($0) }) + rowCells.append(Cell(row: r, col: c, isBlock: false, isSpecial: isSpecial, number: number, solution: solution, acceptedSolutions: normalizedAccepted)) } } cells.append(rowCells) diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift @@ -32,12 +32,48 @@ struct XD: Sendable { /// lives on `XD.specialKind`). enum Cell: Sendable, Equatable { case block - case open(solution: String?, isSpecial: Bool) + case open(solution: String?, acceptedSolutions: Set<String>, isSpecial: Bool) } struct Clue: Sendable, Equatable { let number: Int let text: String + let answer: String? + let metadata: [String: [String]] + + var acceptedAnswers: [String] { + metadata["Accept", default: []].flatMap(Self.parseEscapedTokens) + } + + private static func parseEscapedTokens(_ source: String) -> [String] { + var tokens: [String] = [] + var current = "" + var escaping = false + + for ch in source { + if escaping { + current.append(ch) + escaping = false + } else if ch == "\\" { + escaping = true + } else if ch.isWhitespace { + if !current.isEmpty { + tokens.append(current) + current = "" + } + } else { + current.append(ch) + } + } + + if escaping { + current.append("\\") + } + if !current.isEmpty { + tokens.append(current) + } + return tokens + } } enum ParseError: Error, CustomStringConvertible { @@ -72,8 +108,9 @@ struct XD: Sendable { let rebus = parseRebusHeader(metadata["Rebus"]) let specialKind = parseSpecialHeader(metadata["Special"]) let relatives = parseRelativesHeader(metadata["Relatives"]) - let (cells, width, height) = try parseGrid(sections[1], rebus: rebus) + let (rawCells, width, height) = try parseGrid(sections[1], rebus: rebus) let (across, down) = try parseClues(sections[2]) + let cells = applyAcceptedAnswers(cells: rawCells, across: across, down: down) return XD( title: metadata["Title"], @@ -273,7 +310,7 @@ struct XD: Sendable { } // '.' is an open cell with no known solution. if ch == "." { - return .open(solution: nil, isSpecial: false) + return .open(solution: nil, acceptedSolutions: [], isSpecial: false) } // Per the .xd spec, lowercase letters always indicate a special cell // (circled or shaded — the kind is in the `Special:` header). They @@ -281,10 +318,10 @@ struct XD: Sendable { // is the rebus expansion; otherwise it's the uppercased letter. let isLowercaseLetter = ch.isLetter && ch.isLowercase if let expansion = rebus[ch] { - return .open(solution: expansion.uppercased(), isSpecial: isLowercaseLetter) + return .open(solution: expansion.uppercased(), acceptedSolutions: [], isSpecial: isLowercaseLetter) } if ch.isLetter { - return .open(solution: String(ch).uppercased(), isSpecial: isLowercaseLetter) + return .open(solution: String(ch).uppercased(), acceptedSolutions: [], isSpecial: isLowercaseLetter) } throw ParseError.unknownGridCharacter(ch) } @@ -294,8 +331,15 @@ struct XD: Sendable { private static func parseClues( _ lines: [String] ) throws -> (across: [Clue], down: [Clue]) { - var across: [Clue] = [] - var down: [Clue] = [] + struct ClueKey: Hashable { + let number: Int + let direction: Character + } + + var clueTexts: [ClueKey: String] = [:] + var clueAnswers: [ClueKey: String] = [:] + var clueOrder: [ClueKey] = [] + var metadataByClue: [ClueKey: [String: [String]]] = [:] for rawLine in lines { let line = rawLine.trimmingCharacters(in: .whitespaces) @@ -304,6 +348,16 @@ struct XD: Sendable { guard let leading = line.first, leading == "A" || leading == "D" else { throw ParseError.malformedClue(line) } + + if let match = line.firstMatch(of: /^([AD])(\d+)\s+\^([^:]+):\s*(.*)$/) { + guard let number = Int(match.2) else { throw ParseError.malformedClue(line) } + let key = ClueKey(number: number, direction: Character(String(match.1))) + let metadataKey = String(match.3).trimmingCharacters(in: .whitespaces) + guard !metadataKey.isEmpty else { throw ParseError.malformedClue(line) } + metadataByClue[key, default: [:]][metadataKey, default: []].append(String(match.4)) + continue + } + guard let dot = line.firstIndex(of: ".") else { throw ParseError.malformedClue(line) } @@ -312,27 +366,222 @@ struct XD: Sendable { guard let number = Int(numberSlice) else { throw ParseError.malformedClue(line) } + let key = ClueKey(number: number, direction: leading) var afterDot = line[line.index(after: dot)...] .trimmingCharacters(in: .whitespaces) - // Strip the trailing " ~ ANSWER" if present. We don't currently - // use the answer (the grid is the source of truth), but the - // separator is part of the format and must not leak into the - // clue text. if let tilde = afterDot.range(of: " ~ ", options: .backwards) { + let answer = afterDot[tilde.upperBound...] + .trimmingCharacters(in: .whitespaces) + if !answer.isEmpty { + clueAnswers[key] = answer + } afterDot = String(afterDot[..<tilde.lowerBound]) .trimmingCharacters(in: .whitespaces) } - let clue = Clue(number: number, text: afterDot) - if leading == "A" { + if clueTexts[key] == nil { + clueOrder.append(key) + } + clueTexts[key] = afterDot + } + + var across: [Clue] = [] + var down: [Clue] = [] + for key in clueOrder { + let clue = Clue( + number: key.number, + text: clueTexts[key] ?? "", + answer: clueAnswers[key], + metadata: metadataByClue[key] ?? [:] + ) + if key.direction == "A" { across.append(clue) } else { down.append(clue) } } - return (across, down) } + + private static func applyAcceptedAnswers( + cells: [[Cell]], + across: [Clue], + down: [Clue] + ) -> [[Cell]] { + var cells = cells + let acrossByNumber = Dictionary(uniqueKeysWithValues: across.map { ($0.number, $0) }) + let downByNumber = Dictionary(uniqueKeysWithValues: down.map { ($0.number, $0) }) + + for r in cells.indices { + for c in cells[r].indices { + guard case .open(let solution, let acceptedSolutions, let isSpecial) = cells[r][c] else { continue } + var merged = acceptedSolutions + if let accepted = acceptedCellValues( + atRow: r, + col: c, + direction: .across, + cells: cells, + cluesByNumber: acrossByNumber + ) { + merged.formUnion(accepted) + } + if let accepted = acceptedCellValues( + atRow: r, + col: c, + direction: .down, + cells: cells, + cluesByNumber: downByNumber + ) { + merged.formUnion(accepted) + } + if merged != acceptedSolutions { + cells[r][c] = .open(solution: solution, acceptedSolutions: merged, isSpecial: isSpecial) + } + } + } + + return cells + } + + private static func acceptedCellValues( + atRow row: Int, + col: Int, + direction: Direction, + cells: [[Cell]], + cluesByNumber: [Int: Clue] + ) -> Set<String>? { + let word = wordCells(fromRow: row, col: col, direction: direction, cells: cells) + guard let wordIndex = word.firstIndex(where: { $0.row == row && $0.col == col }) else { return nil } + guard let number = clueNumber(forWord: word, cells: cells), + let clue = cluesByNumber[number], + !clue.acceptedAnswers.isEmpty else { return nil } + + if word.count == 1 { + return Set(clue.acceptedAnswers) + } + + let solutions = word.compactMap { position -> String? in + guard case .open(let solution?, _, _) = cells[position.row][position.col] else { return nil } + return solution + } + guard solutions.count == word.count else { return nil } + + let canonicalAnswer = clue.answer ?? solutions.joined() + guard canonicalAnswer == solutions.joined() else { return nil } + + var accepted: Set<String> = [] + for acceptedAnswer in clue.acceptedAnswers { + guard let segments = segmentAcceptedAnswer(acceptedAnswer, canonicalSegments: solutions), + segments.indices.contains(wordIndex), + segments[wordIndex] != solutions[wordIndex] else { continue } + accepted.insert(segments[wordIndex]) + } + return accepted + } + + private static func segmentAcceptedAnswer( + _ acceptedAnswer: String, + canonicalSegments: [String] + ) -> [String]? { + let accepted = Array(acceptedAnswer) + let canonical = canonicalSegments.map(Array.init) + + for replacedIndex in canonical.indices { + let prefixLength = canonical[..<replacedIndex].reduce(0) { $0 + $1.count } + let suffixLength = canonical[canonical.index(after: replacedIndex)...].reduce(0) { $0 + $1.count } + guard accepted.count >= prefixLength + suffixLength else { continue } + + let prefix = canonical[..<replacedIndex].flatMap { $0 } + let suffix = canonical[canonical.index(after: replacedIndex)...].flatMap { $0 } + guard accepted.prefix(prefix.count).elementsEqual(prefix) else { continue } + guard accepted.suffix(suffix.count).elementsEqual(suffix) else { continue } + + let replacementEnd = accepted.count - suffixLength + let replacement = Array(accepted[prefixLength..<replacementEnd]) + guard !replacement.isEmpty, replacement != canonical[replacedIndex] else { continue } + + var segments = canonical.map { String($0) } + segments[replacedIndex] = String(replacement) + return segments + } + + return nil + } + + private enum Direction { + case across + case down + + var delta: (row: Int, col: Int) { + switch self { + case .across: return (0, 1) + case .down: return (1, 0) + } + } + } + + private static func clueNumber(atRow row: Int, col: Int, direction: Direction, cells: [[Cell]]) -> Int? { + let word = wordCells(fromRow: row, col: col, direction: direction, cells: cells) + return clueNumber(forWord: word, cells: cells) + } + + private static func clueNumber(forWord word: [(row: Int, col: Int)], cells: [[Cell]]) -> Int? { + guard let first = word.first else { return nil } + return computedNumber(atRow: first.row, col: first.col, cells: cells) + } + + private static func wordCells( + fromRow row: Int, + col: Int, + direction: Direction, + cells: [[Cell]] + ) -> [(row: Int, col: Int)] { + guard !isBlock(cells, row, col) else { return [] } + let delta = direction.delta + var startRow = row + var startCol = col + while isOpen(cells, startRow - delta.row, startCol - delta.col) { + startRow -= delta.row + startCol -= delta.col + } + + var result: [(row: Int, col: Int)] = [] + var r = startRow + var c = startCol + while isOpen(cells, r, c) { + result.append((r, c)) + r += delta.row + c += delta.col + } + return result + } + + private static func computedNumber(atRow row: Int, col: Int, cells: [[Cell]]) -> Int? { + var counter = 1 + for r in cells.indices { + for c in cells[r].indices { + guard isOpen(cells, r, c) else { continue } + let startsAcross = !isOpen(cells, r, c - 1) && isOpen(cells, r, c + 1) + let startsDown = !isOpen(cells, r - 1, c) && isOpen(cells, r + 1, c) + if startsAcross || startsDown { + if r == row, c == col { return counter } + counter += 1 + } + } + } + return nil + } + + private static func isOpen(_ cells: [[Cell]], _ row: Int, _ col: Int) -> Bool { + guard row >= 0, row < cells.count, col >= 0, col < cells[row].count else { return false } + return !isBlock(cells, row, col) + } + + private static func isBlock(_ cells: [[Cell]], _ row: Int, _ col: Int) -> Bool { + guard row >= 0, row < cells.count, col >= 0, col < cells[row].count else { return true } + if case .block = cells[row][col] { return true } + return false + } } diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift @@ -47,9 +47,10 @@ enum NYTToXDConverter { // Each cell is either an empty dict (block) or a dict with "answer", "type", etc. var answers: [String?] = [] // nil = block, String = answer + var acceptedAnswersByCellIndex: [Int: [String]] = [:] var nextRebusKey: Character = "1" - for cell in cells { + for (index, cell) in cells.enumerated() { guard let dict = cell as? [String: Any], !dict.isEmpty else { answers.append(nil) continue @@ -61,6 +62,14 @@ enum NYTToXDConverter { } else { answers.append(answer) } + + if let moreAnswers = dict["moreAnswers"] as? [String: Any], + let valid = moreAnswers["valid"] as? [String] { + let cleaned = valid.filter { !$0.isEmpty && $0 != answer } + if !cleaned.isEmpty { + acceptedAnswersByCellIndex[index] = cleaned + } + } } // -- Build rebus header if needed -- @@ -145,11 +154,25 @@ enum NYTToXDConverter { let prefix = direction == "Across" ? "A" : "D" let line = "\(prefix)\(label). \(clueText) ~ \(answerStr)" + let acceptLine: String? + let acceptedAnswers = acceptedAnswerVariants( + cellIndices: cellIndices, + answers: answers, + acceptedAnswersByCellIndex: acceptedAnswersByCellIndex + ) + if acceptedAnswers.isEmpty { + acceptLine = nil + } else { + let escaped = acceptedAnswers.map(escapeAcceptToken).joined(separator: " ") + acceptLine = "\(prefix)\(label) ^Accept: \(escaped)" + } if direction == "Across" { acrossClueLines.append(line) + if let acceptLine { acrossClueLines.append(acceptLine) } } else { downClueLines.append(line) + if let acceptLine { downClueLines.append(acceptLine) } } } @@ -205,6 +228,41 @@ enum NYTToXDConverter { return sections.joined(separator: "\n\n\n") } + private static func acceptedAnswerVariants( + cellIndices: [Int], + answers: [String?], + acceptedAnswersByCellIndex: [Int: [String]] + ) -> [String] { + var variants: [String] = [] + var seen = Set<String>() + let canonicalParts = cellIndices.map { answers.indices.contains($0) ? answers[$0] ?? "" : "" } + let canonicalAnswer = canonicalParts.joined() + + for (partIndex, cellIndex) in cellIndices.enumerated() { + guard let accepted = acceptedAnswersByCellIndex[cellIndex] else { continue } + for value in accepted { + var parts = canonicalParts + parts[partIndex] = value + let variant = parts.joined() + guard variant != canonicalAnswer, seen.insert(variant).inserted else { continue } + variants.append(variant) + } + } + + return variants + } + + private static func escapeAcceptToken(_ token: String) -> String { + var escaped = "" + for ch in token { + if ch == "\\" || ch.isWhitespace { + escaped.append("\\") + } + escaped.append(ch) + } + return escaped + } + private static func title(forPublicationDate publicationDate: String) -> String { guard let date = date(fromPublicationDate: publicationDate) else { return "NYT Crossword" diff --git a/Crossmate/Views/SuccessPanel.swift b/Crossmate/Views/SuccessPanel.swift @@ -49,7 +49,7 @@ struct SuccessPanel: View { guard !square.mark.isRevealed else { continue } let entry = square.entry guard !entry.isEmpty else { continue } - if let solution = cell.solution, entry != solution.uppercased() { continue } + if cell.solution != nil, !cell.accepts(entry) { continue } counts[square.letterAuthorID, default: 0] += 1 } } diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift @@ -16,9 +16,12 @@ struct NYTToXDConverterTests { private func puzzleJSON( relatives: [[Int]?], formattedClueIndices: Set<Int> = [], - clueTexts: [Int: String] = [:] + clueTexts: [Int: String] = [:], + letters: [String] = ["A", "B", "C", "D", "E", "F", "G", "H", "I"], + moreAnswersByCell: [Int: [String]] = [:] ) throws -> Data { precondition(relatives.count == 6, "Expected 6 clues for a 3×3 open grid") + precondition(letters.count == 9, "Expected 9 cell answers for a 3×3 open grid") let defs: [(label: Int, direction: String, cells: [Int])] = [ (1, "Across", [0, 1, 2]), (4, "Across", [3, 4, 5]), @@ -45,13 +48,19 @@ struct NYTToXDConverterTests { } clueDicts.append(dict) } - let letters = ["A", "B", "C", "D", "E", "F", "G", "H", "I"] + let cells = letters.enumerated().map { index, answer -> [String: Any] in + var cell: [String: Any] = ["answer": answer] + if let moreAnswers = moreAnswersByCell[index] { + cell["moreAnswers"] = ["valid": moreAnswers] + } + return cell + } let root: [String: Any] = [ "publicationDate": "2025-01-01", "constructors": ["Tester"], "body": [[ "dimensions": ["width": 3, "height": 3], - "cells": letters.map { ["answer": $0] }, + "cells": cells, "clues": clueDicts ]] ] @@ -90,6 +99,27 @@ struct NYTToXDConverterTests { #expect(puzzle.publisher == "New York Times") } + @Test("NYT moreAnswers.valid emits XD Accept metadata and round-trips") + func moreAnswersEmitAcceptMetadata() throws { + let data = try puzzleJSON( + relatives: [nil, nil, nil, nil, nil, nil], + letters: ["A", "B", "C", "D", "Φ", "F", "G", "H", "I"], + moreAnswersByCell: [4: ["PHI", "I/O", "NEW YORK", "BACK\\SLASH"]] + ) + let xd = try NYTToXDConverter.convert(jsonData: data) + + #expect(xd.contains("A4 ^Accept: DPHIF DI/OF DNEW\\ YORKF DBACK\\\\SLASHF")) + #expect(xd.contains("D2 ^Accept: BPHIH BI/OH BNEW\\ YORKH BBACK\\\\SLASHH")) + + let puzzle = Puzzle(xd: try XD.parse(xd)) + let cell = puzzle.cells[1][1] + #expect(cell.solution == "Φ") + #expect(cell.accepts("PHI")) + #expect(cell.accepts("I/O")) + #expect(cell.accepts("NEW YORK")) + #expect(cell.accepts("BACK\\SLASH")) + } + @Test("Revealer with ≥2 relatives produces a group") func revealerGroup() throws { // 1A (index 0) references 4A (1) and 5A (2). diff --git a/Tests/Unit/XDAcceptTests.swift b/Tests/Unit/XDAcceptTests.swift @@ -0,0 +1,124 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("XD Accept metadata") +@MainActor +struct XDAcceptTests { + @Test("Clue metadata is parsed generically") + func clueMetadataParsesGenerically() throws { + let source = """ + Title: Metadata Test + + + ABC + + + A1. Gardener's concerns. ~ ABC + A1 ^Refs: A2 D4 + A1 ^Note: first value + A1 ^Note: second value + """ + + let xd = try XD.parse(source) + let clue = try #require(xd.acrossClues.first) + + #expect(clue.text == "Gardener's concerns.") + #expect(clue.answer == "ABC") + #expect(clue.metadata["Refs"] == ["A2 D4"]) + #expect(clue.metadata["Note"] == ["first value", "second value"]) + } + + @Test("Accept metadata parses escaped tokens onto matching rebus cells") + func acceptMetadataParsesEscapedTokens() throws { + let source = """ + Title: Accept Test + Rebus: 1=PHI + + + 1A + + + A1. Greek letter represented in the puzzle. ~ PHI + A1 ^Accept: IO OI I/O NEW\\ YORK A\\|B BACK\\\\SLASH + """ + + let puzzle = Puzzle(xd: try XD.parse(source)) + let acceptClue = try #require(try XD.parse(source).acrossClues.first) + let cell = puzzle.cells[0][0] + + #expect(acceptClue.metadata["Accept"] == ["IO OI I/O NEW\\ YORK A\\|B BACK\\\\SLASH"]) + #expect(acceptClue.acceptedAnswers == ["IO", "OI", "I/O", "NEW YORK", "A|B", "BACK\\SLASH"]) + #expect(cell.accepts("PHI")) + #expect(cell.accepts("io")) + #expect(cell.accepts("NEW YORK")) + #expect(cell.accepts("A|B")) + #expect(cell.accepts("BACK\\SLASH")) + #expect(!cell.accepts("NYC")) + } + + @Test("Accepted answers are case-insensitive and NFC-normalized") + func acceptedAnswersNormalizeForCompletion() throws { + let source = """ + Title: Normalize Test + Rebus: 1=PHI + + + 1A + + + A1. Greek letter represented in the puzzle. ~ PHI + A1 ^Accept: CAF\\É + """ + + let game = Game(puzzle: Puzzle(xd: try XD.parse(source))) + game.setLetter("cafe\u{301}", atRow: 0, atCol: 0, pencil: false) + + #expect(game.completionState == .solved) + } + + @Test("Whole-clue accepted answers project onto changed cells") + func acceptedClueAnswersProjectOntoCells() throws { + let source = """ + Title: Projection Test + Rebus: 1=Φ + + + P1NG + + + A1. Paddle sport starter. ~ PΦNG + A1 ^Accept: PPHING PI/ONG + """ + + let puzzle = Puzzle(xd: try XD.parse(source)) + let cell = puzzle.cells[0][1] + + #expect(cell.solution == "Φ") + #expect(cell.accepts("PHI")) + #expect(cell.accepts("I/O")) + #expect(!puzzle.cells[0][0].accepts("PHI")) + } + + @Test("Check accepts alternate entries") + func checkAcceptsAlternateEntries() throws { + let source = """ + Title: Check Test + Rebus: 1=PHI + + + 1A + + + A1. Greek letter represented in the puzzle. ~ PHI + A1 ^Accept: IO + """ + + let game = Game(puzzle: Puzzle(xd: try XD.parse(source))) + game.setLetter("IO", atRow: 0, atCol: 0, pencil: false) + game.checkCells([game.puzzle.cells[0][0]]) + + #expect(game.squares[0][0].mark == .none) + } +}