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 }