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