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:
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)
+ }
+}