crossmate

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

NYTToXDConverterTests.swift (41055B)


      1 import Foundation
      2 import Testing
      3 
      4 @testable import Crossmate
      5 
      6 @Suite("NYTToXDConverter")
      7 struct NYTToXDConverterTests {
      8 
      9     // MARK: - Fixtures
     10 
     11     /// Builds minimal NYT v6-shaped JSON for a 3×3 all-open puzzle. The grid
     12     /// admits six clues in a fixed order: 1-Across, 4-Across, 5-Across,
     13     /// 1-Down, 2-Down, 3-Down (indices 0…5 in the flat `clues` array). Each
     14     /// tuple supplies that clue's `relatives` array, or nil to omit the key
     15     /// entirely.
     16     private func puzzleJSON(
     17         relatives: [[Int]?],
     18         title: String? = nil,
     19         formattedClueIndices: Set<Int> = [],
     20         formattedOverrides: [Int: String] = [:],
     21         clueTexts: [Int: String] = [:],
     22         letters: [String] = ["A", "B", "C", "D", "E", "F", "G", "H", "I"],
     23         moreAnswersByCell: [Int: [String]] = [:],
     24         cellTypes: [Int: Int] = [:]
     25     ) throws -> Data {
     26         precondition(relatives.count == 6, "Expected 6 clues for a 3×3 open grid")
     27         precondition(letters.count == 9, "Expected 9 cell answers for a 3×3 open grid")
     28         let defs: [(label: Int, direction: String, cells: [Int])] = [
     29             (1, "Across", [0, 1, 2]),
     30             (4, "Across", [3, 4, 5]),
     31             (5, "Across", [6, 7, 8]),
     32             (1, "Down", [0, 3, 6]),
     33             (2, "Down", [1, 4, 7]),
     34             (3, "Down", [2, 5, 8])
     35         ]
     36         var clueDicts: [[String: Any]] = []
     37         for (i, def) in defs.enumerated() {
     38             var text: [String: Any] = ["plain": clueTexts[i] ?? "clue \(i)"]
     39             if let override = formattedOverrides[i] {
     40                 text["formatted"] = override
     41             } else if formattedClueIndices.contains(i) {
     42                 text["formatted"] = "<i>clue \(i)</i>"
     43             }
     44 
     45             var dict: [String: Any] = [
     46                 "label": "\(def.label)",
     47                 "direction": def.direction,
     48                 "cells": def.cells,
     49                 "text": [text]
     50             ]
     51             if let rels = relatives[i] {
     52                 dict["relatives"] = rels
     53             }
     54             clueDicts.append(dict)
     55         }
     56         let cells = letters.enumerated().map { index, answer -> [String: Any] in
     57             var cell: [String: Any] = ["answer": answer]
     58             if let type = cellTypes[index] {
     59                 cell["type"] = type
     60             }
     61             if let moreAnswers = moreAnswersByCell[index] {
     62                 cell["moreAnswers"] = ["valid": moreAnswers]
     63             }
     64             return cell
     65         }
     66         var root: [String: Any] = [
     67             "publicationDate": "2025-01-01",
     68             "constructors": ["Tester"],
     69             "body": [[
     70                 "dimensions": ["width": 3, "height": 3],
     71                 "cells": cells,
     72                 "clues": clueDicts
     73             ]]
     74         ]
     75         if let title {
     76             root["title"] = title
     77         }
     78         return try JSONSerialization.data(withJSONObject: root)
     79     }
     80 
     81     /// Builds NYT v6-shaped JSON for a single open row of `answers.count`
     82     /// cells — one Across clue, no Down words. Handy for exercising cell-level
     83     /// behaviour (rebus placeholders, Schrödinger fills) at a chosen volume
     84     /// without the 3×3 grid's six-clue topology.
     85     private func singleRowPuzzleJSON(answers: [String]) throws -> Data {
     86         let cells = answers.map { ["answer": $0] as [String: Any] }
     87         let clue: [String: Any] = [
     88             "label": "1",
     89             "direction": "Across",
     90             "cells": Array(0..<answers.count),
     91             "text": [["plain": "across"]]
     92         ]
     93         let root: [String: Any] = [
     94             "publicationDate": "2025-02-02",
     95             "body": [[
     96                 "dimensions": ["width": answers.count, "height": 1],
     97                 "cells": cells,
     98                 "clues": [clue]
     99             ]]
    100         ]
    101         return try JSONSerialization.data(withJSONObject: root)
    102     }
    103 
    104     /// Extracts the value after `Relatives: ` in an `.xd` source, or nil if
    105     /// the header isn't present.
    106     private func relativesHeader(in xd: String) -> String? {
    107         header("Relatives", in: xd)
    108     }
    109 
    110     private func header(_ name: String, in xd: String) -> String? {
    111         headers(name, in: xd).first
    112     }
    113 
    114     private func headers(_ name: String, in xd: String) -> [String] {
    115         let prefix = "\(name): "
    116         var values: [String] = []
    117         for line in xd.split(separator: "\n") {
    118             if line.hasPrefix(prefix) {
    119                 values.append(String(line.dropFirst(prefix.count)))
    120             }
    121         }
    122         return values
    123     }
    124 
    125     // MARK: - Malformed input
    126 
    127     @Test("Invalid dimensions are rejected before grid ranges are built")
    128     func invalidDimensionsRejected() throws {
    129         let root: [String: Any] = [
    130             "publicationDate": "2025-04-04",
    131             "body": [[
    132                 "dimensions": ["width": -1, "height": -1],
    133                 "cells": [["answer": "A"]],
    134                 "clues": [[
    135                     "label": "1",
    136                     "direction": "Across",
    137                     "cells": [0],
    138                     "text": [["plain": "malformed"]]
    139                 ]]
    140             ]]
    141         ]
    142         let data = try JSONSerialization.data(withJSONObject: root)
    143 
    144         #expect(throws: NYTToXDConverter.ConversionError.self) {
    145             try NYTToXDConverter.convert(jsonData: data)
    146         }
    147     }
    148 
    149     @Test("Clue cell indices outside the grid are rejected")
    150     func invalidClueCellIndicesRejected() throws {
    151         let root: [String: Any] = [
    152             "publicationDate": "2025-04-05",
    153             "body": [[
    154                 "dimensions": ["width": 1, "height": 1],
    155                 "cells": [["answer": "A"]],
    156                 "clues": [[
    157                     "label": "1",
    158                     "direction": "Across",
    159                     "cells": [0, 1],
    160                     "text": [["plain": "malformed"]]
    161                 ]]
    162             ]]
    163         ]
    164         let data = try JSONSerialization.data(withJSONObject: root)
    165 
    166         #expect(throws: NYTToXDConverter.ConversionError.self) {
    167             try NYTToXDConverter.convert(jsonData: data)
    168         }
    169     }
    170 
    171     // MARK: - Header emission
    172 
    173     @Test("NYT metadata uses weekday title and publisher")
    174     func nytMetadataTitleAndPublisher() throws {
    175         let data = try puzzleJSON(relatives: [nil, nil, nil, nil, nil, nil])
    176         let xd = try NYTToXDConverter.convert(jsonData: data)
    177         #expect(header("Title", in: xd) == "Wednesday Crossword")
    178         #expect(header("Publisher", in: xd) == "New York Times")
    179         #expect(header("Date", in: xd) == "2025-01-01")
    180 
    181         let parsed = try XD.parse(xd)
    182         let puzzle = Puzzle(xd: parsed)
    183         #expect(puzzle.title == "Wednesday Crossword")
    184         #expect(puzzle.publisher == "New York Times")
    185     }
    186 
    187     @Test("NYT metadata preserves supplied puzzle title")
    188     func nytMetadataPreservesSuppliedPuzzleTitle() throws {
    189         let data = try puzzleJSON(
    190             relatives: [nil, nil, nil, nil, nil, nil],
    191             title: "Big Draw"
    192         )
    193         let xd = try NYTToXDConverter.convert(jsonData: data)
    194         #expect(header("Title", in: xd) == "Big Draw")
    195 
    196         let parsed = try XD.parse(xd)
    197         let puzzle = Puzzle(xd: parsed)
    198         #expect(puzzle.title == "Big Draw")
    199     }
    200 
    201     @Test("NYT moreAnswers.valid emits XD Accept metadata and round-trips")
    202     func moreAnswersEmitAcceptMetadata() throws {
    203         let data = try puzzleJSON(
    204             relatives: [nil, nil, nil, nil, nil, nil],
    205             letters: ["A", "B", "C", "D", "IO", "F", "G", "H", "I"],
    206             moreAnswersByCell: [4: ["PHI", "I/O", "NEW YORK", "BACK\\SLASH"]]
    207         )
    208         let xd = try NYTToXDConverter.convert(jsonData: data)
    209 
    210         #expect(xd.contains("A4 ^Accept: DPHIF DI/OF DNEW\\ YORKF DBACK\\\\SLASHF"))
    211         #expect(xd.contains("D2 ^Accept: BPHIH BI/OH BNEW\\ YORKH BBACK\\\\SLASHH"))
    212 
    213         let puzzle = Puzzle(xd: try XD.parse(xd))
    214         let cell = puzzle.cells[1][1]
    215         #expect(cell.solution == "IO")
    216         #expect(cell.accepts("PHI"))
    217         #expect(cell.accepts("I/O"))
    218         #expect(cell.accepts("NEW YORK"))
    219         #expect(cell.accepts("BACK\\SLASH"))
    220     }
    221 
    222     @Test("Schrödinger slash cell keeps NYT's slash form and round-trips")
    223     func schrodingerCellKeepsSlash() throws {
    224         // Center cell is correct as L or W; NYT's canonical answer is the
    225         // slash-joined "L/W".
    226         let data = try puzzleJSON(
    227             relatives: [nil, nil, nil, nil, nil, nil],
    228             letters: ["A", "B", "C", "D", "L/W", "F", "G", "H", "I"],
    229             moreAnswersByCell: [4: ["W/L", "LW", "WL", "L", "W"]]
    230         )
    231         let xd = try NYTToXDConverter.convert(jsonData: data)
    232 
    233         // The slash form rides through verbatim as the canonical rebus fill —
    234         // it's ASCII and needs no header escaping — and reveals as "L/W".
    235         #expect(header("Rebus", in: xd) == "1=L/W")
    236 
    237         let puzzle = Puzzle(xd: try XD.parse(xd))
    238         let cell = puzzle.cells[1][1]
    239         #expect(cell.solution == "L/W")
    240         #expect(cell.accepts("L/W")) // NYT's canonical slash form
    241         #expect(cell.accepts("LW"))  // both letters, no slash (across)
    242         #expect(cell.accepts("L"))   // either single letter (down)
    243         #expect(cell.accepts("W"))
    244     }
    245 
    246     @Test("Converted output carries the current ConVer header, not legacy CmVer")
    247     func emitsConVerHeader() throws {
    248         let data = try singleRowPuzzleJSON(answers: ["A", "B", "C"])
    249         let xd = try NYTToXDConverter.convert(jsonData: data)
    250 
    251         #expect(header("ConVer", in: xd) == String(XD.currentConverterVersion))
    252         #expect(header("CmVer", in: xd) == nil)
    253     }
    254 
    255     @Test("Many distinct rebus fills never collide with reserved grid syntax")
    256     func manyRebusFillsAvoidReservedKeys() throws {
    257         // The 13 Schrödinger fills from the 2 Feb 2025 Sunday puzzle. Walking
    258         // ASCII from '1' reaches '=' (the Rebus header delimiter) on the 13th,
    259         // which used to yield an unparseable "==HE" entry and a grid character
    260         // the parser rejected with unknownGridCharacter.
    261         let pairs = ["L/W", "Q/B", "Z/M", "C/V", "U/I", "D/N", "R/Y",
    262                      "G/T", "S/F", "X/K", "J/P", "O/A", "H/E"]
    263         let data = try singleRowPuzzleJSON(answers: pairs)
    264         let xd = try NYTToXDConverter.convert(jsonData: data)
    265 
    266         let rebus = try #require(header("Rebus", in: xd))
    267         #expect(!rebus.contains("==")) // no entry has '=' as its key
    268         for entry in rebus.split(separator: " ") {
    269             #expect(entry.first != "=")
    270         }
    271 
    272         // The whole puzzle must now parse rather than throwing, with every
    273         // distinct fill landing as a multi-character rebus solution (NYT's
    274         // slash form preserved verbatim).
    275         let puzzle = Puzzle(xd: try XD.parse(xd))
    276         #expect(puzzle.cells[0][0].solution == "L/W")
    277         #expect(puzzle.cells[0][12].solution == "H/E")
    278     }
    279 
    280     @Test("Single-character digit fills are encoded as rebus placeholders and round-trip")
    281     func digitCellsBecomeRebusPlaceholders() throws {
    282         // Mirrors the 2021-04-21 "R2D2" themer: the "2" cells are single-
    283         // character digit fills. A digit can't appear literally in the .xd grid
    284         // (the parser only accepts letters there), so each rides in via a Rebus
    285         // placeholder — the digit never lands in the grid. After parsing the
    286         // cell is an ordinary single-character cell whose solution is "2", so a
    287         // solver typing "2" directly (not through the rebus interface) is
    288         // accepted.
    289         let data = try singleRowPuzzleJSON(answers: ["R", "2", "D", "2"])
    290         let xd = try NYTToXDConverter.convert(jsonData: data)
    291 
    292         #expect(header("Rebus", in: xd) == "1=2")
    293         #expect(xd.contains("R1D1"))    // grid uses the placeholder, not a literal 2
    294         #expect(xd.contains("~ R2D2"))  // the clue answer still carries the real fill
    295 
    296         let puzzle = Puzzle(xd: try XD.parse(xd))
    297         let cell = puzzle.cells[0][1]
    298         #expect(cell.solution == "2")
    299         #expect(cell.accepts("2"))      // a direct "2" keystroke counts as correct
    300         #expect(!cell.accepts("R"))
    301     }
    302 
    303     @Test("Type 1 cell with no answer becomes a space-fill rebus that round-trips")
    304     func gapCellBecomesSpaceRebus() throws {
    305         // The 2006-07-06 "THE GAP" themer reads crossing words straight through
    306         // blank squares: a playable (type 1) cell with no `answer` whose only
    307         // correct state is empty. NYT tags it with a blank-marker `moreAnswers`
    308         // ("B") we must ignore. Here "TO BE" has its gap at index 2.
    309         let cells: [[String: Any]] = [
    310             ["answer": "T"],
    311             ["answer": "O"],
    312             ["type": 1, "moreAnswers": ["valid": ["B"]]],
    313             ["answer": "B"],
    314             ["answer": "E"]
    315         ]
    316         let clue: [String: Any] = [
    317             "label": "1",
    318             "direction": "Across",
    319             "cells": [0, 1, 2, 3, 4],
    320             "text": [["plain": "Repeated part of a soliloquy"]]
    321         ]
    322         let root: [String: Any] = [
    323             "publicationDate": "2025-03-03",
    324             "body": [[
    325                 "dimensions": ["width": 5, "height": 1],
    326                 "cells": cells,
    327                 "clues": [clue]
    328             ]]
    329         ]
    330         let data = try JSONSerialization.data(withJSONObject: root)
    331         let xd = try NYTToXDConverter.convert(jsonData: data)
    332 
    333         // The space fill rides in as a grid placeholder, escaped as `\space` so
    334         // it survives the whitespace-split Rebus header grammar.
    335         #expect(header("Rebus", in: xd) == "1=\\space")
    336         #expect(xd.contains("\nTO1BE\n"))   // grid uses the placeholder, never a literal gap
    337         #expect(xd.contains("~ TO BE"))     // the clue answer carries the real space
    338         #expect(!xd.contains("Accept"))     // the "B" blank marker is dropped, not an alternate
    339 
    340         let puzzle = Puzzle(xd: try XD.parse(xd))
    341         #expect(puzzle.cells[0][2].solution == " ")
    342         #expect(puzzle.cells[0][0].solution == "T")
    343         #expect(puzzle.cells[0][3].solution == "B")
    344     }
    345 
    346     @Test("NYT type 2 cells emit circle decorations and keep their letters")
    347     func typeTwoCellsEmitCircleDecorations() throws {
    348         let data = try puzzleJSON(
    349             relatives: [nil, nil, nil, nil, nil, nil],
    350             cellTypes: [0: 2, 4: 2]
    351         )
    352         let xd = try NYTToXDConverter.convert(jsonData: data)
    353 
    354         // The circle no longer displaces the fill: the grid reads normally and
    355         // the decoration rides in its own section.
    356         #expect(header("Specials", in: xd) == nil)
    357         #expect(xd.contains("\nABC\nDEF\nGHI\n"))
    358         #expect(xd.contains("## Decorations"))
    359         #expect(xd.contains("\nO..\n.O.\n...\n"))
    360         #expect(xd.contains("O. mark=circle"))
    361 
    362         let puzzle = Puzzle(xd: try XD.parse(xd))
    363         #expect(puzzle.cells[0][0].special == .circled)
    364         #expect(puzzle.cells[1][1].special == .circled)
    365         #expect(puzzle.cells[0][0].solution == "A")
    366         #expect(puzzle.cells[1][1].solution == "E")
    367     }
    368 
    369     @Test("NYT type 3 cells emit shaded decorations")
    370     func typeThreeCellsEmitShadedDecorations() throws {
    371         let data = try puzzleJSON(
    372             relatives: [nil, nil, nil, nil, nil, nil],
    373             cellTypes: [0: 3, 4: 3]
    374         )
    375         let xd = try NYTToXDConverter.convert(jsonData: data)
    376 
    377         #expect(header("Specials", in: xd) == nil)
    378         #expect(xd.contains("\nABC\nDEF\nGHI\n"))
    379         #expect(xd.contains("\nS..\n.S.\n...\n"))
    380         #expect(xd.contains("S. mark=shaded"))
    381 
    382         let puzzle = Puzzle(xd: try XD.parse(xd))
    383         #expect(puzzle.cells[0][0].special == .shaded)
    384         #expect(puzzle.cells[1][1].special == .shaded)
    385     }
    386 
    387     @Test("Mixed type 2 and type 3 cells share one design grid")
    388     func mixedSpecialTypesShareOneDesignGrid() throws {
    389         let data = try puzzleJSON(
    390             relatives: [nil, nil, nil, nil, nil, nil],
    391             cellTypes: [0: 2, 4: 3]
    392         )
    393         let xd = try NYTToXDConverter.convert(jsonData: data)
    394 
    395         #expect(xd.contains("\nO..\n.S.\n...\n"))
    396         #expect(xd.contains("O. mark=circle"))
    397         #expect(xd.contains("S. mark=shaded"))
    398 
    399         let puzzle = Puzzle(xd: try XD.parse(xd))
    400         #expect(puzzle.cells[0][0].special == .circled)
    401         #expect(puzzle.cells[1][1].special == .shaded)
    402     }
    403 
    404     @Test("A cell both circled and rebus keeps its fill and circle")
    405     func circledRebusCellKeepsFillAndCircle() throws {
    406         let data = try puzzleJSON(
    407             relatives: [nil, nil, nil, nil, nil, nil],
    408             letters: ["A", "B", "C", "D", "HEART", "F", "G", "H", "I"],
    409             cellTypes: [0: 2, 4: 2]
    410         )
    411         let xd = try NYTToXDConverter.convert(jsonData: data)
    412 
    413         #expect(header("Rebus", in: xd) == "1=HEART")
    414         #expect(xd.contains("\nABC\nD1F\nGHI\n"))
    415         #expect(xd.contains("\nO..\n.O.\n...\n"))
    416         #expect(xd.contains("O. mark=circle"))
    417 
    418         let puzzle = Puzzle(xd: try XD.parse(xd))
    419         #expect(puzzle.cells[1][1].solution == "HEART")
    420         #expect(puzzle.cells[0][0].special == .circled)
    421         #expect(puzzle.cells[1][1].special == .circled)
    422     }
    423 
    424     @Test("Shaded and unshaded occurrences of one rebus share a placeholder")
    425     func shadedAndUnshadedRebusOccurrencesShareAPlaceholder() throws {
    426         // Shading used to be part of a rebus placeholder's identity, because the
    427         // marker displaced the grid character. Now that it rides in the design
    428         // grid keyed by position, one fill needs only one placeholder however
    429         // many of its occurrences are decorated.
    430         let data = try puzzleJSON(
    431             relatives: [nil, nil, nil, nil, nil, nil],
    432             letters: ["HEART", "B", "C", "D", "HEART", "F", "G", "H", "I"],
    433             cellTypes: [0: 3]
    434         )
    435         let xd = try NYTToXDConverter.convert(jsonData: data)
    436 
    437         #expect(header("Rebus", in: xd) == "1=HEART")
    438         #expect(xd.contains("\n1BC\nD1F\nGHI\n"))
    439         #expect(xd.contains("\nS..\n...\n...\n"))
    440         #expect(xd.contains("S. mark=shaded"))
    441 
    442         let puzzle = Puzzle(xd: try XD.parse(xd))
    443         #expect(puzzle.cells[0][0].solution == "HEART")
    444         #expect(puzzle.cells[0][0].special == .shaded)
    445         #expect(puzzle.cells[1][1].solution == "HEART")
    446         #expect(puzzle.cells[1][1].special == nil)
    447     }
    448 
    449     @Test("Revealer with ≥2 relatives produces a group")
    450     func revealerGroup() throws {
    451         // 1A (index 0) references 4A (1) and 5A (2).
    452         let data = try puzzleJSON(relatives: [[1, 2], nil, nil, nil, nil, nil])
    453         let xd = try NYTToXDConverter.convert(jsonData: data)
    454         #expect(relativesHeader(in: xd) == "1A,4A,5A")
    455     }
    456 
    457     @Test("Mutual 1-relative pair produces a group")
    458     func mutualPair() throws {
    459         // 1D (index 3) ↔ 2D (index 4).
    460         let data = try puzzleJSON(relatives: [nil, nil, nil, [4], [3], nil])
    461         let xd = try NYTToXDConverter.convert(jsonData: data)
    462         #expect(relativesHeader(in: xd) == "1D,2D")
    463     }
    464 
    465     @Test("Asymmetric 1-relative edge is discarded")
    466     func asymmetricEdgeDropped() throws {
    467         // 3D (index 5) points at 1A (0); 1A does not point back. No group.
    468         let data = try puzzleJSON(relatives: [nil, nil, nil, nil, nil, [0]])
    469         let xd = try NYTToXDConverter.convert(jsonData: data)
    470         #expect(relativesHeader(in: xd) == nil)
    471     }
    472 
    473     @Test("No relatives anywhere means no Relatives header")
    474     func noRelatives() throws {
    475         let data = try puzzleJSON(relatives: [nil, nil, nil, nil, nil, nil])
    476         let xd = try NYTToXDConverter.convert(jsonData: data)
    477         #expect(relativesHeader(in: xd) == nil)
    478     }
    479 
    480     @Test("Formatted clue text produces a thematic group")
    481     func formattedCluesProduceThemeGroup() throws {
    482         let data = try puzzleJSON(
    483             relatives: [nil, nil, nil, nil, nil, nil],
    484             formattedClueIndices: [0, 2]
    485         )
    486         let xd = try NYTToXDConverter.convert(jsonData: data)
    487         #expect(relativesHeader(in: xd) == "1A,5A")
    488     }
    489 
    490     @Test("Italic formatted clue becomes XD brace markup and round-trips to an emphasized run")
    491     func italicClueBecomesBraceMarkup() throws {
    492         let data = try puzzleJSON(
    493             relatives: [nil, nil, nil, nil, nil, nil],
    494             formattedOverrides: [0: "<i>10th grader critiques swanky boutique?</i>"]
    495         )
    496         let xd = try NYTToXDConverter.convert(jsonData: data)
    497         #expect(xd.contains("A1. {/10th grader critiques swanky boutique?/} ~ ABC"))
    498 
    499         let puzzle = Puzzle(xd: try XD.parse(xd))
    500         let clue = try #require(puzzle.acrossClues.first { $0.number == 1 })
    501         #expect(clue.text == "10th grader critiques swanky boutique?")
    502         let intents = clue.attributedText.runs.map(\.inlinePresentationIntent)
    503         #expect(intents == [.emphasized])
    504     }
    505 
    506     @Test("Underline formatted clue converts for display but is not a theme group")
    507     func underlineClueConvertsButDoesNotGroup() throws {
    508         let data = try puzzleJSON(
    509             relatives: [nil, nil, nil, nil, nil, nil],
    510             formattedOverrides: [0: "<u>John</u> <u>Philip</u> ___"]
    511         )
    512         let xd = try NYTToXDConverter.convert(jsonData: data)
    513         #expect(xd.contains("A1. {_John_} {_Philip_} ___ ~ ABC"))
    514         // Underline is a highlight gimmick, not NYT's italic themer marker.
    515         #expect(relativesHeader(in: xd) == nil)
    516     }
    517 
    518     @Test("Formatted field without emphasis tags falls back to plain and isn't a themer")
    519     func symbolFormattedFallsBackToPlain() throws {
    520         // NYT mirrors a bare symbol in `formatted` for image clues; it carries
    521         // no emphasis and must not be treated as markup or as a theme group.
    522         let data = try puzzleJSON(
    523             relatives: [nil, nil, nil, nil, nil, nil],
    524             formattedOverrides: [0: "¥"],
    525             clueTexts: [0: "Eastern currency"]
    526         )
    527         let xd = try NYTToXDConverter.convert(jsonData: data)
    528         #expect(xd.contains("A1. Eastern currency ~ ABC"))
    529         #expect(!xd.contains("¥"))
    530         #expect(relativesHeader(in: xd) == nil)
    531     }
    532 
    533     @Test("Formatted clue text is merged with explicit relatives")
    534     func formattedCluesMergeWithRelatives() throws {
    535         let data = try puzzleJSON(
    536             relatives: [[1, 2], nil, nil, nil, nil, nil],
    537             formattedClueIndices: [3, 4]
    538         )
    539         let xd = try NYTToXDConverter.convert(jsonData: data)
    540         let header = try #require(relativesHeader(in: xd))
    541         #expect(header.contains("1A,4A,5A"))
    542         #expect(header.contains("1D,2D"))
    543         #expect(header.contains("; "))
    544     }
    545 
    546     @Test("A revealer naming the italicized clues is folded into their group")
    547     func italicizedRevealerJoinsThemerGroup() throws {
    548         // 1A and 4A are italic themers; 5A is the plain revealer that points at
    549         // them in prose. NYT supplies no relatives for any of the three, so the
    550         // "italicized clues" phrasing is the only link.
    551         let data = try puzzleJSON(
    552             relatives: [nil, nil, nil, nil, nil, nil],
    553             formattedOverrides: [0: "<i>themer one</i>", 1: "<i>themer two</i>"],
    554             clueTexts: [2: "What the answers to the italicized clues have in common"]
    555         )
    556         let xd = try NYTToXDConverter.convert(jsonData: data)
    557         #expect(relativesHeader(in: xd) == "1A,4A,5A")
    558     }
    559 
    560     @Test("An italicized-clue mention with no italic set forms no group")
    561     func italicizedMentionWithoutSetDoesNotGroup() throws {
    562         // The revealer phrasing alone is inert: without an actual italicized
    563         // set to bind to, the clue is just prose and must not start a group.
    564         let data = try puzzleJSON(
    565             relatives: [nil, nil, nil, nil, nil, nil],
    566             clueTexts: [2: "What the answers to the italicized clues have in common"]
    567         )
    568         let xd = try NYTToXDConverter.convert(jsonData: data)
    569         #expect(relativesHeader(in: xd) == nil)
    570     }
    571 
    572     @Test("Multiple groups are semicolon-joined on a single Relatives line")
    573     func multipleGroups() throws {
    574         // Revealer group {1A, 4A, 5A} + mutual pair {1D, 2D}.
    575         let data = try puzzleJSON(relatives: [[1, 2], nil, nil, [4], [3], nil])
    576         let xd = try NYTToXDConverter.convert(jsonData: data)
    577         let header = try #require(relativesHeader(in: xd))
    578         #expect(header.contains("1A,4A,5A"))
    579         #expect(header.contains("1D,2D"))
    580         #expect(header.contains("; "))
    581     }
    582 
    583     @Test("Revealer list wins over a buggy leaf back-reference")
    584     func revealerWinsOverBadLeaf() throws {
    585         // Mirrors the real-world NYT data bug: 4A (index 1, a leaf) back-
    586         // references 3D (index 5) instead of the revealer. The asymmetric
    587         // edge must be dropped so 3D is not dragged into the group.
    588         let data = try puzzleJSON(relatives: [[1, 2], [5], nil, nil, nil, nil])
    589         let xd = try NYTToXDConverter.convert(jsonData: data)
    590         let header = try #require(relativesHeader(in: xd))
    591         #expect(header == "1A,4A,5A")
    592         #expect(!header.contains("3D"))
    593     }
    594 
    595     @Test("Duplicate indices in relatives are deduplicated")
    596     func duplicateRelativesDeduped() throws {
    597         // Real NYT data occasionally repeats a clue in the relatives array
    598         // (we saw 57A include index 23 twice). The emitted group must not
    599         // repeat the token.
    600         let data = try puzzleJSON(relatives: [[1, 2, 2, 1], nil, nil, nil, nil, nil])
    601         let xd = try NYTToXDConverter.convert(jsonData: data)
    602         #expect(relativesHeader(in: xd) == "1A,4A,5A")
    603     }
    604 
    605     @Test("Cross-references in clue text never appear in the Relatives header")
    606     func crossRefsDoNotPolluteRelatives() throws {
    607         // Text-only "See N-Down" / "With X- and Y-Down" mentions belong to
    608         // navigation and are derived in Puzzle.init, not by the converter —
    609         // so the Relatives header sees only structured-relatives groups.
    610         let data = try puzzleJSON(
    611             relatives: [[1, 2], nil, nil, nil, nil, nil],
    612             clueTexts: [
    613                 3: "With 2-Down, a phrase",
    614                 4: "See 1-Down"
    615             ]
    616         )
    617         let xd = try NYTToXDConverter.convert(jsonData: data)
    618         #expect(relativesHeader(in: xd) == "1A,4A,5A")
    619     }
    620 
    621     @Test("Clue-text refs without structured relatives produce no Relatives header")
    622     func clueTextRefsAloneEmitNothing() throws {
    623         let data = try puzzleJSON(
    624             relatives: [nil, nil, nil, nil, nil, nil],
    625             clueTexts: [
    626                 3: "With 2- and 3-Down, an environmentalist motto",
    627                 4: "See 1-Down",
    628                 5: "See 1-Down"
    629             ]
    630         )
    631         let xd = try NYTToXDConverter.convert(jsonData: data)
    632         #expect(relativesHeader(in: xd) == nil)
    633     }
    634 
    635     @Test("Self-reference in relatives is ignored")
    636     func selfReferenceIgnored() throws {
    637         // A revealer that lists itself as a relative plus one real reference
    638         // still produces a valid 2-member group (itself + the reference).
    639         let data = try puzzleJSON(relatives: [[0, 1], nil, nil, nil, nil, nil])
    640         let xd = try NYTToXDConverter.convert(jsonData: data)
    641         #expect(relativesHeader(in: xd) == "1A,4A")
    642     }
    643 
    644     // MARK: - Round-trip through XD.parse and Puzzle
    645 
    646     @Test("Emitted Relatives header round-trips through XD.parse")
    647     func roundTripThroughXD() throws {
    648         let data = try puzzleJSON(relatives: [[1, 2], nil, nil, [4], [3], nil])
    649         let xd = try NYTToXDConverter.convert(jsonData: data)
    650         let parsed = try XD.parse(xd)
    651         #expect(parsed.relatives.count == 2)
    652         let groupSets = parsed.relatives.map { Set($0) }
    653         let expectedRevealer: Set<XD.ClueRef> = [
    654             XD.ClueRef(number: 1, direction: .across),
    655             XD.ClueRef(number: 4, direction: .across),
    656             XD.ClueRef(number: 5, direction: .across)
    657         ]
    658         let expectedPair: Set<XD.ClueRef> = [
    659             XD.ClueRef(number: 1, direction: .down),
    660             XD.ClueRef(number: 2, direction: .down)
    661         ]
    662         #expect(groupSets.contains(expectedRevealer))
    663         #expect(groupSets.contains(expectedPair))
    664     }
    665 
    666     @Test("Clue-text cross-references drive Puzzle.relatedCells")
    667     func clueTextCrossRefsDrivePuzzle() throws {
    668         // 1A is a revealer that points at 4A and 5A in its clue text. In a
    669         // 3×3 open grid, 1A is row 0, 4A is row 1, 5A is row 2. With the
    670         // cursor on 1A going Across, every cell of 4A and 5A should appear
    671         // in relatedCells (and none of 1A's own row).
    672         let data = try puzzleJSON(
    673             relatives: [nil, nil, nil, nil, nil, nil],
    674             clueTexts: [0: "With 4- and 5-Across, a phrase"]
    675         )
    676         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    677         let puzzle = Puzzle(xd: xd)
    678         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    679         for c in 0..<3 {
    680             #expect(!related.contains(GridPosition(row: 0, col: c)))
    681             #expect(related.contains(GridPosition(row: 1, col: c)))
    682             #expect(related.contains(GridPosition(row: 2, col: c)))
    683         }
    684     }
    685 
    686     @Test("Revealer list without See or With drives Puzzle.relatedCells")
    687     func unanchoredRevealerListDrivesPuzzle() throws {
    688         let data = try puzzleJSON(
    689             relatives: [nil, nil, nil, nil, nil, nil],
    690             clueTexts: [
    691                 0: "What can go after the respective halves of 1-, 4- and 5-Across"
    692             ]
    693         )
    694         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    695         let puzzle = Puzzle(xd: xd)
    696         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    697         for c in 0..<3 {
    698             #expect(!related.contains(GridPosition(row: 0, col: c)))
    699             #expect(related.contains(GridPosition(row: 1, col: c)))
    700             #expect(related.contains(GridPosition(row: 2, col: c)))
    701         }
    702     }
    703 
    704     @Test("Connected components: See / With chains form a single group")
    705     func clueTextChainConnectedComponents() throws {
    706         // 1D references 2D and 3D via "With"; 2D and 3D both point back via
    707         // "See". Should resolve to a single connected component {1D,2D,3D}.
    708         let data = try puzzleJSON(
    709             relatives: [nil, nil, nil, nil, nil, nil],
    710             clueTexts: [
    711                 3: "With 2- and 3-Down, an environmentalist motto",
    712                 4: "See 1-Down",
    713                 5: "See 1-Down"
    714             ]
    715         )
    716         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    717         let puzzle = Puzzle(xd: xd)
    718         // Cursor on 1D (col 0, going down) — 2D and 3D should be related.
    719         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .down)
    720         for r in 0..<3 {
    721             #expect(related.contains(GridPosition(row: r, col: 1)))
    722             #expect(related.contains(GridPosition(row: r, col: 2)))
    723             #expect(!related.contains(GridPosition(row: r, col: 0)))
    724         }
    725     }
    726 
    727     @Test("Cross-reference outlines only fire when the focus direction matches")
    728     func crossRefsGatedByFocusDirection() throws {
    729         // 1A points at 4A and 5A. Cell (0,0) is the start of both 1A
    730         // (Across) and 1D (Down). On Across, the cursor's focus clue is
    731         // 1A — the revealer — so 4A/5A light up. Switching to Down on the
    732         // same cell makes the focus clue 1D, which isn't in any group, so
    733         // the outlines disappear.
    734         let data = try puzzleJSON(
    735             relatives: [nil, nil, nil, nil, nil, nil],
    736             clueTexts: [0: "With 4- and 5-Across, a phrase"]
    737         )
    738         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    739         let puzzle = Puzzle(xd: xd)
    740         #expect(!puzzle.relatedCells(atRow: 0, col: 0, direction: .across).isEmpty)
    741         #expect(puzzle.relatedCells(atRow: 0, col: 0, direction: .down).isEmpty)
    742     }
    743 
    744     @Test("Bare clue mentions without hyphen do not link clues")
    745     func bareClueMentionsWithoutHyphenDoNotLink() throws {
    746         let data = try puzzleJSON(
    747             relatives: [nil, nil, nil, nil, nil, nil],
    748             clueTexts: [
    749                 0: "Compare with 5 Across, sort of",
    750                 2: "Reminiscent of 1 Across"
    751             ]
    752         )
    753         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    754         let puzzle = Puzzle(xd: xd)
    755         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    756         #expect(related.isEmpty)
    757     }
    758 
    759     @Test("Single unanchored clue mention with hyphen links clues")
    760     func singleUnanchoredClueMentionWithHyphenLinks() throws {
    761         let data = try puzzleJSON(
    762             relatives: [nil, nil, nil, nil, nil, nil],
    763             clueTexts: [
    764                 0: "What might follow 5-Across in a phrase"
    765             ]
    766         )
    767         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    768         let puzzle = Puzzle(xd: xd)
    769         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    770         for c in 0..<3 {
    771             #expect(!related.contains(GridPosition(row: 0, col: c)))
    772             #expect(!related.contains(GridPosition(row: 1, col: c)))
    773             #expect(related.contains(GridPosition(row: 2, col: c)))
    774         }
    775     }
    776 
    777     @Test("Mixed Across and Down references link all mentioned clues")
    778     func mixedAcrossAndDownReferencesLinkAllMentionedClues() throws {
    779         let data = try puzzleJSON(
    780             relatives: [nil, nil, nil, nil, nil, nil],
    781             clueTexts: [
    782                 0: "Bridge between 5-Across and 2-Down"
    783             ]
    784         )
    785         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    786         let puzzle = Puzzle(xd: xd)
    787         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    788         for i in 0..<3 {
    789             #expect(related.contains(GridPosition(row: 2, col: i)))
    790             #expect(related.contains(GridPosition(row: i, col: 1)))
    791         }
    792     }
    793 
    794     @Test("Or-separated revealer lists link all mentioned clues")
    795     func orSeparatedRevealerListsLinkAllMentionedClues() throws {
    796         let data = try puzzleJSON(
    797             relatives: [nil, nil, nil, nil, nil, nil],
    798             clueTexts: [
    799                 0: "What can precede 4- or 5-Across"
    800             ]
    801         )
    802         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    803         let puzzle = Puzzle(xd: xd)
    804         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    805         for c in 0..<3 {
    806             #expect(!related.contains(GridPosition(row: 0, col: c)))
    807             #expect(related.contains(GridPosition(row: 1, col: c)))
    808             #expect(related.contains(GridPosition(row: 2, col: c)))
    809         }
    810     }
    811 
    812     @Test("[aria-label] prefix is stripped from clue text")
    813     func ariaLabelPrefixStripped() throws {
    814         let data = try puzzleJSON(
    815             relatives: [nil, nil, nil, nil, nil, nil],
    816             clueTexts: [
    817                 0: "[aria-label] Circled letter + walking stick",
    818                 3: "[ARIA-LABEL]  Circled letter + map line",
    819                 4: "Normal clue, no prefix"
    820             ]
    821         )
    822         let xd = try NYTToXDConverter.convert(jsonData: data)
    823         #expect(xd.contains("A1. Circled letter + walking stick ~ ABC"))
    824         #expect(xd.contains("D1. Circled letter + map line ~ ADG"))
    825         #expect(xd.contains("D2. Normal clue, no prefix ~ BEH"))
    826         #expect(!xd.contains("[aria-label]"))
    827         #expect(!xd.lowercased().contains("[aria-label]"))
    828     }
    829 
    830     @Test("Slash-separated clue references link all mentioned clues")
    831     func slashSeparatedClueReferencesLinkAllMentionedClues() throws {
    832         let data = try puzzleJSON(
    833             relatives: [nil, nil, nil, nil, nil, nil],
    834             clueTexts: [
    835                 0: "Phrase in 4-/5-Across"
    836             ]
    837         )
    838         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    839         let puzzle = Puzzle(xd: xd)
    840         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    841         for c in 0..<3 {
    842             #expect(!related.contains(GridPosition(row: 0, col: c)))
    843             #expect(related.contains(GridPosition(row: 1, col: c)))
    844             #expect(related.contains(GridPosition(row: 2, col: c)))
    845         }
    846     }
    847 
    848     // MARK: - Overlay assets
    849 
    850     private func overlayJSON(
    851         beforeStart: Any? = nil,
    852         afterSolve: Any? = nil,
    853         assetURIs: [String]
    854     ) throws -> Data {
    855         var body: [String: Any] = ["cells": [], "clues": []]
    856         var overlays: [String: Any] = [:]
    857         if let beforeStart {
    858             overlays["beforeStart"] = beforeStart
    859         }
    860         if let afterSolve {
    861             overlays["afterSolve"] = afterSolve
    862         }
    863         if !overlays.isEmpty {
    864             body["overlays"] = overlays
    865         }
    866         let root: [String: Any] = [
    867             "body": [body],
    868             "assets": assetURIs.map { ["uri": $0] }
    869         ]
    870         return try JSONSerialization.data(withJSONObject: root)
    871     }
    872 
    873     @Test("Overlay phases are independent one-based indices into assets")
    874     func overlayPhaseIndicesAreOneBased() throws {
    875         let data = try overlayJSON(
    876             beforeStart: 1,
    877             afterSolve: 2,
    878             assetURIs: ["https://example.test/start.png", "https://example.test/solve.png"]
    879         )
    880         #expect(try NYTToXDConverter.beforeStartImageURL(jsonData: data)?.absoluteString
    881             == "https://example.test/start.png")
    882         #expect(try NYTToXDConverter.afterSolveImageURL(jsonData: data)?.absoluteString
    883             == "https://example.test/solve.png")
    884     }
    885 
    886     @Test("A missing overlay phase resolves to no URL")
    887     func missingOverlayPhaseResolvesToNil() throws {
    888         let data = try overlayJSON(beforeStart: 1, assetURIs: ["https://example.test/a.png"])
    889         #expect(try NYTToXDConverter.beforeStartImageURL(jsonData: data)?.absoluteString
    890             == "https://example.test/a.png")
    891         #expect(try NYTToXDConverter.afterSolveImageURL(jsonData: data) == nil)
    892     }
    893 
    894     @Test("An overlay index outside the asset list resolves to no URL")
    895     func outOfRangeOverlayIndexResolvesToNil() throws {
    896         // Off-by-one in either direction: 0 predates the one-based scheme, 3
    897         // runs past the end. Neither may be read as a valid asset.
    898         for index in [0, 3] {
    899             let data = try overlayJSON(
    900                 beforeStart: index,
    901                 afterSolve: index,
    902                 assetURIs: ["https://example.test/a.png", "https://example.test/b.png"]
    903             )
    904             #expect(try NYTToXDConverter.beforeStartImageURL(jsonData: data) == nil)
    905             #expect(try NYTToXDConverter.afterSolveImageURL(jsonData: data) == nil)
    906         }
    907     }
    908 
    909     @Test("An empty asset URI resolves to no URL")
    910     func emptyAssetURIResolvesToNil() throws {
    911         let data = try overlayJSON(beforeStart: 1, afterSolve: 1, assetURIs: [""])
    912         #expect(try NYTToXDConverter.beforeStartImageURL(jsonData: data) == nil)
    913         #expect(try NYTToXDConverter.afterSolveImageURL(jsonData: data) == nil)
    914     }
    915 
    916     @Test("Only overlays wholly on empty block dictionaries are eligible for letter OCR")
    917     func onlyEmptyBlockOverlaysAreLetterEligible() {
    918         let body: [String: Any] = [
    919             "cells": [
    920                 [String: Any](),
    921                 ["answer": "A"],
    922                 [String: Any](),
    923                 ["answer": "REDR", "type": 2],
    924             ]
    925         ]
    926 
    927         #expect(NYTToXDConverter.overlayTargetsOnlyEmptyBlocks(
    928             [GridPosition(row: 0, col: 0), GridPosition(row: 1, col: 0)],
    929             body: body,
    930             width: 2,
    931             height: 2
    932         ))
    933         #expect(!NYTToXDConverter.overlayTargetsOnlyEmptyBlocks(
    934             [GridPosition(row: 0, col: 0), GridPosition(row: 0, col: 1)],
    935             body: body,
    936             width: 2,
    937             height: 2
    938         ))
    939         #expect(!NYTToXDConverter.overlayTargetsOnlyEmptyBlocks(
    940             [GridPosition(row: 1, col: 1)],
    941             body: body,
    942             width: 2,
    943             height: 2
    944         ))
    945     }
    946 
    947     @Test("An empty, malformed, or out-of-bounds overlay is not letter eligible")
    948     func malformedOverlayIsNotLetterEligible() {
    949         let body: [String: Any] = ["cells": [[String: Any](), [String: Any]()]]
    950 
    951         #expect(!NYTToXDConverter.overlayTargetsOnlyEmptyBlocks(
    952             [],
    953             body: body,
    954             width: 2,
    955             height: 1
    956         ))
    957         #expect(!NYTToXDConverter.overlayTargetsOnlyEmptyBlocks(
    958             [GridPosition(row: 1, col: 0)],
    959             body: body,
    960             width: 2,
    961             height: 1
    962         ))
    963         #expect(!NYTToXDConverter.overlayTargetsOnlyEmptyBlocks(
    964             [GridPosition(row: 0, col: 0)],
    965             body: ["cells": [[String: Any]()]],
    966             width: 2,
    967             height: 1
    968         ))
    969     }
    970 }