XDAcceptTests.swift (17837B)
1 import Foundation 2 import Testing 3 4 @testable import Crossmate 5 6 @Suite("XD Accept metadata") 7 @MainActor 8 struct XDAcceptTests { 9 @Test("Missing converter version defaults to one") 10 func missingConverterVersionDefaultsToOne() throws { 11 let xd = try XD.parse(""" 12 Title: Versionless 13 14 15 A 16 17 18 A1. Letter ~ A 19 D1. Letter ~ A 20 """) 21 22 #expect(xd.converterVersion == 1) 23 } 24 25 @Test("Explicit positive ConVer is parsed") 26 func explicitConVerIsParsed() throws { 27 let xd = try XD.parse(""" 28 Title: Versioned 29 ConVer: 3 30 31 32 A 33 34 35 A1. Letter ~ A 36 D1. Letter ~ A 37 """) 38 39 #expect(xd.converterVersion == 3) 40 } 41 42 @Test("Legacy CmVer header still parses as the converter version") 43 func legacyCmVerHeaderParses() throws { 44 let xd = try XD.parse(""" 45 Title: Legacy 46 CmVer: 3 47 48 49 A 50 51 52 A1. Letter ~ A 53 D1. Letter ~ A 54 """) 55 56 #expect(xd.converterVersion == 3) 57 } 58 59 @Test("ConVer takes precedence over a legacy CmVer when both are present") 60 func conVerWinsOverLegacyCmVer() throws { 61 let xd = try XD.parse(""" 62 Title: Both 63 ConVer: 8 64 CmVer: 3 65 66 67 A 68 69 70 A1. Letter ~ A 71 D1. Letter ~ A 72 """) 73 74 #expect(xd.converterVersion == 8) 75 } 76 77 @Test("settingConverterVersionHeader rewrites an existing ConVer in place") 78 func stampRewritesExistingConVer() throws { 79 let source = """ 80 Title: Versioned 81 ConVer: 3 82 83 84 A 85 86 87 A1. Letter ~ A 88 D1. Letter ~ A 89 """ 90 let stamped = XD.settingConverterVersionHeader(in: source, to: 8) 91 92 #expect(!stamped.contains("ConVer: 3")) 93 #expect(try XD.parse(stamped).converterVersion == 8) 94 #expect(try XD.parse(stamped).width == 1) // grid untouched 95 } 96 97 @Test("settingConverterVersionHeader upgrades a legacy CmVer to ConVer") 98 func stampUpgradesLegacyCmVer() throws { 99 let source = """ 100 Title: Legacy 101 CmVer: 3 102 103 104 A 105 106 107 A1. Letter ~ A 108 D1. Letter ~ A 109 """ 110 let stamped = XD.settingConverterVersionHeader(in: source, to: 8) 111 112 #expect(stamped.contains("ConVer: 8")) 113 #expect(!stamped.contains("CmVer:")) 114 #expect(try XD.parse(stamped).converterVersion == 8) 115 } 116 117 @Test("settingConverterVersionHeader inserts a header when none is present") 118 func stampInsertsWhenAbsent() throws { 119 let source = """ 120 Title: Versionless 121 122 123 A 124 125 126 A1. Letter ~ A 127 D1. Letter ~ A 128 """ 129 let stamped = XD.settingConverterVersionHeader(in: source, to: 8) 130 let xd = try XD.parse(stamped) 131 132 #expect(xd.converterVersion == 8) 133 #expect(xd.title == "Versionless") // metadata + grid intact 134 } 135 136 @Test("Rebus value escapes decode: \\space to a blank, \\\\ to a backslash") 137 func rebusValueEscapesDecode() throws { 138 // `\space` lets a gap cell's blank fill survive the whitespace-split 139 // Rebus header; `\\` is the defensive literal-backslash escape. Grid 140 // "A1B2": cell 1 is the space fill, cell 2 a backslash-bearing fill. 141 let puzzle = Puzzle(xd: try XD.parse(#""" 142 Title: Gaps 143 CmVer: 5 144 Rebus: 1=\space 2=C\\D 145 146 147 A1B2 148 149 150 A1. Row ~ A BC\D 151 """#)) 152 153 #expect(puzzle.cells[0][0].solution == "A") 154 #expect(puzzle.cells[0][1].solution == " ") 155 #expect(puzzle.cells[0][2].solution == "B") 156 #expect(puzzle.cells[0][3].solution == "C\\D") 157 } 158 159 @Test("A gap cell is solved by leaving it blank") 160 func gapCellSolvedWhenBlank() throws { 161 // "TO BE" with the gap at col 2 (the space fill). The gap is correct 162 // when empty and needs no fill for completion; a stray letter in it is 163 // wrong. 164 let source = #""" 165 Title: Gap 166 CmVer: 5 167 Rebus: 1=\space 168 169 170 TO1BE 171 172 173 A1. Repeated part of a soliloquy ~ TO BE 174 """# 175 let puzzle = Puzzle(xd: try XD.parse(source)) 176 177 let gap = puzzle.cells[0][2] 178 #expect(gap.expectsBlank) 179 #expect(gap.accepts("")) // blank is the correct state 180 #expect(!gap.accepts("B")) // a letter is not 181 #expect(!puzzle.cells[0][0].accepts("")) // ordinary cells still need a fill 182 183 let game = Game(puzzle: Puzzle(xd: try XD.parse(source))) 184 game.setLetter("T", atRow: 0, atCol: 0, pencil: false) 185 game.setLetter("O", atRow: 0, atCol: 1, pencil: false) 186 game.setLetter("B", atRow: 0, atCol: 3, pencil: false) 187 game.setLetter("E", atRow: 0, atCol: 4, pencil: false) 188 // The gap at col 2 is left empty. 189 #expect(game.completionState == .solved) 190 191 // A stray letter in the gap surfaces as an error rather than completion. 192 game.setLetter("X", atRow: 0, atCol: 2, pencil: false) 193 #expect(game.completionState == .filledWithErrors) 194 } 195 196 @Test("Special symbols parse per cell") 197 func specialSymbolsParsePerCell() throws { 198 let puzzle = Puzzle(xd: try XD.parse(""" 199 Title: Specials 200 CmVer: 3 201 Specials: @=circle *=shaded 202 203 204 @B@ 205 D** 206 207 208 A1. Row 1 ~ ABC 209 A4. Row 2 ~ DEF 210 D1. Col 1 ~ AD 211 D2. Col 2 ~ BE 212 D3. Col 3 ~ CF 213 """)) 214 215 #expect(puzzle.cells[0][0].special == .circled) 216 #expect(puzzle.cells[0][2].special == .circled) 217 #expect(puzzle.cells[1][1].special == .shaded) 218 #expect(puzzle.cells[1][2].special == .shaded) 219 #expect(puzzle.cells[0][1].special == nil) 220 #expect(puzzle.cells[0][0].solution == "A") 221 #expect(puzzle.cells[1][1].solution == "E") 222 } 223 224 @Test("A rebus placeholder can also mark a special cell") 225 func rebusPlaceholderCanAlsoMarkSpecialCell() throws { 226 let puzzle = Puzzle(xd: try XD.parse(""" 227 Title: Shaded Rebus 228 Rebus: 1=HEART 229 Specials: 1=shaded 230 231 232 1 233 234 235 A1. Rebus ~ HEART 236 """)) 237 238 #expect(puzzle.cells[0][0].solution == "HEART") 239 #expect(puzzle.cells[0][0].special == .shaded) 240 } 241 242 @Test("Conflicting inferred special symbols fail parsing") 243 func conflictingInferredSpecialSymbolsFailParsing() throws { 244 #expect(throws: XD.ParseError.self) { 245 try XD.parse(""" 246 Title: Conflicting Specials 247 CmVer: 3 248 Specials: @=circle 249 250 251 @BC 252 DEF 253 254 255 A1. Row 1 ~ ABC 256 A4. Row 2 ~ DEF 257 D1. Col 1 ~ ZD 258 D2. Col 2 ~ BE 259 D3. Col 3 ~ CF 260 """) 261 } 262 } 263 264 @Test("Unfilled special symbols fail parsing") 265 func unfilledSpecialSymbolsFailParsing() throws { 266 #expect(throws: XD.ParseError.self) { 267 try XD.parse(""" 268 Title: Unfilled Specials 269 CmVer: 3 270 Specials: @=circle 271 272 273 @ 274 275 276 """) 277 } 278 } 279 280 @Test("Ambiguous inferred special symbols fail parsing") 281 func ambiguousInferredSpecialSymbolsFailParsing() throws { 282 #expect(throws: XD.ParseError.self) { 283 try XD.parse(""" 284 Title: Ambiguous Specials 285 CmVer: 3 286 Specials: @=circle 287 288 289 @B@ 290 291 292 A1. Row 1 ~ ABBBC 293 D1. Col 1 ~ A 294 D2. Col 2 ~ B 295 D3. Col 3 ~ C 296 """) 297 } 298 } 299 300 @Test("Standard XD Special header marks lowercase cells") 301 func standardXDSpecialHeaderMarksLowercaseCells() throws { 302 let puzzle = Puzzle(xd: try XD.parse(""" 303 Title: Standard Special 304 CmVer: 3 305 Special: circle 306 307 308 aBC 309 310 311 A1. Row ~ ABC 312 D1. Col 1 ~ A 313 D2. Col 2 ~ B 314 D3. Col 3 ~ C 315 """)) 316 317 #expect(puzzle.cells[0][0].special == .circled) 318 #expect(puzzle.cells[0][1].special == nil) 319 } 320 321 @Test("Clue metadata is parsed generically") 322 func clueMetadataParsesGenerically() throws { 323 let source = """ 324 Title: Metadata Test 325 326 327 ABC 328 329 330 A1. Gardener's concerns. ~ ABC 331 A1 ^Refs: A2 D4 332 A1 ^Note: first value 333 A1 ^Note: second value 334 """ 335 336 let xd = try XD.parse(source) 337 let clue = try #require(xd.acrossClues.first) 338 339 #expect(clue.text == "Gardener's concerns.") 340 #expect(clue.answer == "ABC") 341 #expect(clue.metadata["Refs"] == ["A2 D4"]) 342 #expect(clue.metadata["Note"] == ["first value", "second value"]) 343 } 344 345 @Test("Accept metadata parses escaped tokens onto matching rebus cells") 346 func acceptMetadataParsesEscapedTokens() throws { 347 let source = """ 348 Title: Accept Test 349 Rebus: 1=PHI 350 351 352 1A 353 354 355 A1. Greek letter represented in the puzzle. ~ PHI 356 A1 ^Accept: IO OI I/O NEW\\ YORK A\\|B BACK\\\\SLASH 357 """ 358 359 let puzzle = Puzzle(xd: try XD.parse(source)) 360 let acceptClue = try #require(try XD.parse(source).acrossClues.first) 361 let cell = puzzle.cells[0][0] 362 363 #expect(acceptClue.metadata["Accept"] == ["IO OI I/O NEW\\ YORK A\\|B BACK\\\\SLASH"]) 364 #expect(acceptClue.acceptedAnswers == ["IO", "OI", "I/O", "NEW YORK", "A|B", "BACK\\SLASH"]) 365 #expect(cell.accepts("PHI")) 366 #expect(cell.accepts("io")) 367 #expect(cell.accepts("NEW YORK")) 368 #expect(cell.accepts("A|B")) 369 #expect(cell.accepts("BACK\\SLASH")) 370 #expect(!cell.accepts("NYC")) 371 } 372 373 @Test("Accepted answers are case-insensitive") 374 func acceptedAnswersNormalizeCaseForCompletion() throws { 375 let source = """ 376 Title: Normalize Test 377 Rebus: 1=PHI 378 379 380 1A 381 382 383 A1. Greek letter represented in the puzzle. ~ PHI 384 A1 ^Accept: PHI 385 """ 386 387 let game = Game(puzzle: Puzzle(xd: try XD.parse(source))) 388 game.setLetter("phi", atRow: 0, atCol: 0, pencil: false) 389 390 #expect(game.completionState == .solved) 391 } 392 393 @Test("Non-ASCII clue prose is allowed") 394 func nonASCIIClueProseIsAllowed() throws { 395 let puzzle = Puzzle(xd: try XD.parse(""" 396 Title: Clue Emoji 397 398 399 A 400 401 402 A1. Emoji clue is fine 🔥 ~ A 403 D1. Accented clue is fine café ~ A 404 """)) 405 406 #expect(puzzle.acrossClues[0].text == "Emoji clue is fine 🔥") 407 #expect(puzzle.downClues[0].text == "Accented clue is fine café") 408 #expect(puzzle.cells[0][0].solution == "A") 409 } 410 411 @Test("Non-ASCII answer content is rejected without an ASCII alternative") 412 func nonASCIIAnswerContentIsRejectedWithoutASCIIAlternative() { 413 #expect(throws: XD.ParseError.self) { 414 try XD.parse(""" 415 Title: Emoji Answer 416 Rebus: 1=🔥 417 418 419 1A 420 BA 421 422 423 A1. Hot stuff ~ 🔥A 424 D1. Hot stuff ~ 🔥B 425 """) 426 } 427 428 #expect(throws: XD.ParseError.self) { 429 try XD.parse(""" 430 Title: Unicode Grid 431 432 433 ΦA 434 BA 435 436 437 A1. Greek letter ~ ΦA 438 D1. Greek letter ~ ΦB 439 """) 440 } 441 } 442 443 @Test("Non-ASCII answer content is allowed with an ASCII alternative") 444 func nonASCIIAnswerContentIsAllowedWithASCIIAlternative() throws { 445 let emojiPuzzle = Puzzle(xd: try XD.parse(""" 446 Title: Emoji Answer 447 Rebus: 1=🔥 448 449 450 1A 451 BA 452 453 454 A1. Hot stuff ~ 🔥A 455 A1 ^Accept: FIREA 456 D1. Hot stuff ~ 🔥B 457 D1 ^Accept: FIREB 458 """)) 459 460 let emojiCell = emojiPuzzle.cells[0][0] 461 #expect(emojiCell.solution == "🔥") 462 #expect(emojiCell.accepts("FIRE")) 463 464 let greekPuzzle = Puzzle(xd: try XD.parse(""" 465 Title: Unicode Grid 466 467 468 ΦA 469 BA 470 471 472 A1. Greek letter ~ ΦA 473 A1 ^Accept: PHIA 474 D1. Greek letter ~ ΦB 475 D1 ^Accept: PHIB 476 """)) 477 478 let greekCell = greekPuzzle.cells[0][0] 479 #expect(greekCell.solution == "Φ") 480 #expect(greekCell.accepts("PHI")) 481 } 482 483 @Test("Whole-clue accepted answers project onto changed cells") 484 func acceptedClueAnswersProjectOntoCells() throws { 485 let source = """ 486 Title: Projection Test 487 Rebus: 1=IO 488 489 490 P1NG 491 492 493 A1. Paddle sport starter. ~ PIONG 494 A1 ^Accept: PPHING PI/ONG 495 """ 496 497 let puzzle = Puzzle(xd: try XD.parse(source)) 498 let cell = puzzle.cells[0][1] 499 500 #expect(cell.solution == "IO") 501 #expect(cell.accepts("PHI")) 502 #expect(cell.accepts("I/O")) 503 #expect(!puzzle.cells[0][0].accepts("PHI")) 504 } 505 506 @Test("Slash ~ field accepts a single magic-square alternate") 507 func slashFieldAcceptsMagicSquare() throws { 508 let source = """ 509 Title: Magic Square 510 CmVer: 3 511 512 513 ABC 514 515 516 A1. Three letters ~ ABC / ADC 517 """ 518 519 let xd = try XD.parse(source) 520 let clue = try #require(xd.acrossClues.first) 521 522 #expect(clue.answer == "ABC") 523 #expect(clue.alternativeAnswers == ["ADC"]) 524 #expect(clue.acceptedAnswers == ["ADC"]) 525 526 let puzzle = Puzzle(xd: xd) 527 #expect(puzzle.cells[0][1].solution == "B") 528 #expect(puzzle.cells[0][1].accepts("D")) 529 #expect(!puzzle.cells[0][0].accepts("D")) 530 #expect(!puzzle.cells[0][2].accepts("D")) 531 } 532 533 @Test("Slash ~ field accepts a whole-word alternate where every cell differs") 534 func slashFieldAcceptsWholeWordAlternate() throws { 535 let source = """ 536 Title: Whole Word 537 CmVer: 3 538 539 540 CIGAR 541 542 543 A1. Cigar, to a Freudian ~ CIGAR / PENIS 544 """ 545 546 let xd = try XD.parse(source) 547 let clue = try #require(xd.acrossClues.first) 548 549 #expect(clue.answer == "CIGAR") 550 #expect(clue.alternativeAnswers == ["PENIS"]) 551 552 let puzzle = Puzzle(xd: xd) 553 let canonical = Array("CIGAR") 554 let alternate = Array("PENIS") 555 for column in 0..<5 { 556 #expect(puzzle.cells[0][column].solution == String(canonical[column])) 557 #expect(puzzle.cells[0][column].accepts(String(alternate[column]))) 558 } 559 } 560 561 @Test("Free cells receive a unique answer segmentation") 562 func freeCellsReceiveUniqueSegmentation() throws { 563 // A '.' cell has no known solution, so the clue answer must be 564 // distributed across it. "XYB" over `.B` has exactly one valid split: 565 // the free cell takes "XY" and the fixed 'B' cell matches "B". 566 let puzzle = Puzzle(xd: try XD.parse(""" 567 Title: Segmentation 568 CmVer: 3 569 570 571 .B 572 573 574 A1. Two cells ~ XYB 575 """)) 576 577 #expect(puzzle.cells[0][0].solution == "XY") 578 #expect(puzzle.cells[0][1].solution == "B") 579 } 580 581 @Test("Unsegmentable answer over many free cells fails fast") 582 func unsegmentableAnswerOverManyFreeCellsFailsFast() throws { 583 // 30 free cells followed by a fixed 'Z', with a 61-char answer that 584 // does not end in Z. No valid segmentation exists, so the naive 585 // recursion would explore every one of C(59, 29) ≈ 10^16 compositions 586 // before failing — effectively forever. The memoized word-break DP 587 // collapses this to O(cells × answer), so parsing returns promptly with 588 // a mismatch. If this test hangs, the memoization has regressed. 589 let grid = String(repeating: ".", count: 30) + "Z" 590 let answer = String(repeating: "A", count: 61) 591 let source = """ 592 Title: DoS 593 CmVer: 3 594 595 596 \(grid) 597 598 599 A1. Crafted ~ \(answer) 600 """ 601 602 #expect(throws: XD.ParseError.self) { 603 try XD.parse(source) 604 } 605 } 606 607 @Test("Oversized source is rejected before parsing") 608 func oversizedSourceIsRejected() throws { 609 let source = String(repeating: "A", count: XD.maxSourceBytes + 1) 610 611 do { 612 _ = try XD.parse(source) 613 Issue.record("expected parse to throw sourceTooLarge") 614 } catch let error as XD.ParseError { 615 guard case .sourceTooLarge = error else { 616 Issue.record("expected sourceTooLarge, got \(error)") 617 return 618 } 619 } 620 } 621 622 @Test("A source at the size limit still parses") 623 func sourceAtLimitParses() throws { 624 // A well-formed minimal puzzle padded with comment-free filler in the 625 // metadata section up to exactly the cap must still parse — the bound is 626 // inclusive and far above any real puzzle. 627 let base = """ 628 Title: Padded 629 630 631 A 632 633 634 A1. Letter ~ A 635 D1. Letter ~ A 636 """ 637 let padding = String(repeating: " ", count: XD.maxSourceBytes - base.utf8.count) 638 let source = base + padding 639 640 #expect(source.utf8.count == XD.maxSourceBytes) 641 #expect(throws: Never.self) { 642 try XD.parse(source) 643 } 644 } 645 646 @Test("Over-long clue text is rejected") 647 func overLongClueTextIsRejected() throws { 648 let longText = String(repeating: "x", count: XD.maxClueTextLength + 1) 649 let source = """ 650 Title: Long Clue 651 652 653 AB 654 655 656 A1. \(longText) ~ AB 657 """ 658 659 #expect(throws: XD.ParseError.self) { 660 try XD.parse(source) 661 } 662 } 663 664 @Test("Check accepts alternate entries") 665 func checkAcceptsAlternateEntries() throws { 666 let source = """ 667 Title: Check Test 668 Rebus: 1=PHI 669 670 671 1A 672 673 674 A1. Greek letter represented in the puzzle. ~ PHI 675 A1 ^Accept: IO 676 """ 677 678 let game = Game(puzzle: Puzzle(xd: try XD.parse(source))) 679 game.setLetter("IO", atRow: 0, atCol: 0, pencil: false) 680 game.checkCells([game.puzzle.cells[0][0]]) 681 682 #expect(game.squares[0][0].mark == .pen(checked: .right)) 683 } 684 }