crossmate

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

NYTToXDConverterTests.swift (18422B)


      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         formattedClueIndices: Set<Int> = [],
     19         clueTexts: [Int: String] = [:],
     20         letters: [String] = ["A", "B", "C", "D", "E", "F", "G", "H", "I"],
     21         moreAnswersByCell: [Int: [String]] = [:]
     22     ) throws -> Data {
     23         precondition(relatives.count == 6, "Expected 6 clues for a 3×3 open grid")
     24         precondition(letters.count == 9, "Expected 9 cell answers for a 3×3 open grid")
     25         let defs: [(label: Int, direction: String, cells: [Int])] = [
     26             (1, "Across", [0, 1, 2]),
     27             (4, "Across", [3, 4, 5]),
     28             (5, "Across", [6, 7, 8]),
     29             (1, "Down", [0, 3, 6]),
     30             (2, "Down", [1, 4, 7]),
     31             (3, "Down", [2, 5, 8])
     32         ]
     33         var clueDicts: [[String: Any]] = []
     34         for (i, def) in defs.enumerated() {
     35             var text: [String: Any] = ["plain": clueTexts[i] ?? "clue \(i)"]
     36             if formattedClueIndices.contains(i) {
     37                 text["formatted"] = "<i>clue \(i)</i>"
     38             }
     39 
     40             var dict: [String: Any] = [
     41                 "label": "\(def.label)",
     42                 "direction": def.direction,
     43                 "cells": def.cells,
     44                 "text": [text]
     45             ]
     46             if let rels = relatives[i] {
     47                 dict["relatives"] = rels
     48             }
     49             clueDicts.append(dict)
     50         }
     51         let cells = letters.enumerated().map { index, answer -> [String: Any] in
     52             var cell: [String: Any] = ["answer": answer]
     53             if let moreAnswers = moreAnswersByCell[index] {
     54                 cell["moreAnswers"] = ["valid": moreAnswers]
     55             }
     56             return cell
     57         }
     58         let root: [String: Any] = [
     59             "publicationDate": "2025-01-01",
     60             "constructors": ["Tester"],
     61             "body": [[
     62                 "dimensions": ["width": 3, "height": 3],
     63                 "cells": cells,
     64                 "clues": clueDicts
     65             ]]
     66         ]
     67         return try JSONSerialization.data(withJSONObject: root)
     68     }
     69 
     70     /// Extracts the value after `Relatives: ` in an `.xd` source, or nil if
     71     /// the header isn't present.
     72     private func relativesHeader(in xd: String) -> String? {
     73         header("Relatives", in: xd)
     74     }
     75 
     76     private func header(_ name: String, in xd: String) -> String? {
     77         let prefix = "\(name): "
     78         for line in xd.split(separator: "\n") {
     79             if line.hasPrefix(prefix) {
     80                 return String(line.dropFirst(prefix.count))
     81             }
     82         }
     83         return nil
     84     }
     85 
     86     // MARK: - Header emission
     87 
     88     @Test("NYT metadata uses weekday title and publisher")
     89     func nytMetadataTitleAndPublisher() throws {
     90         let data = try puzzleJSON(relatives: [nil, nil, nil, nil, nil, nil])
     91         let xd = try NYTToXDConverter.convert(jsonData: data)
     92         #expect(header("Title", in: xd) == "Wednesday Crossword")
     93         #expect(header("Publisher", in: xd) == "New York Times")
     94         #expect(header("Date", in: xd) == "2025-01-01")
     95 
     96         let parsed = try XD.parse(xd)
     97         let puzzle = Puzzle(xd: parsed)
     98         #expect(puzzle.title == "Wednesday Crossword")
     99         #expect(puzzle.publisher == "New York Times")
    100     }
    101 
    102     @Test("NYT moreAnswers.valid emits XD Accept metadata and round-trips")
    103     func moreAnswersEmitAcceptMetadata() throws {
    104         let data = try puzzleJSON(
    105             relatives: [nil, nil, nil, nil, nil, nil],
    106             letters: ["A", "B", "C", "D", "Φ", "F", "G", "H", "I"],
    107             moreAnswersByCell: [4: ["PHI", "I/O", "NEW YORK", "BACK\\SLASH"]]
    108         )
    109         let xd = try NYTToXDConverter.convert(jsonData: data)
    110 
    111         #expect(xd.contains("A4 ^Accept: DPHIF DI/OF DNEW\\ YORKF DBACK\\\\SLASHF"))
    112         #expect(xd.contains("D2 ^Accept: BPHIH BI/OH BNEW\\ YORKH BBACK\\\\SLASHH"))
    113 
    114         let puzzle = Puzzle(xd: try XD.parse(xd))
    115         let cell = puzzle.cells[1][1]
    116         #expect(cell.solution == "Φ")
    117         #expect(cell.accepts("PHI"))
    118         #expect(cell.accepts("I/O"))
    119         #expect(cell.accepts("NEW YORK"))
    120         #expect(cell.accepts("BACK\\SLASH"))
    121     }
    122 
    123     @Test("Revealer with ≥2 relatives produces a group")
    124     func revealerGroup() throws {
    125         // 1A (index 0) references 4A (1) and 5A (2).
    126         let data = try puzzleJSON(relatives: [[1, 2], nil, nil, nil, nil, nil])
    127         let xd = try NYTToXDConverter.convert(jsonData: data)
    128         #expect(relativesHeader(in: xd) == "1A,4A,5A")
    129     }
    130 
    131     @Test("Mutual 1-relative pair produces a group")
    132     func mutualPair() throws {
    133         // 1D (index 3) ↔ 2D (index 4).
    134         let data = try puzzleJSON(relatives: [nil, nil, nil, [4], [3], nil])
    135         let xd = try NYTToXDConverter.convert(jsonData: data)
    136         #expect(relativesHeader(in: xd) == "1D,2D")
    137     }
    138 
    139     @Test("Asymmetric 1-relative edge is discarded")
    140     func asymmetricEdgeDropped() throws {
    141         // 3D (index 5) points at 1A (0); 1A does not point back. No group.
    142         let data = try puzzleJSON(relatives: [nil, nil, nil, nil, nil, [0]])
    143         let xd = try NYTToXDConverter.convert(jsonData: data)
    144         #expect(relativesHeader(in: xd) == nil)
    145     }
    146 
    147     @Test("No relatives anywhere means no Relatives header")
    148     func noRelatives() throws {
    149         let data = try puzzleJSON(relatives: [nil, nil, nil, nil, nil, nil])
    150         let xd = try NYTToXDConverter.convert(jsonData: data)
    151         #expect(relativesHeader(in: xd) == nil)
    152     }
    153 
    154     @Test("Formatted clue text produces a thematic group")
    155     func formattedCluesProduceThemeGroup() throws {
    156         let data = try puzzleJSON(
    157             relatives: [nil, nil, nil, nil, nil, nil],
    158             formattedClueIndices: [0, 2]
    159         )
    160         let xd = try NYTToXDConverter.convert(jsonData: data)
    161         #expect(relativesHeader(in: xd) == "1A,5A")
    162     }
    163 
    164     @Test("Formatted clue text is merged with explicit relatives")
    165     func formattedCluesMergeWithRelatives() throws {
    166         let data = try puzzleJSON(
    167             relatives: [[1, 2], nil, nil, nil, nil, nil],
    168             formattedClueIndices: [3, 4]
    169         )
    170         let xd = try NYTToXDConverter.convert(jsonData: data)
    171         let header = try #require(relativesHeader(in: xd))
    172         #expect(header.contains("1A,4A,5A"))
    173         #expect(header.contains("1D,2D"))
    174         #expect(header.contains("; "))
    175     }
    176 
    177     @Test("Multiple groups are semicolon-joined on a single Relatives line")
    178     func multipleGroups() throws {
    179         // Revealer group {1A, 4A, 5A} + mutual pair {1D, 2D}.
    180         let data = try puzzleJSON(relatives: [[1, 2], nil, nil, [4], [3], nil])
    181         let xd = try NYTToXDConverter.convert(jsonData: data)
    182         let header = try #require(relativesHeader(in: xd))
    183         #expect(header.contains("1A,4A,5A"))
    184         #expect(header.contains("1D,2D"))
    185         #expect(header.contains("; "))
    186     }
    187 
    188     @Test("Revealer list wins over a buggy leaf back-reference")
    189     func revealerWinsOverBadLeaf() throws {
    190         // Mirrors the real-world NYT data bug: 4A (index 1, a leaf) back-
    191         // references 3D (index 5) instead of the revealer. The asymmetric
    192         // edge must be dropped so 3D is not dragged into the group.
    193         let data = try puzzleJSON(relatives: [[1, 2], [5], nil, nil, nil, nil])
    194         let xd = try NYTToXDConverter.convert(jsonData: data)
    195         let header = try #require(relativesHeader(in: xd))
    196         #expect(header == "1A,4A,5A")
    197         #expect(!header.contains("3D"))
    198     }
    199 
    200     @Test("Duplicate indices in relatives are deduplicated")
    201     func duplicateRelativesDeduped() throws {
    202         // Real NYT data occasionally repeats a clue in the relatives array
    203         // (we saw 57A include index 23 twice). The emitted group must not
    204         // repeat the token.
    205         let data = try puzzleJSON(relatives: [[1, 2, 2, 1], nil, nil, nil, nil, nil])
    206         let xd = try NYTToXDConverter.convert(jsonData: data)
    207         #expect(relativesHeader(in: xd) == "1A,4A,5A")
    208     }
    209 
    210     @Test("Cross-references in clue text never appear in the Relatives header")
    211     func crossRefsDoNotPolluteRelatives() throws {
    212         // Text-only "See N-Down" / "With X- and Y-Down" mentions belong to
    213         // navigation and are derived in Puzzle.init, not by the converter —
    214         // so the Relatives header sees only structured-relatives groups.
    215         let data = try puzzleJSON(
    216             relatives: [[1, 2], nil, nil, nil, nil, nil],
    217             clueTexts: [
    218                 3: "With 2-Down, a phrase",
    219                 4: "See 1-Down"
    220             ]
    221         )
    222         let xd = try NYTToXDConverter.convert(jsonData: data)
    223         #expect(relativesHeader(in: xd) == "1A,4A,5A")
    224     }
    225 
    226     @Test("Clue-text refs without structured relatives produce no Relatives header")
    227     func clueTextRefsAloneEmitNothing() throws {
    228         let data = try puzzleJSON(
    229             relatives: [nil, nil, nil, nil, nil, nil],
    230             clueTexts: [
    231                 3: "With 2- and 3-Down, an environmentalist motto",
    232                 4: "See 1-Down",
    233                 5: "See 1-Down"
    234             ]
    235         )
    236         let xd = try NYTToXDConverter.convert(jsonData: data)
    237         #expect(relativesHeader(in: xd) == nil)
    238     }
    239 
    240     @Test("Self-reference in relatives is ignored")
    241     func selfReferenceIgnored() throws {
    242         // A revealer that lists itself as a relative plus one real reference
    243         // still produces a valid 2-member group (itself + the reference).
    244         let data = try puzzleJSON(relatives: [[0, 1], nil, nil, nil, nil, nil])
    245         let xd = try NYTToXDConverter.convert(jsonData: data)
    246         #expect(relativesHeader(in: xd) == "1A,4A")
    247     }
    248 
    249     // MARK: - Round-trip through XD.parse and Puzzle
    250 
    251     @Test("Emitted Relatives header round-trips through XD.parse")
    252     func roundTripThroughXD() throws {
    253         let data = try puzzleJSON(relatives: [[1, 2], nil, nil, [4], [3], nil])
    254         let xd = try NYTToXDConverter.convert(jsonData: data)
    255         let parsed = try XD.parse(xd)
    256         #expect(parsed.relatives.count == 2)
    257         let groupSets = parsed.relatives.map { Set($0) }
    258         let expectedRevealer: Set<XD.ClueRef> = [
    259             XD.ClueRef(number: 1, direction: .across),
    260             XD.ClueRef(number: 4, direction: .across),
    261             XD.ClueRef(number: 5, direction: .across)
    262         ]
    263         let expectedPair: Set<XD.ClueRef> = [
    264             XD.ClueRef(number: 1, direction: .down),
    265             XD.ClueRef(number: 2, direction: .down)
    266         ]
    267         #expect(groupSets.contains(expectedRevealer))
    268         #expect(groupSets.contains(expectedPair))
    269     }
    270 
    271     @Test("Clue-text cross-references drive Puzzle.relatedCells")
    272     func clueTextCrossRefsDrivePuzzle() throws {
    273         // 1A is a revealer that points at 4A and 5A in its clue text. In a
    274         // 3×3 open grid, 1A is row 0, 4A is row 1, 5A is row 2. With the
    275         // cursor on 1A going Across, every cell of 4A and 5A should appear
    276         // in relatedCells (and none of 1A's own row).
    277         let data = try puzzleJSON(
    278             relatives: [nil, nil, nil, nil, nil, nil],
    279             clueTexts: [0: "With 4- and 5-Across, a phrase"]
    280         )
    281         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    282         let puzzle = Puzzle(xd: xd)
    283         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    284         for c in 0..<3 {
    285             #expect(!related.contains(GridPosition(row: 0, col: c)))
    286             #expect(related.contains(GridPosition(row: 1, col: c)))
    287             #expect(related.contains(GridPosition(row: 2, col: c)))
    288         }
    289     }
    290 
    291     @Test("Revealer list without See or With drives Puzzle.relatedCells")
    292     func unanchoredRevealerListDrivesPuzzle() throws {
    293         let data = try puzzleJSON(
    294             relatives: [nil, nil, nil, nil, nil, nil],
    295             clueTexts: [
    296                 0: "What can go after the respective halves of 1-, 4- and 5-Across"
    297             ]
    298         )
    299         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    300         let puzzle = Puzzle(xd: xd)
    301         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    302         for c in 0..<3 {
    303             #expect(!related.contains(GridPosition(row: 0, col: c)))
    304             #expect(related.contains(GridPosition(row: 1, col: c)))
    305             #expect(related.contains(GridPosition(row: 2, col: c)))
    306         }
    307     }
    308 
    309     @Test("Connected components: See / With chains form a single group")
    310     func clueTextChainConnectedComponents() throws {
    311         // 1D references 2D and 3D via "With"; 2D and 3D both point back via
    312         // "See". Should resolve to a single connected component {1D,2D,3D}.
    313         let data = try puzzleJSON(
    314             relatives: [nil, nil, nil, nil, nil, nil],
    315             clueTexts: [
    316                 3: "With 2- and 3-Down, an environmentalist motto",
    317                 4: "See 1-Down",
    318                 5: "See 1-Down"
    319             ]
    320         )
    321         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    322         let puzzle = Puzzle(xd: xd)
    323         // Cursor on 1D (col 0, going down) — 2D and 3D should be related.
    324         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .down)
    325         for r in 0..<3 {
    326             #expect(related.contains(GridPosition(row: r, col: 1)))
    327             #expect(related.contains(GridPosition(row: r, col: 2)))
    328             #expect(!related.contains(GridPosition(row: r, col: 0)))
    329         }
    330     }
    331 
    332     @Test("Cross-reference outlines only fire when the focus direction matches")
    333     func crossRefsGatedByFocusDirection() throws {
    334         // 1A points at 4A and 5A. Cell (0,0) is the start of both 1A
    335         // (Across) and 1D (Down). On Across, the cursor's focus clue is
    336         // 1A — the revealer — so 4A/5A light up. Switching to Down on the
    337         // same cell makes the focus clue 1D, which isn't in any group, so
    338         // the outlines disappear.
    339         let data = try puzzleJSON(
    340             relatives: [nil, nil, nil, nil, nil, nil],
    341             clueTexts: [0: "With 4- and 5-Across, a phrase"]
    342         )
    343         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    344         let puzzle = Puzzle(xd: xd)
    345         #expect(!puzzle.relatedCells(atRow: 0, col: 0, direction: .across).isEmpty)
    346         #expect(puzzle.relatedCells(atRow: 0, col: 0, direction: .down).isEmpty)
    347     }
    348 
    349     @Test("Bare clue mentions without hyphen do not link clues")
    350     func bareClueMentionsWithoutHyphenDoNotLink() throws {
    351         let data = try puzzleJSON(
    352             relatives: [nil, nil, nil, nil, nil, nil],
    353             clueTexts: [
    354                 0: "Compare with 5 Across, sort of",
    355                 2: "Reminiscent of 1 Across"
    356             ]
    357         )
    358         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    359         let puzzle = Puzzle(xd: xd)
    360         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    361         #expect(related.isEmpty)
    362     }
    363 
    364     @Test("Single unanchored clue mention with hyphen links clues")
    365     func singleUnanchoredClueMentionWithHyphenLinks() throws {
    366         let data = try puzzleJSON(
    367             relatives: [nil, nil, nil, nil, nil, nil],
    368             clueTexts: [
    369                 0: "What might follow 5-Across in a phrase"
    370             ]
    371         )
    372         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    373         let puzzle = Puzzle(xd: xd)
    374         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    375         for c in 0..<3 {
    376             #expect(!related.contains(GridPosition(row: 0, col: c)))
    377             #expect(!related.contains(GridPosition(row: 1, col: c)))
    378             #expect(related.contains(GridPosition(row: 2, col: c)))
    379         }
    380     }
    381 
    382     @Test("Mixed Across and Down references link all mentioned clues")
    383     func mixedAcrossAndDownReferencesLinkAllMentionedClues() throws {
    384         let data = try puzzleJSON(
    385             relatives: [nil, nil, nil, nil, nil, nil],
    386             clueTexts: [
    387                 0: "Bridge between 5-Across and 2-Down"
    388             ]
    389         )
    390         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    391         let puzzle = Puzzle(xd: xd)
    392         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    393         for i in 0..<3 {
    394             #expect(related.contains(GridPosition(row: 2, col: i)))
    395             #expect(related.contains(GridPosition(row: i, col: 1)))
    396         }
    397     }
    398 
    399     @Test("Or-separated revealer lists link all mentioned clues")
    400     func orSeparatedRevealerListsLinkAllMentionedClues() throws {
    401         let data = try puzzleJSON(
    402             relatives: [nil, nil, nil, nil, nil, nil],
    403             clueTexts: [
    404                 0: "What can precede 4- or 5-Across"
    405             ]
    406         )
    407         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    408         let puzzle = Puzzle(xd: xd)
    409         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    410         for c in 0..<3 {
    411             #expect(!related.contains(GridPosition(row: 0, col: c)))
    412             #expect(related.contains(GridPosition(row: 1, col: c)))
    413             #expect(related.contains(GridPosition(row: 2, col: c)))
    414         }
    415     }
    416 
    417     @Test("Slash-separated clue references link all mentioned clues")
    418     func slashSeparatedClueReferencesLinkAllMentionedClues() throws {
    419         let data = try puzzleJSON(
    420             relatives: [nil, nil, nil, nil, nil, nil],
    421             clueTexts: [
    422                 0: "Phrase in 4-/5-Across"
    423             ]
    424         )
    425         let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
    426         let puzzle = Puzzle(xd: xd)
    427         let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
    428         for c in 0..<3 {
    429             #expect(!related.contains(GridPosition(row: 0, col: c)))
    430             #expect(related.contains(GridPosition(row: 1, col: c)))
    431             #expect(related.contains(GridPosition(row: 2, col: c)))
    432         }
    433     }
    434 }