crossmate

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

NYTToXDConverterTests.swift (32053B)


      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: - Header emission
    126 
    127     @Test("NYT metadata uses weekday title and publisher")
    128     func nytMetadataTitleAndPublisher() throws {
    129         let data = try puzzleJSON(relatives: [nil, nil, nil, nil, nil, nil])
    130         let xd = try NYTToXDConverter.convert(jsonData: data)
    131         #expect(header("Title", in: xd) == "Wednesday Crossword")
    132         #expect(header("Publisher", in: xd) == "New York Times")
    133         #expect(header("Date", in: xd) == "2025-01-01")
    134 
    135         let parsed = try XD.parse(xd)
    136         let puzzle = Puzzle(xd: parsed)
    137         #expect(puzzle.title == "Wednesday Crossword")
    138         #expect(puzzle.publisher == "New York Times")
    139     }
    140 
    141     @Test("NYT metadata preserves supplied puzzle title")
    142     func nytMetadataPreservesSuppliedPuzzleTitle() throws {
    143         let data = try puzzleJSON(
    144             relatives: [nil, nil, nil, nil, nil, nil],
    145             title: "Big Draw"
    146         )
    147         let xd = try NYTToXDConverter.convert(jsonData: data)
    148         #expect(header("Title", in: xd) == "Big Draw")
    149 
    150         let parsed = try XD.parse(xd)
    151         let puzzle = Puzzle(xd: parsed)
    152         #expect(puzzle.title == "Big Draw")
    153     }
    154 
    155     @Test("NYT moreAnswers.valid emits XD Accept metadata and round-trips")
    156     func moreAnswersEmitAcceptMetadata() throws {
    157         let data = try puzzleJSON(
    158             relatives: [nil, nil, nil, nil, nil, nil],
    159             letters: ["A", "B", "C", "D", "Φ", "F", "G", "H", "I"],
    160             moreAnswersByCell: [4: ["PHI", "I/O", "NEW YORK", "BACK\\SLASH"]]
    161         )
    162         let xd = try NYTToXDConverter.convert(jsonData: data)
    163 
    164         #expect(xd.contains("A4 ^Accept: DPHIF DI/OF DNEW\\ YORKF DBACK\\\\SLASHF"))
    165         #expect(xd.contains("D2 ^Accept: BPHIH BI/OH BNEW\\ YORKH BBACK\\\\SLASHH"))
    166 
    167         let puzzle = Puzzle(xd: try XD.parse(xd))
    168         let cell = puzzle.cells[1][1]
    169         #expect(cell.solution == "Φ")
    170         #expect(cell.accepts("PHI"))
    171         #expect(cell.accepts("I/O"))
    172         #expect(cell.accepts("NEW YORK"))
    173         #expect(cell.accepts("BACK\\SLASH"))
    174     }
    175 
    176     @Test("Schrödinger slash cell becomes a letters-only rebus that round-trips")
    177     func schrodingerCellStripsSlash() throws {
    178         // Center cell is correct as L or W; the across answer crams both in.
    179         let data = try puzzleJSON(
    180             relatives: [nil, nil, nil, nil, nil, nil],
    181             letters: ["A", "B", "C", "D", "L/W", "F", "G", "H", "I"],
    182             moreAnswersByCell: [4: ["W/L", "LW", "WL", "L", "W"]]
    183         )
    184         let xd = try NYTToXDConverter.convert(jsonData: data)
    185 
    186         // The canonical rebus fill is the letters with the slash removed, and
    187         // the slash never reaches the grid, the header, or the Accept metadata.
    188         #expect(header("Rebus", in: xd) == "1=LW")
    189         #expect(!xd.contains("/"))
    190 
    191         let puzzle = Puzzle(xd: try XD.parse(xd))
    192         let cell = puzzle.cells[1][1]
    193         #expect(cell.solution == "LW")
    194         #expect(cell.accepts("LW"))   // both letters (across)
    195         #expect(cell.accepts("L"))    // either single letter (down)
    196         #expect(cell.accepts("W"))
    197         #expect(!cell.accepts("L/W")) // the slash itself is not a valid entry
    198     }
    199 
    200     @Test("Many distinct rebus fills never collide with reserved grid syntax")
    201     func manyRebusFillsAvoidReservedKeys() throws {
    202         // The 13 Schrödinger fills from the 2 Feb 2025 Sunday puzzle. Walking
    203         // ASCII from '1' reaches '=' (the Rebus header delimiter) on the 13th,
    204         // which used to yield an unparseable "==HE" entry and a grid character
    205         // the parser rejected with unknownGridCharacter.
    206         let pairs = ["L/W", "Q/B", "Z/M", "C/V", "U/I", "D/N", "R/Y",
    207                      "G/T", "S/F", "X/K", "J/P", "O/A", "H/E"]
    208         let data = try singleRowPuzzleJSON(answers: pairs)
    209         let xd = try NYTToXDConverter.convert(jsonData: data)
    210 
    211         let rebus = try #require(header("Rebus", in: xd))
    212         #expect(!rebus.contains("==")) // no entry has '=' as its key
    213         for entry in rebus.split(separator: " ") {
    214             #expect(entry.first != "=")
    215         }
    216 
    217         // The whole puzzle must now parse rather than throwing, with every
    218         // distinct fill landing as a multi-letter rebus solution.
    219         let puzzle = Puzzle(xd: try XD.parse(xd))
    220         #expect(puzzle.cells[0][0].solution == "LW")
    221         #expect(puzzle.cells[0][12].solution == "HE")
    222     }
    223 
    224     @Test("Single-character digit fills are encoded as rebus placeholders and round-trip")
    225     func digitCellsBecomeRebusPlaceholders() throws {
    226         // Mirrors the 2021-04-21 "R2D2" themer: the "2" cells are single-
    227         // character digit fills. A digit can't appear literally in the .xd grid
    228         // (the parser only accepts letters there), so each rides in via a Rebus
    229         // placeholder — the digit never lands in the grid. After parsing the
    230         // cell is an ordinary single-character cell whose solution is "2", so a
    231         // solver typing "2" directly (not through the rebus interface) is
    232         // accepted.
    233         let data = try singleRowPuzzleJSON(answers: ["R", "2", "D", "2"])
    234         let xd = try NYTToXDConverter.convert(jsonData: data)
    235 
    236         #expect(header("Rebus", in: xd) == "1=2")
    237         #expect(xd.contains("R1D1"))    // grid uses the placeholder, not a literal 2
    238         #expect(xd.contains("~ R2D2"))  // the clue answer still carries the real fill
    239 
    240         let puzzle = Puzzle(xd: try XD.parse(xd))
    241         let cell = puzzle.cells[0][1]
    242         #expect(cell.solution == "2")
    243         #expect(cell.accepts("2"))      // a direct "2" keystroke counts as correct
    244         #expect(!cell.accepts("R"))
    245     }
    246 
    247     @Test("Type 1 cell with no answer becomes a space-fill rebus that round-trips")
    248     func gapCellBecomesSpaceRebus() throws {
    249         // The 2006-07-06 "THE GAP" themer reads crossing words straight through
    250         // blank squares: a playable (type 1) cell with no `answer` whose only
    251         // correct state is empty. NYT tags it with a blank-marker `moreAnswers`
    252         // ("B") we must ignore. Here "TO BE" has its gap at index 2.
    253         let cells: [[String: Any]] = [
    254             ["answer": "T"],
    255             ["answer": "O"],
    256             ["type": 1, "moreAnswers": ["valid": ["B"]]],
    257             ["answer": "B"],
    258             ["answer": "E"]
    259         ]
    260         let clue: [String: Any] = [
    261             "label": "1",
    262             "direction": "Across",
    263             "cells": [0, 1, 2, 3, 4],
    264             "text": [["plain": "Repeated part of a soliloquy"]]
    265         ]
    266         let root: [String: Any] = [
    267             "publicationDate": "2025-03-03",
    268             "body": [[
    269                 "dimensions": ["width": 5, "height": 1],
    270                 "cells": cells,
    271                 "clues": [clue]
    272             ]]
    273         ]
    274         let data = try JSONSerialization.data(withJSONObject: root)
    275         let xd = try NYTToXDConverter.convert(jsonData: data)
    276 
    277         // The space fill rides in as a grid placeholder, escaped as `\space` so
    278         // it survives the whitespace-split Rebus header grammar.
    279         #expect(header("Rebus", in: xd) == "1=\\space")
    280         #expect(xd.contains("\nTO1BE\n"))   // grid uses the placeholder, never a literal gap
    281         #expect(xd.contains("~ TO BE"))     // the clue answer carries the real space
    282         #expect(!xd.contains("Accept"))     // the "B" blank marker is dropped, not an alternate
    283 
    284         let puzzle = Puzzle(xd: try XD.parse(xd))
    285         #expect(puzzle.cells[0][2].solution == " ")
    286         #expect(puzzle.cells[0][0].solution == "T")
    287         #expect(puzzle.cells[0][3].solution == "B")
    288     }
    289 
    290     @Test("NYT type 2 cells emit circled specials")
    291     func typeTwoCellsEmitCircledSpecials() throws {
    292         let data = try puzzleJSON(
    293             relatives: [nil, nil, nil, nil, nil, nil],
    294             cellTypes: [0: 2, 4: 2]
    295         )
    296         let xd = try NYTToXDConverter.convert(jsonData: data)
    297 
    298         #expect(header("Specials", in: xd) == "@=circle")
    299         #expect(xd.contains("\n@BC\nD@F\nGHI\n"))
    300         let puzzle = Puzzle(xd: try XD.parse(xd))
    301         #expect(puzzle.cells[0][0].special == .circled)
    302         #expect(puzzle.cells[1][1].special == .circled)
    303         #expect(puzzle.cells[0][0].solution == "A")
    304         #expect(puzzle.cells[1][1].solution == "E")
    305     }
    306 
    307     @Test("NYT type 3 cells emit shaded specials")
    308     func typeThreeCellsEmitShadedSpecials() throws {
    309         let data = try puzzleJSON(
    310             relatives: [nil, nil, nil, nil, nil, nil],
    311             cellTypes: [0: 3, 4: 3]
    312         )
    313         let xd = try NYTToXDConverter.convert(jsonData: data)
    314 
    315         #expect(header("Specials", in: xd) == "*=shaded")
    316         #expect(xd.contains("\n*BC\nD*F\nGHI\n"))
    317         let puzzle = Puzzle(xd: try XD.parse(xd))
    318         #expect(puzzle.cells[0][0].special == .shaded)
    319         #expect(puzzle.cells[1][1].special == .shaded)
    320     }
    321 
    322     @Test("Mixed type 2 and type 3 cells emit separate special masks")
    323     func mixedSpecialTypesEmitSeparateMasks() throws {
    324         let data = try puzzleJSON(
    325             relatives: [nil, nil, nil, nil, nil, nil],
    326             cellTypes: [0: 2, 4: 3]
    327         )
    328         let xd = try NYTToXDConverter.convert(jsonData: data)
    329 
    330         #expect(headers("Specials", in: xd) == ["@=circle *=shaded"])
    331         #expect(xd.contains("\n@BC\nD*F\nGHI\n"))
    332         let puzzle = Puzzle(xd: try XD.parse(xd))
    333         #expect(puzzle.cells[0][0].special == .circled)
    334         #expect(puzzle.cells[1][1].special == .shaded)
    335     }
    336 
    337     @Test("Revealer with ≥2 relatives produces a group")
    338     func revealerGroup() throws {
    339         // 1A (index 0) references 4A (1) and 5A (2).
    340         let data = try puzzleJSON(relatives: [[1, 2], nil, nil, nil, nil, nil])
    341         let xd = try NYTToXDConverter.convert(jsonData: data)
    342         #expect(relativesHeader(in: xd) == "1A,4A,5A")
    343     }
    344 
    345     @Test("Mutual 1-relative pair produces a group")
    346     func mutualPair() throws {
    347         // 1D (index 3) ↔ 2D (index 4).
    348         let data = try puzzleJSON(relatives: [nil, nil, nil, [4], [3], nil])
    349         let xd = try NYTToXDConverter.convert(jsonData: data)
    350         #expect(relativesHeader(in: xd) == "1D,2D")
    351     }
    352 
    353     @Test("Asymmetric 1-relative edge is discarded")
    354     func asymmetricEdgeDropped() throws {
    355         // 3D (index 5) points at 1A (0); 1A does not point back. No group.
    356         let data = try puzzleJSON(relatives: [nil, nil, nil, nil, nil, [0]])
    357         let xd = try NYTToXDConverter.convert(jsonData: data)
    358         #expect(relativesHeader(in: xd) == nil)
    359     }
    360 
    361     @Test("No relatives anywhere means no Relatives header")
    362     func noRelatives() throws {
    363         let data = try puzzleJSON(relatives: [nil, nil, nil, nil, nil, nil])
    364         let xd = try NYTToXDConverter.convert(jsonData: data)
    365         #expect(relativesHeader(in: xd) == nil)
    366     }
    367 
    368     @Test("Formatted clue text produces a thematic group")
    369     func formattedCluesProduceThemeGroup() throws {
    370         let data = try puzzleJSON(
    371             relatives: [nil, nil, nil, nil, nil, nil],
    372             formattedClueIndices: [0, 2]
    373         )
    374         let xd = try NYTToXDConverter.convert(jsonData: data)
    375         #expect(relativesHeader(in: xd) == "1A,5A")
    376     }
    377 
    378     @Test("Italic formatted clue becomes XD brace markup and round-trips to an emphasized run")
    379     func italicClueBecomesBraceMarkup() throws {
    380         let data = try puzzleJSON(
    381             relatives: [nil, nil, nil, nil, nil, nil],
    382             formattedOverrides: [0: "<i>10th grader critiques swanky boutique?</i>"]
    383         )
    384         let xd = try NYTToXDConverter.convert(jsonData: data)
    385         #expect(xd.contains("A1. {/10th grader critiques swanky boutique?/} ~ ABC"))
    386 
    387         let puzzle = Puzzle(xd: try XD.parse(xd))
    388         let clue = try #require(puzzle.acrossClues.first { $0.number == 1 })
    389         #expect(clue.text == "10th grader critiques swanky boutique?")
    390         let intents = clue.attributedText.runs.map(\.inlinePresentationIntent)
    391         #expect(intents == [.emphasized])
    392     }
    393 
    394     @Test("Underline formatted clue converts for display but is not a theme group")
    395     func underlineClueConvertsButDoesNotGroup() throws {
    396         let data = try puzzleJSON(
    397             relatives: [nil, nil, nil, nil, nil, nil],
    398             formattedOverrides: [0: "<u>John</u> <u>Philip</u> ___"]
    399         )
    400         let xd = try NYTToXDConverter.convert(jsonData: data)
    401         #expect(xd.contains("A1. {_John_} {_Philip_} ___ ~ ABC"))
    402         // Underline is a highlight gimmick, not NYT's italic themer marker.
    403         #expect(relativesHeader(in: xd) == nil)
    404     }
    405 
    406     @Test("Formatted field without emphasis tags falls back to plain and isn't a themer")
    407     func symbolFormattedFallsBackToPlain() throws {
    408         // NYT mirrors a bare symbol in `formatted` for image clues; it carries
    409         // no emphasis and must not be treated as markup or as a theme group.
    410         let data = try puzzleJSON(
    411             relatives: [nil, nil, nil, nil, nil, nil],
    412             formattedOverrides: [0: "¥"],
    413             clueTexts: [0: "Eastern currency"]
    414         )
    415         let xd = try NYTToXDConverter.convert(jsonData: data)
    416         #expect(xd.contains("A1. Eastern currency ~ ABC"))
    417         #expect(!xd.contains("¥"))
    418         #expect(relativesHeader(in: xd) == nil)
    419     }
    420 
    421     @Test("Formatted clue text is merged with explicit relatives")
    422     func formattedCluesMergeWithRelatives() throws {
    423         let data = try puzzleJSON(
    424             relatives: [[1, 2], nil, nil, nil, nil, nil],
    425             formattedClueIndices: [3, 4]
    426         )
    427         let xd = try NYTToXDConverter.convert(jsonData: data)
    428         let header = try #require(relativesHeader(in: xd))
    429         #expect(header.contains("1A,4A,5A"))
    430         #expect(header.contains("1D,2D"))
    431         #expect(header.contains("; "))
    432     }
    433 
    434     @Test("A revealer naming the italicized clues is folded into their group")
    435     func italicizedRevealerJoinsThemerGroup() throws {
    436         // 1A and 4A are italic themers; 5A is the plain revealer that points at
    437         // them in prose. NYT supplies no relatives for any of the three, so the
    438         // "italicized clues" phrasing is the only link.
    439         let data = try puzzleJSON(
    440             relatives: [nil, nil, nil, nil, nil, nil],
    441             formattedOverrides: [0: "<i>themer one</i>", 1: "<i>themer two</i>"],
    442             clueTexts: [2: "What the answers to the italicized clues have in common"]
    443         )
    444         let xd = try NYTToXDConverter.convert(jsonData: data)
    445         #expect(relativesHeader(in: xd) == "1A,4A,5A")
    446     }
    447 
    448     @Test("An italicized-clue mention with no italic set forms no group")
    449     func italicizedMentionWithoutSetDoesNotGroup() throws {
    450         // The revealer phrasing alone is inert: without an actual italicized
    451         // set to bind to, the clue is just prose and must not start a group.
    452         let data = try puzzleJSON(
    453             relatives: [nil, nil, nil, nil, nil, nil],
    454             clueTexts: [2: "What the answers to the italicized clues have in common"]
    455         )
    456         let xd = try NYTToXDConverter.convert(jsonData: data)
    457         #expect(relativesHeader(in: xd) == nil)
    458     }
    459 
    460     @Test("Multiple groups are semicolon-joined on a single Relatives line")
    461     func multipleGroups() throws {
    462         // Revealer group {1A, 4A, 5A} + mutual pair {1D, 2D}.
    463         let data = try puzzleJSON(relatives: [[1, 2], nil, nil, [4], [3], nil])
    464         let xd = try NYTToXDConverter.convert(jsonData: data)
    465         let header = try #require(relativesHeader(in: xd))
    466         #expect(header.contains("1A,4A,5A"))
    467         #expect(header.contains("1D,2D"))
    468         #expect(header.contains("; "))
    469     }
    470 
    471     @Test("Revealer list wins over a buggy leaf back-reference")
    472     func revealerWinsOverBadLeaf() throws {
    473         // Mirrors the real-world NYT data bug: 4A (index 1, a leaf) back-
    474         // references 3D (index 5) instead of the revealer. The asymmetric
    475         // edge must be dropped so 3D is not dragged into the group.
    476         let data = try puzzleJSON(relatives: [[1, 2], [5], nil, nil, nil, nil])
    477         let xd = try NYTToXDConverter.convert(jsonData: data)
    478         let header = try #require(relativesHeader(in: xd))
    479         #expect(header == "1A,4A,5A")
    480         #expect(!header.contains("3D"))
    481     }
    482 
    483     @Test("Duplicate indices in relatives are deduplicated")
    484     func duplicateRelativesDeduped() throws {
    485         // Real NYT data occasionally repeats a clue in the relatives array
    486         // (we saw 57A include index 23 twice). The emitted group must not
    487         // repeat the token.
    488         let data = try puzzleJSON(relatives: [[1, 2, 2, 1], nil, nil, nil, nil, nil])
    489         let xd = try NYTToXDConverter.convert(jsonData: data)
    490         #expect(relativesHeader(in: xd) == "1A,4A,5A")
    491     }
    492 
    493     @Test("Cross-references in clue text never appear in the Relatives header")
    494     func crossRefsDoNotPolluteRelatives() throws {
    495         // Text-only "See N-Down" / "With X- and Y-Down" mentions belong to
    496         // navigation and are derived in Puzzle.init, not by the converter —
    497         // so the Relatives header sees only structured-relatives groups.
    498         let data = try puzzleJSON(
    499             relatives: [[1, 2], nil, nil, nil, nil, nil],
    500             clueTexts: [
    501                 3: "With 2-Down, a phrase",
    502                 4: "See 1-Down"
    503             ]
    504         )
    505         let xd = try NYTToXDConverter.convert(jsonData: data)
    506         #expect(relativesHeader(in: xd) == "1A,4A,5A")
    507     }
    508 
    509     @Test("Clue-text refs without structured relatives produce no Relatives header")
    510     func clueTextRefsAloneEmitNothing() throws {
    511         let data = try puzzleJSON(
    512             relatives: [nil, nil, nil, nil, nil, nil],
    513             clueTexts: [
    514                 3: "With 2- and 3-Down, an environmentalist motto",
    515                 4: "See 1-Down",
    516                 5: "See 1-Down"
    517             ]
    518         )
    519         let xd = try NYTToXDConverter.convert(jsonData: data)
    520         #expect(relativesHeader(in: xd) == nil)
    521     }
    522 
    523     @Test("Self-reference in relatives is ignored")
    524     func selfReferenceIgnored() throws {
    525         // A revealer that lists itself as a relative plus one real reference
    526         // still produces a valid 2-member group (itself + the reference).
    527         let data = try puzzleJSON(relatives: [[0, 1], nil, nil, nil, nil, nil])
    528         let xd = try NYTToXDConverter.convert(jsonData: data)
    529         #expect(relativesHeader(in: xd) == "1A,4A")
    530     }
    531 
    532     // MARK: - Round-trip through XD.parse and Puzzle
    533 
    534     @Test("Emitted Relatives header round-trips through XD.parse")
    535     func roundTripThroughXD() throws {
    536         let data = try puzzleJSON(relatives: [[1, 2], nil, nil, [4], [3], nil])
    537         let xd = try NYTToXDConverter.convert(jsonData: data)
    538         let parsed = try XD.parse(xd)
    539         #expect(parsed.relatives.count == 2)
    540         let groupSets = parsed.relatives.map { Set($0) }
    541         let expectedRevealer: Set<XD.ClueRef> = [
    542             XD.ClueRef(number: 1, direction: .across),
    543             XD.ClueRef(number: 4, direction: .across),
    544             XD.ClueRef(number: 5, direction: .across)
    545         ]
    546         let expectedPair: Set<XD.ClueRef> = [
    547             XD.ClueRef(number: 1, direction: .down),
    548             XD.ClueRef(number: 2, direction: .down)
    549         ]
    550         #expect(groupSets.contains(expectedRevealer))
    551         #expect(groupSets.contains(expectedPair))
    552     }
    553 
    554     @Test("Clue-text cross-references drive Puzzle.relatedCells")
    555     func clueTextCrossRefsDrivePuzzle() throws {
    556         // 1A is a revealer that points at 4A and 5A in its clue text. In a
    557         // 3×3 open grid, 1A is row 0, 4A is row 1, 5A is row 2. With the
    558         // cursor on 1A going Across, every cell of 4A and 5A should appear
    559         // in relatedCells (and none of 1A's own row).
    560         let data = try puzzleJSON(
    561             relatives: [nil, nil, nil, nil, nil, nil],
    562             clueTexts: [0: "With 4- and 5-Across, a phrase"]
    563         )
    564         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    565         let puzzle = Puzzle(xd: xd)
    566         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    567         for c in 0..<3 {
    568             #expect(!related.contains(GridPosition(row: 0, col: c)))
    569             #expect(related.contains(GridPosition(row: 1, col: c)))
    570             #expect(related.contains(GridPosition(row: 2, col: c)))
    571         }
    572     }
    573 
    574     @Test("Revealer list without See or With drives Puzzle.relatedCells")
    575     func unanchoredRevealerListDrivesPuzzle() throws {
    576         let data = try puzzleJSON(
    577             relatives: [nil, nil, nil, nil, nil, nil],
    578             clueTexts: [
    579                 0: "What can go after the respective halves of 1-, 4- and 5-Across"
    580             ]
    581         )
    582         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    583         let puzzle = Puzzle(xd: xd)
    584         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    585         for c in 0..<3 {
    586             #expect(!related.contains(GridPosition(row: 0, col: c)))
    587             #expect(related.contains(GridPosition(row: 1, col: c)))
    588             #expect(related.contains(GridPosition(row: 2, col: c)))
    589         }
    590     }
    591 
    592     @Test("Connected components: See / With chains form a single group")
    593     func clueTextChainConnectedComponents() throws {
    594         // 1D references 2D and 3D via "With"; 2D and 3D both point back via
    595         // "See". Should resolve to a single connected component {1D,2D,3D}.
    596         let data = try puzzleJSON(
    597             relatives: [nil, nil, nil, nil, nil, nil],
    598             clueTexts: [
    599                 3: "With 2- and 3-Down, an environmentalist motto",
    600                 4: "See 1-Down",
    601                 5: "See 1-Down"
    602             ]
    603         )
    604         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    605         let puzzle = Puzzle(xd: xd)
    606         // Cursor on 1D (col 0, going down) — 2D and 3D should be related.
    607         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .down)
    608         for r in 0..<3 {
    609             #expect(related.contains(GridPosition(row: r, col: 1)))
    610             #expect(related.contains(GridPosition(row: r, col: 2)))
    611             #expect(!related.contains(GridPosition(row: r, col: 0)))
    612         }
    613     }
    614 
    615     @Test("Cross-reference outlines only fire when the focus direction matches")
    616     func crossRefsGatedByFocusDirection() throws {
    617         // 1A points at 4A and 5A. Cell (0,0) is the start of both 1A
    618         // (Across) and 1D (Down). On Across, the cursor's focus clue is
    619         // 1A — the revealer — so 4A/5A light up. Switching to Down on the
    620         // same cell makes the focus clue 1D, which isn't in any group, so
    621         // the outlines disappear.
    622         let data = try puzzleJSON(
    623             relatives: [nil, nil, nil, nil, nil, nil],
    624             clueTexts: [0: "With 4- and 5-Across, a phrase"]
    625         )
    626         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    627         let puzzle = Puzzle(xd: xd)
    628         #expect(!puzzle.relatedCells(atRow: 0, col: 0, direction: .across).isEmpty)
    629         #expect(puzzle.relatedCells(atRow: 0, col: 0, direction: .down).isEmpty)
    630     }
    631 
    632     @Test("Bare clue mentions without hyphen do not link clues")
    633     func bareClueMentionsWithoutHyphenDoNotLink() throws {
    634         let data = try puzzleJSON(
    635             relatives: [nil, nil, nil, nil, nil, nil],
    636             clueTexts: [
    637                 0: "Compare with 5 Across, sort of",
    638                 2: "Reminiscent of 1 Across"
    639             ]
    640         )
    641         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    642         let puzzle = Puzzle(xd: xd)
    643         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    644         #expect(related.isEmpty)
    645     }
    646 
    647     @Test("Single unanchored clue mention with hyphen links clues")
    648     func singleUnanchoredClueMentionWithHyphenLinks() throws {
    649         let data = try puzzleJSON(
    650             relatives: [nil, nil, nil, nil, nil, nil],
    651             clueTexts: [
    652                 0: "What might follow 5-Across in a phrase"
    653             ]
    654         )
    655         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    656         let puzzle = Puzzle(xd: xd)
    657         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    658         for c in 0..<3 {
    659             #expect(!related.contains(GridPosition(row: 0, col: c)))
    660             #expect(!related.contains(GridPosition(row: 1, col: c)))
    661             #expect(related.contains(GridPosition(row: 2, col: c)))
    662         }
    663     }
    664 
    665     @Test("Mixed Across and Down references link all mentioned clues")
    666     func mixedAcrossAndDownReferencesLinkAllMentionedClues() throws {
    667         let data = try puzzleJSON(
    668             relatives: [nil, nil, nil, nil, nil, nil],
    669             clueTexts: [
    670                 0: "Bridge between 5-Across and 2-Down"
    671             ]
    672         )
    673         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    674         let puzzle = Puzzle(xd: xd)
    675         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    676         for i in 0..<3 {
    677             #expect(related.contains(GridPosition(row: 2, col: i)))
    678             #expect(related.contains(GridPosition(row: i, col: 1)))
    679         }
    680     }
    681 
    682     @Test("Or-separated revealer lists link all mentioned clues")
    683     func orSeparatedRevealerListsLinkAllMentionedClues() throws {
    684         let data = try puzzleJSON(
    685             relatives: [nil, nil, nil, nil, nil, nil],
    686             clueTexts: [
    687                 0: "What can precede 4- or 5-Across"
    688             ]
    689         )
    690         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    691         let puzzle = Puzzle(xd: xd)
    692         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    693         for c in 0..<3 {
    694             #expect(!related.contains(GridPosition(row: 0, col: c)))
    695             #expect(related.contains(GridPosition(row: 1, col: c)))
    696             #expect(related.contains(GridPosition(row: 2, col: c)))
    697         }
    698     }
    699 
    700     @Test("[aria-label] prefix is stripped from clue text")
    701     func ariaLabelPrefixStripped() throws {
    702         let data = try puzzleJSON(
    703             relatives: [nil, nil, nil, nil, nil, nil],
    704             clueTexts: [
    705                 0: "[aria-label] Circled letter + walking stick",
    706                 3: "[ARIA-LABEL]  Circled letter + map line",
    707                 4: "Normal clue, no prefix"
    708             ]
    709         )
    710         let xd = try NYTToXDConverter.convert(jsonData: data)
    711         #expect(xd.contains("A1. Circled letter + walking stick ~ ABC"))
    712         #expect(xd.contains("D1. Circled letter + map line ~ ADG"))
    713         #expect(xd.contains("D2. Normal clue, no prefix ~ BEH"))
    714         #expect(!xd.contains("[aria-label]"))
    715         #expect(!xd.lowercased().contains("[aria-label]"))
    716     }
    717 
    718     @Test("Slash-separated clue references link all mentioned clues")
    719     func slashSeparatedClueReferencesLinkAllMentionedClues() throws {
    720         let data = try puzzleJSON(
    721             relatives: [nil, nil, nil, nil, nil, nil],
    722             clueTexts: [
    723                 0: "Phrase in 4-/5-Across"
    724             ]
    725         )
    726         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    727         let puzzle = Puzzle(xd: xd)
    728         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    729         for c in 0..<3 {
    730             #expect(!related.contains(GridPosition(row: 0, col: c)))
    731             #expect(related.contains(GridPosition(row: 1, col: c)))
    732             #expect(related.contains(GridPosition(row: 2, col: c)))
    733         }
    734     }
    735 }