crossmate

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

commit 6cfa469467c4937df01dc14a4cef87cbdecc8fa5
parent 5e3014a92bbbec090ce446cea6606b0ee847beff
Author: Michael Camilleri <[email protected]>
Date:   Thu,  2 Jul 2026 13:11:46 +0900

Keep the rebus fill when a converted cell is also circled

This commit fixes a precedence collision in the .puz and JSON grid
writers. Both emitted the circle (or shade) marker before checking for a
rebus placeholder, so a cell that was both circled and rebus wrote `@`
to the grid — which parses as a solution-less circled cell. The
multi-character fill vanished from the grid while the `Rebus:` header
still referenced a placeholder key that never appeared and the clue
answer still carried the full fill, leaving the converted puzzle
internally inconsistent.

The placeholder now wins in both converters. A grid character can only
say one thing, and the XD dialect cannot express circled-and-rebus in a
single cell — placeholders are digits and symbols, and the parser
attaches specials only to `@`, `*` and lowercase letters — so the
correct fill takes precedence and only that cell's circle is lost.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate/Services/NYTToXDConverter.swift | 16+++++++++++-----
MCrossmate/Services/PUZToXDConverter.swift | 13+++++++++----
MTests/Unit/NYTToXDConverterTests.swift | 20++++++++++++++++++++
MTests/Unit/PUZToXDConverterTests.swift | 35+++++++++++++++++++++++++++++++++++
4 files changed, 75 insertions(+), 9 deletions(-)

diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift @@ -161,6 +161,16 @@ enum NYTToXDConverter { line += "#" continue } + // A cell can be both special and rebus, but a grid character + // can only say one thing: `@`/`*` parse as solution-less + // special cells, so emitting them here would drop the + // multi-char fill and leave the `Rebus:` header referencing a + // key absent from the grid. The fill wins; the special marker + // on that one cell is lost. + if needsRebusEncoding(answer) { + line += String(rebusLookup[answer]!) + continue + } if specialCells.circled.contains(index) { line += "@" continue @@ -169,11 +179,7 @@ enum NYTToXDConverter { line += "*" continue } - if needsRebusEncoding(answer) { - line += String(rebusLookup[answer]!) - } else { - line += answer.uppercased() - } + line += answer.uppercased() } gridLines.append(line) } diff --git a/Crossmate/Services/PUZToXDConverter.swift b/Crossmate/Services/PUZToXDConverter.swift @@ -124,14 +124,19 @@ enum PUZToXDConverter { line += "#" continue } - if circledCells.contains(index) { - line += "@" - continue - } + // A cell can be both circled and rebus, but a grid character + // can only say one thing: `@` parses as a solution-less circled + // cell, so emitting it here would drop the multi-char fill and + // leave the `Rebus:` header referencing a key absent from the + // grid. The fill wins; the circle on that one cell is lost. if let key = rebusKeys[index] { line.append(key) continue } + if circledCells.contains(index) { + line += "@" + continue + } line += String(UnicodeScalar(byte)).uppercased() } return line diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift @@ -380,6 +380,26 @@ struct NYTToXDConverterTests { #expect(puzzle.cells[1][1].special == .shaded) } + @Test("A cell both circled and rebus keeps its fill over the circle") + func circledRebusCellKeepsRebusFill() throws { + // The grid can't say both: '@' parses as a solution-less circled cell, + // so letting the circle win (the old precedence) dropped the rebus + // placeholder from the grid while the Rebus header still referenced it. + // The fill must win; only that cell's circle is sacrificed. + let data = try puzzleJSON( + relatives: [nil, nil, nil, nil, nil, nil], + letters: ["A", "B", "C", "D", "HEART", "F", "G", "H", "I"], + cellTypes: [0: 2, 4: 2] + ) + let xd = try NYTToXDConverter.convert(jsonData: data) + + #expect(header("Rebus", in: xd) == "1=HEART") + #expect(xd.contains("\n@BC\nD1F\nGHI\n")) + let puzzle = Puzzle(xd: try XD.parse(xd)) + #expect(puzzle.cells[1][1].solution == "HEART") + #expect(puzzle.cells[0][0].special == .circled) + } + @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/PUZToXDConverterTests.swift b/Tests/Unit/PUZToXDConverterTests.swift @@ -206,6 +206,41 @@ struct PUZToXDConverterTests { #expect(puzzle.cells[1][1].solution == "HEART") } + @Test("A cell both circled and rebus keeps its fill over the circle") + func circledRebusCellKeepsRebusFill() throws { + // The grid can't say both: '@' parses as a solution-less circled cell, + // so letting the circle win (the old precedence) dropped the rebus + // placeholder from the grid while the Rebus header still referenced it. + // The fill must win; only that cell's circle is sacrificed. + let data = try puzData( + width: 3, + height: 3, + solution: "ABCDEFGHI", + title: "Both", + author: "", + copyright: "", + clues: [ + "Across 1", + "Down 1", + "Down 2", + "Down 3", + "Across 4", + "Across 5" + ], + circledCells: [0, 4], + rebusGrid: [4: 0], + rebusTable: [0: "HEART"] + ) + + let source = try PUZToXDConverter.convert(puzData: data) + #expect(header("Rebus", in: source) == "1=HEART") + #expect(source.contains("\n@BC\nD1F\nGHI\n")) + + let puzzle = Puzzle(xd: try XD.parse(source)) + #expect(puzzle.cells[1][1].solution == "HEART") + #expect(puzzle.cells[0][0].special == .circled) + } + @Test("Many distinct rebus fills never collide with reserved grid syntax") func manyRebusFillsAvoidReservedKeys() throws { // Keying placeholders by cell index used to reach '=' (the Rebus header