crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }