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 }