NYTToXDConverterTests.swift (47382B)
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("Formatted clue decodes named and numeric HTML entities") 507 func formattedClueDecodesHTMLEntities() throws { 508 // NYT's 2026-09-10 25-Down used ` ` to preserve its deliberate 509 // double spacing within an italicized theme clue. The other forms 510 // cover the broader decoder rather than another one-off replacement. 511 let data = try puzzleJSON( 512 relatives: [nil, nil, nil, nil, nil, nil], 513 formattedOverrides: [ 514 0: "<i>Pla_e for a _ase? “Crème” … © 🙂</i>" 515 ] 516 ) 517 let xd = try NYTToXDConverter.convert(jsonData: data) 518 #expect(xd.contains("A1. {/Pla_e for a \u{00A0}_ase? “Crème” … © 🙂/} ~ ABC")) 519 520 let puzzle = Puzzle(xd: try XD.parse(xd)) 521 let clue = try #require(puzzle.acrossClues.first { $0.number == 1 }) 522 #expect(clue.text == "Pla_e for a \u{00A0}_ase? “Crème” … © 🙂") 523 } 524 525 @Test("Formatted clue preserves unrecognized HTML entities") 526 func formattedCluePreservesUnrecognizedHTMLEntity() throws { 527 let data = try puzzleJSON( 528 relatives: [nil, nil, nil, nil, nil, nil], 529 formattedOverrides: [0: "<i>Unknown &madeup; entity</i>"] 530 ) 531 let xd = try NYTToXDConverter.convert(jsonData: data) 532 #expect(xd.contains("A1. {/Unknown &madeup; entity/} ~ ABC")) 533 } 534 535 @Test("Underline formatted clue converts for display but is not a theme group") 536 func underlineClueConvertsButDoesNotGroup() throws { 537 let data = try puzzleJSON( 538 relatives: [nil, nil, nil, nil, nil, nil], 539 formattedOverrides: [0: "<u>John</u> <u>Philip</u> ___"] 540 ) 541 let xd = try NYTToXDConverter.convert(jsonData: data) 542 #expect(xd.contains("A1. {_John_} {_Philip_} ___ ~ ABC")) 543 // Underline is a highlight gimmick, not NYT's italic themer marker. 544 #expect(relativesHeader(in: xd) == nil) 545 } 546 547 @Test("Formatted field without emphasis tags falls back to plain and isn't a themer") 548 func symbolFormattedFallsBackToPlain() throws { 549 // NYT mirrors a bare symbol in `formatted` for image clues; it carries 550 // no emphasis and must not be treated as markup or as a theme group. 551 let data = try puzzleJSON( 552 relatives: [nil, nil, nil, nil, nil, nil], 553 formattedOverrides: [0: "¥"], 554 clueTexts: [0: "Eastern currency"] 555 ) 556 let xd = try NYTToXDConverter.convert(jsonData: data) 557 #expect(xd.contains("A1. Eastern currency ~ ABC")) 558 #expect(!xd.contains("¥")) 559 #expect(relativesHeader(in: xd) == nil) 560 } 561 562 @Test("Formatted clue text is merged with explicit relatives") 563 func formattedCluesMergeWithRelatives() throws { 564 let data = try puzzleJSON( 565 relatives: [[1, 2], nil, nil, nil, nil, nil], 566 formattedClueIndices: [3, 4] 567 ) 568 let xd = try NYTToXDConverter.convert(jsonData: data) 569 let header = try #require(relativesHeader(in: xd)) 570 #expect(header.contains("1A,4A,5A")) 571 #expect(header.contains("1D,2D")) 572 #expect(header.contains("; ")) 573 } 574 575 @Test("A revealer naming the italicized clues is folded into their group") 576 func italicizedRevealerJoinsThemerGroup() throws { 577 // 1A and 4A are italic themers; 5A is the plain revealer that points at 578 // them in prose. NYT supplies no relatives for any of the three, so the 579 // "italicized clues" phrasing is the only link. 580 let data = try puzzleJSON( 581 relatives: [nil, nil, nil, nil, nil, nil], 582 formattedOverrides: [0: "<i>themer one</i>", 1: "<i>themer two</i>"], 583 clueTexts: [2: "What the answers to the italicized clues have in common"] 584 ) 585 let xd = try NYTToXDConverter.convert(jsonData: data) 586 #expect(relativesHeader(in: xd) == "1A,4A,5A") 587 } 588 589 @Test("An italicized-clue mention with no italic set forms no group") 590 func italicizedMentionWithoutSetDoesNotGroup() throws { 591 // The revealer phrasing alone is inert: without an actual italicized 592 // set to bind to, the clue is just prose and must not start a group. 593 let data = try puzzleJSON( 594 relatives: [nil, nil, nil, nil, nil, nil], 595 clueTexts: [2: "What the answers to the italicized clues have in common"] 596 ) 597 let xd = try NYTToXDConverter.convert(jsonData: data) 598 #expect(relativesHeader(in: xd) == nil) 599 } 600 601 @Test("Multiple groups are semicolon-joined on a single Relatives line") 602 func multipleGroups() throws { 603 // Revealer group {1A, 4A, 5A} + mutual pair {1D, 2D}. 604 let data = try puzzleJSON(relatives: [[1, 2], nil, nil, [4], [3], nil]) 605 let xd = try NYTToXDConverter.convert(jsonData: data) 606 let header = try #require(relativesHeader(in: xd)) 607 #expect(header.contains("1A,4A,5A")) 608 #expect(header.contains("1D,2D")) 609 #expect(header.contains("; ")) 610 } 611 612 @Test("Revealer list wins over a buggy leaf back-reference") 613 func revealerWinsOverBadLeaf() throws { 614 // Mirrors the real-world NYT data bug: 4A (index 1, a leaf) back- 615 // references 3D (index 5) instead of the revealer. The asymmetric 616 // edge must be dropped so 3D is not dragged into the group. 617 let data = try puzzleJSON(relatives: [[1, 2], [5], nil, nil, nil, nil]) 618 let xd = try NYTToXDConverter.convert(jsonData: data) 619 let header = try #require(relativesHeader(in: xd)) 620 #expect(header == "1A,4A,5A") 621 #expect(!header.contains("3D")) 622 } 623 624 @Test("Duplicate indices in relatives are deduplicated") 625 func duplicateRelativesDeduped() throws { 626 // Real NYT data occasionally repeats a clue in the relatives array 627 // (we saw 57A include index 23 twice). The emitted group must not 628 // repeat the token. 629 let data = try puzzleJSON(relatives: [[1, 2, 2, 1], nil, nil, nil, nil, nil]) 630 let xd = try NYTToXDConverter.convert(jsonData: data) 631 #expect(relativesHeader(in: xd) == "1A,4A,5A") 632 } 633 634 @Test("Cross-references in clue text never appear in the Relatives header") 635 func crossRefsDoNotPolluteRelatives() throws { 636 // Text-only "See N-Down" / "With X- and Y-Down" mentions belong to 637 // navigation and are derived in Puzzle.init, not by the converter — 638 // so the Relatives header sees only structured-relatives groups. 639 let data = try puzzleJSON( 640 relatives: [[1, 2], nil, nil, nil, nil, nil], 641 clueTexts: [ 642 3: "With 2-Down, a phrase", 643 4: "See 1-Down" 644 ] 645 ) 646 let xd = try NYTToXDConverter.convert(jsonData: data) 647 #expect(relativesHeader(in: xd) == "1A,4A,5A") 648 } 649 650 @Test("Clue-text refs without structured relatives produce no Relatives header") 651 func clueTextRefsAloneEmitNothing() throws { 652 let data = try puzzleJSON( 653 relatives: [nil, nil, nil, nil, nil, nil], 654 clueTexts: [ 655 3: "With 2- and 3-Down, an environmentalist motto", 656 4: "See 1-Down", 657 5: "See 1-Down" 658 ] 659 ) 660 let xd = try NYTToXDConverter.convert(jsonData: data) 661 #expect(relativesHeader(in: xd) == nil) 662 } 663 664 @Test("Self-reference in relatives is ignored") 665 func selfReferenceIgnored() throws { 666 // A revealer that lists itself as a relative plus one real reference 667 // still produces a valid 2-member group (itself + the reference). 668 let data = try puzzleJSON(relatives: [[0, 1], nil, nil, nil, nil, nil]) 669 let xd = try NYTToXDConverter.convert(jsonData: data) 670 #expect(relativesHeader(in: xd) == "1A,4A") 671 } 672 673 // MARK: - Round-trip through XD.parse and Puzzle 674 675 @Test("Emitted Relatives header round-trips through XD.parse") 676 func roundTripThroughXD() throws { 677 let data = try puzzleJSON(relatives: [[1, 2], nil, nil, [4], [3], nil]) 678 let xd = try NYTToXDConverter.convert(jsonData: data) 679 let parsed = try XD.parse(xd) 680 #expect(parsed.relatives.count == 2) 681 let groupSets = parsed.relatives.map { Set($0) } 682 let expectedRevealer: Set<XD.ClueRef> = [ 683 XD.ClueRef(number: 1, direction: .across), 684 XD.ClueRef(number: 4, direction: .across), 685 XD.ClueRef(number: 5, direction: .across) 686 ] 687 let expectedPair: Set<XD.ClueRef> = [ 688 XD.ClueRef(number: 1, direction: .down), 689 XD.ClueRef(number: 2, direction: .down) 690 ] 691 #expect(groupSets.contains(expectedRevealer)) 692 #expect(groupSets.contains(expectedPair)) 693 } 694 695 @Test("Clue-text cross-references drive Puzzle.relatedCells") 696 func clueTextCrossRefsDrivePuzzle() throws { 697 // 1A is a revealer that points at 4A and 5A in its clue text. In a 698 // 3×3 open grid, 1A is row 0, 4A is row 1, 5A is row 2. With the 699 // cursor on 1A going Across, every cell of 4A and 5A should appear 700 // in relatedCells (and none of 1A's own row). 701 let data = try puzzleJSON( 702 relatives: [nil, nil, nil, nil, nil, nil], 703 clueTexts: [0: "With 4- and 5-Across, a phrase"] 704 ) 705 let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data)) 706 let puzzle = Puzzle(xd: xd) 707 let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across) 708 for c in 0..<3 { 709 #expect(!related.contains(GridPosition(row: 0, col: c))) 710 #expect(related.contains(GridPosition(row: 1, col: c))) 711 #expect(related.contains(GridPosition(row: 2, col: c))) 712 } 713 } 714 715 @Test("Revealer list without See or With drives Puzzle.relatedCells") 716 func unanchoredRevealerListDrivesPuzzle() throws { 717 let data = try puzzleJSON( 718 relatives: [nil, nil, nil, nil, nil, nil], 719 clueTexts: [ 720 0: "What can go after the respective halves of 1-, 4- and 5-Across" 721 ] 722 ) 723 let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data)) 724 let puzzle = Puzzle(xd: xd) 725 let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across) 726 for c in 0..<3 { 727 #expect(!related.contains(GridPosition(row: 0, col: c))) 728 #expect(related.contains(GridPosition(row: 1, col: c))) 729 #expect(related.contains(GridPosition(row: 2, col: c))) 730 } 731 } 732 733 @Test("Connected components: See / With chains form a single group") 734 func clueTextChainConnectedComponents() throws { 735 // 1D references 2D and 3D via "With"; 2D and 3D both point back via 736 // "See". Should resolve to a single connected component {1D,2D,3D}. 737 let data = try puzzleJSON( 738 relatives: [nil, nil, nil, nil, nil, nil], 739 clueTexts: [ 740 3: "With 2- and 3-Down, an environmentalist motto", 741 4: "See 1-Down", 742 5: "See 1-Down" 743 ] 744 ) 745 let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data)) 746 let puzzle = Puzzle(xd: xd) 747 // Cursor on 1D (col 0, going down) — 2D and 3D should be related. 748 let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .down) 749 for r in 0..<3 { 750 #expect(related.contains(GridPosition(row: r, col: 1))) 751 #expect(related.contains(GridPosition(row: r, col: 2))) 752 #expect(!related.contains(GridPosition(row: r, col: 0))) 753 } 754 } 755 756 @Test("Cross-reference outlines only fire when the focus direction matches") 757 func crossRefsGatedByFocusDirection() throws { 758 // 1A points at 4A and 5A. Cell (0,0) is the start of both 1A 759 // (Across) and 1D (Down). On Across, the cursor's focus clue is 760 // 1A — the revealer — so 4A/5A light up. Switching to Down on the 761 // same cell makes the focus clue 1D, which isn't in any group, so 762 // the outlines disappear. 763 let data = try puzzleJSON( 764 relatives: [nil, nil, nil, nil, nil, nil], 765 clueTexts: [0: "With 4- and 5-Across, a phrase"] 766 ) 767 let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data)) 768 let puzzle = Puzzle(xd: xd) 769 #expect(!puzzle.relatedCells(atRow: 0, col: 0, direction: .across).isEmpty) 770 #expect(puzzle.relatedCells(atRow: 0, col: 0, direction: .down).isEmpty) 771 } 772 773 @Test("Bare clue mentions without hyphen do not link clues") 774 func bareClueMentionsWithoutHyphenDoNotLink() throws { 775 let data = try puzzleJSON( 776 relatives: [nil, nil, nil, nil, nil, nil], 777 clueTexts: [ 778 0: "Compare with 5 Across, sort of", 779 2: "Reminiscent of 1 Across" 780 ] 781 ) 782 let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data)) 783 let puzzle = Puzzle(xd: xd) 784 let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across) 785 #expect(related.isEmpty) 786 } 787 788 @Test("Single unanchored clue mention with hyphen links clues") 789 func singleUnanchoredClueMentionWithHyphenLinks() throws { 790 let data = try puzzleJSON( 791 relatives: [nil, nil, nil, nil, nil, nil], 792 clueTexts: [ 793 0: "What might follow 5-Across in a phrase" 794 ] 795 ) 796 let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data)) 797 let puzzle = Puzzle(xd: xd) 798 let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across) 799 for c in 0..<3 { 800 #expect(!related.contains(GridPosition(row: 0, col: c))) 801 #expect(!related.contains(GridPosition(row: 1, col: c))) 802 #expect(related.contains(GridPosition(row: 2, col: c))) 803 } 804 } 805 806 @Test("Mixed Across and Down references link all mentioned clues") 807 func mixedAcrossAndDownReferencesLinkAllMentionedClues() throws { 808 let data = try puzzleJSON( 809 relatives: [nil, nil, nil, nil, nil, nil], 810 clueTexts: [ 811 0: "Bridge between 5-Across and 2-Down" 812 ] 813 ) 814 let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data)) 815 let puzzle = Puzzle(xd: xd) 816 let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across) 817 for i in 0..<3 { 818 #expect(related.contains(GridPosition(row: 2, col: i))) 819 #expect(related.contains(GridPosition(row: i, col: 1))) 820 } 821 } 822 823 @Test("Or-separated revealer lists link all mentioned clues") 824 func orSeparatedRevealerListsLinkAllMentionedClues() throws { 825 let data = try puzzleJSON( 826 relatives: [nil, nil, nil, nil, nil, nil], 827 clueTexts: [ 828 0: "What can precede 4- or 5-Across" 829 ] 830 ) 831 let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data)) 832 let puzzle = Puzzle(xd: xd) 833 let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across) 834 for c in 0..<3 { 835 #expect(!related.contains(GridPosition(row: 0, col: c))) 836 #expect(related.contains(GridPosition(row: 1, col: c))) 837 #expect(related.contains(GridPosition(row: 2, col: c))) 838 } 839 } 840 841 @Test("[aria-label] prefix is stripped from clue text") 842 func ariaLabelPrefixStripped() throws { 843 let data = try puzzleJSON( 844 relatives: [nil, nil, nil, nil, nil, nil], 845 clueTexts: [ 846 0: "[aria-label] Circled letter + walking stick", 847 3: "[ARIA-LABEL] Circled letter + map line", 848 4: "Normal clue, no prefix" 849 ] 850 ) 851 let xd = try NYTToXDConverter.convert(jsonData: data) 852 #expect(xd.contains("A1. Circled letter + walking stick ~ ABC")) 853 #expect(xd.contains("D1. Circled letter + map line ~ ADG")) 854 #expect(xd.contains("D2. Normal clue, no prefix ~ BEH")) 855 #expect(!xd.contains("[aria-label]")) 856 #expect(!xd.lowercased().contains("[aria-label]")) 857 } 858 859 @Test("Slash-separated clue references link all mentioned clues") 860 func slashSeparatedClueReferencesLinkAllMentionedClues() throws { 861 let data = try puzzleJSON( 862 relatives: [nil, nil, nil, nil, nil, nil], 863 clueTexts: [ 864 0: "Phrase in 4-/5-Across" 865 ] 866 ) 867 let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data)) 868 let puzzle = Puzzle(xd: xd) 869 let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across) 870 for c in 0..<3 { 871 #expect(!related.contains(GridPosition(row: 0, col: c))) 872 #expect(related.contains(GridPosition(row: 1, col: c))) 873 #expect(related.contains(GridPosition(row: 2, col: c))) 874 } 875 } 876 877 @Test("Starred theme clues drive Puzzle.relatedCells") 878 func starredCluesDrivePuzzle() throws { 879 // 1A and 4A are both marked with a leading asterisk. The asterisk is 880 // the constructor's own "these go together", so focusing 1A should 881 // outline 4A even though neither clue names the other. 882 let data = try puzzleJSON( 883 relatives: [nil, nil, nil, nil, nil, nil], 884 clueTexts: [ 885 0: "*Theme answer one", 886 1: "*Theme answer two" 887 ] 888 ) 889 let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data)) 890 let puzzle = Puzzle(xd: xd) 891 let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across) 892 for c in 0..<3 { 893 #expect(!related.contains(GridPosition(row: 0, col: c))) 894 #expect(related.contains(GridPosition(row: 1, col: c))) 895 #expect(!related.contains(GridPosition(row: 2, col: c))) 896 } 897 } 898 899 @Test("Starred theme cells carry a passive cross-reference group") 900 func starredCluesCarryCellGroup() throws { 901 // The group has to reach `cellGroups` too: that is what hatches the 902 // theme squares the moment the puzzle opens, before the solver has 903 // focused any clue — the only cue on a phone, where the clue list 904 // can't be scanned for asterisks. 905 let data = try puzzleJSON( 906 relatives: [nil, nil, nil, nil, nil, nil], 907 clueTexts: [ 908 0: "*Theme answer one", 909 1: "*Theme answer two" 910 ] 911 ) 912 let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data)) 913 let puzzle = Puzzle(xd: xd) 914 for c in 0..<3 { 915 #expect(puzzle.cellGroups[GridPosition(row: 0, col: c)] != nil) 916 #expect(puzzle.cellGroups[GridPosition(row: 1, col: c)] != nil) 917 #expect(puzzle.cellGroups[GridPosition(row: 2, col: c)] == nil) 918 } 919 } 920 921 @Test("An unstarred revealer naming the set joins its themers") 922 func unstarredRevealerJoinsStarredSet() throws { 923 // 1A and 4A are starred; 5A is the revealer and carries no asterisk, 924 // so its prose ("each asterisked clue") is the only thing tying it to 925 // them. Focusing it should still outline both themers. 926 let data = try puzzleJSON( 927 relatives: [nil, nil, nil, nil, nil, nil], 928 clueTexts: [ 929 0: "*Theme answer one", 930 1: "*Theme answer two", 931 2: "A literal description of each asterisked clue" 932 ] 933 ) 934 let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data)) 935 let puzzle = Puzzle(xd: xd) 936 let related = puzzle.relatedCells(atRow: 2, col: 0, direction: .across) 937 for c in 0..<3 { 938 #expect(related.contains(GridPosition(row: 0, col: c))) 939 #expect(related.contains(GridPosition(row: 1, col: c))) 940 #expect(!related.contains(GridPosition(row: 2, col: c))) 941 } 942 } 943 944 @Test("A lone starred clue forms no group") 945 func loneStarredClueFormsNoGroup() throws { 946 // A single leading asterisk is as likely to be literal prose as a 947 // theme marker, and one clue is not a set either way. 948 let data = try puzzleJSON( 949 relatives: [nil, nil, nil, nil, nil, nil], 950 clueTexts: [0: "*Not a theme, just an asterisk"] 951 ) 952 let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data)) 953 let puzzle = Puzzle(xd: xd) 954 #expect(puzzle.crossReferenceGroups.isEmpty) 955 #expect(puzzle.relatedCells(atRow: 0, col: 0, direction: .across).isEmpty) 956 } 957 958 @Test("Starred themers stay separate from a prose cross-reference") 959 func starredSetDoesNotMergeWithProseGroup() throws { 960 // 1A is starred and also points at 3-Down. The prose link and the 961 // theme set are different relations, so they stay two groups rather 962 // than collapsing into one component through their shared clue. 963 let data = try puzzleJSON( 964 relatives: [nil, nil, nil, nil, nil, nil], 965 clueTexts: [ 966 0: "*Theme answer one, with 3-Down", 967 1: "*Theme answer two" 968 ] 969 ) 970 let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data)) 971 let puzzle = Puzzle(xd: xd) 972 #expect(puzzle.crossReferenceGroups.count == 2) 973 let prose: Set<Puzzle.ClueRef> = [ 974 Puzzle.ClueRef(number: 1, direction: .across), 975 Puzzle.ClueRef(number: 3, direction: .down) 976 ] 977 let theme: Set<Puzzle.ClueRef> = [ 978 Puzzle.ClueRef(number: 1, direction: .across), 979 Puzzle.ClueRef(number: 4, direction: .across) 980 ] 981 #expect(puzzle.crossReferenceGroups.contains(prose)) 982 #expect(puzzle.crossReferenceGroups.contains(theme)) 983 } 984 985 // MARK: - Overlay assets 986 987 private func overlayJSON( 988 beforeStart: Any? = nil, 989 afterSolve: Any? = nil, 990 assetURIs: [String] 991 ) throws -> Data { 992 var body: [String: Any] = ["cells": [], "clues": []] 993 var overlays: [String: Any] = [:] 994 if let beforeStart { 995 overlays["beforeStart"] = beforeStart 996 } 997 if let afterSolve { 998 overlays["afterSolve"] = afterSolve 999 } 1000 if !overlays.isEmpty { 1001 body["overlays"] = overlays 1002 } 1003 let root: [String: Any] = [ 1004 "body": [body], 1005 "assets": assetURIs.map { ["uri": $0] } 1006 ] 1007 return try JSONSerialization.data(withJSONObject: root) 1008 } 1009 1010 @Test("Overlay phases are independent one-based indices into assets") 1011 func overlayPhaseIndicesAreOneBased() throws { 1012 let data = try overlayJSON( 1013 beforeStart: 1, 1014 afterSolve: 2, 1015 assetURIs: ["https://example.test/start.png", "https://example.test/solve.png"] 1016 ) 1017 #expect(try NYTToXDConverter.beforeStartImageURL(jsonData: data)?.absoluteString 1018 == "https://example.test/start.png") 1019 #expect(try NYTToXDConverter.afterSolveImageURL(jsonData: data)?.absoluteString 1020 == "https://example.test/solve.png") 1021 } 1022 1023 @Test("A missing overlay phase resolves to no URL") 1024 func missingOverlayPhaseResolvesToNil() throws { 1025 let data = try overlayJSON(beforeStart: 1, assetURIs: ["https://example.test/a.png"]) 1026 #expect(try NYTToXDConverter.beforeStartImageURL(jsonData: data)?.absoluteString 1027 == "https://example.test/a.png") 1028 #expect(try NYTToXDConverter.afterSolveImageURL(jsonData: data) == nil) 1029 } 1030 1031 @Test("An overlay index outside the asset list resolves to no URL") 1032 func outOfRangeOverlayIndexResolvesToNil() throws { 1033 // Off-by-one in either direction: 0 predates the one-based scheme, 3 1034 // runs past the end. Neither may be read as a valid asset. 1035 for index in [0, 3] { 1036 let data = try overlayJSON( 1037 beforeStart: index, 1038 afterSolve: index, 1039 assetURIs: ["https://example.test/a.png", "https://example.test/b.png"] 1040 ) 1041 #expect(try NYTToXDConverter.beforeStartImageURL(jsonData: data) == nil) 1042 #expect(try NYTToXDConverter.afterSolveImageURL(jsonData: data) == nil) 1043 } 1044 } 1045 1046 @Test("An empty asset URI resolves to no URL") 1047 func emptyAssetURIResolvesToNil() throws { 1048 let data = try overlayJSON(beforeStart: 1, afterSolve: 1, assetURIs: [""]) 1049 #expect(try NYTToXDConverter.beforeStartImageURL(jsonData: data) == nil) 1050 #expect(try NYTToXDConverter.afterSolveImageURL(jsonData: data) == nil) 1051 } 1052 1053 @Test("Only overlays wholly on empty block dictionaries are eligible for letter OCR") 1054 func onlyEmptyBlockOverlaysAreLetterEligible() { 1055 let body: [String: Any] = [ 1056 "cells": [ 1057 [String: Any](), 1058 ["answer": "A"], 1059 [String: Any](), 1060 ["answer": "REDR", "type": 2], 1061 ] 1062 ] 1063 1064 #expect(NYTToXDConverter.overlayTargetsOnlyEmptyBlocks( 1065 [GridPosition(row: 0, col: 0), GridPosition(row: 1, col: 0)], 1066 body: body, 1067 width: 2, 1068 height: 2 1069 )) 1070 #expect(!NYTToXDConverter.overlayTargetsOnlyEmptyBlocks( 1071 [GridPosition(row: 0, col: 0), GridPosition(row: 0, col: 1)], 1072 body: body, 1073 width: 2, 1074 height: 2 1075 )) 1076 #expect(!NYTToXDConverter.overlayTargetsOnlyEmptyBlocks( 1077 [GridPosition(row: 1, col: 1)], 1078 body: body, 1079 width: 2, 1080 height: 2 1081 )) 1082 } 1083 1084 @Test("An empty, malformed, or out-of-bounds overlay is not letter eligible") 1085 func malformedOverlayIsNotLetterEligible() { 1086 let body: [String: Any] = ["cells": [[String: Any](), [String: Any]()]] 1087 1088 #expect(!NYTToXDConverter.overlayTargetsOnlyEmptyBlocks( 1089 [], 1090 body: body, 1091 width: 2, 1092 height: 1 1093 )) 1094 #expect(!NYTToXDConverter.overlayTargetsOnlyEmptyBlocks( 1095 [GridPosition(row: 1, col: 0)], 1096 body: body, 1097 width: 2, 1098 height: 1 1099 )) 1100 #expect(!NYTToXDConverter.overlayTargetsOnlyEmptyBlocks( 1101 [GridPosition(row: 0, col: 0)], 1102 body: ["cells": [[String: Any]()]], 1103 width: 2, 1104 height: 1 1105 )) 1106 } 1107 }