crossmate

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

XDAcceptTests.swift (10071B)


      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 CmVer defaults to one")
     10     func missingCmVerDefaultsToOne() 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.cmVersion == 1)
     23     }
     24 
     25     @Test("Explicit positive CmVer is parsed")
     26     func explicitCmVerIsParsed() throws {
     27         let xd = try XD.parse("""
     28         Title: Versioned
     29         CmVer: 3
     30 
     31 
     32         A
     33 
     34 
     35         A1. Letter ~ A
     36         D1. Letter ~ A
     37         """)
     38 
     39         #expect(xd.cmVersion == 3)
     40     }
     41 
     42     @Test("Rebus value escapes decode: \\space to a blank, \\\\ to a backslash")
     43     func rebusValueEscapesDecode() throws {
     44         // `\space` lets a gap cell's blank fill survive the whitespace-split
     45         // Rebus header; `\\` is the defensive literal-backslash escape. Grid
     46         // "A1B2": cell 1 is the space fill, cell 2 a backslash-bearing fill.
     47         let puzzle = Puzzle(xd: try XD.parse(#"""
     48         Title: Gaps
     49         CmVer: 5
     50         Rebus: 1=\space 2=C\\D
     51 
     52 
     53         A1B2
     54 
     55 
     56         A1. Row ~ A BC\D
     57         """#))
     58 
     59         #expect(puzzle.cells[0][0].solution == "A")
     60         #expect(puzzle.cells[0][1].solution == " ")
     61         #expect(puzzle.cells[0][2].solution == "B")
     62         #expect(puzzle.cells[0][3].solution == "C\\D")
     63     }
     64 
     65     @Test("A gap cell is solved by leaving it blank")
     66     func gapCellSolvedWhenBlank() throws {
     67         // "TO BE" with the gap at col 2 (the space fill). The gap is correct
     68         // when empty and needs no fill for completion; a stray letter in it is
     69         // wrong.
     70         let source = #"""
     71         Title: Gap
     72         CmVer: 5
     73         Rebus: 1=\space
     74 
     75 
     76         TO1BE
     77 
     78 
     79         A1. Repeated part of a soliloquy ~ TO BE
     80         """#
     81         let puzzle = Puzzle(xd: try XD.parse(source))
     82 
     83         let gap = puzzle.cells[0][2]
     84         #expect(gap.expectsBlank)
     85         #expect(gap.accepts(""))        // blank is the correct state
     86         #expect(!gap.accepts("B"))      // a letter is not
     87         #expect(!puzzle.cells[0][0].accepts(""))  // ordinary cells still need a fill
     88 
     89         let game = Game(puzzle: Puzzle(xd: try XD.parse(source)))
     90         game.setLetter("T", atRow: 0, atCol: 0, pencil: false)
     91         game.setLetter("O", atRow: 0, atCol: 1, pencil: false)
     92         game.setLetter("B", atRow: 0, atCol: 3, pencil: false)
     93         game.setLetter("E", atRow: 0, atCol: 4, pencil: false)
     94         // The gap at col 2 is left empty.
     95         #expect(game.completionState == .solved)
     96 
     97         // A stray letter in the gap surfaces as an error rather than completion.
     98         game.setLetter("X", atRow: 0, atCol: 2, pencil: false)
     99         #expect(game.completionState == .filledWithErrors)
    100     }
    101 
    102     @Test("Special symbols parse per cell")
    103     func specialSymbolsParsePerCell() throws {
    104         let puzzle = Puzzle(xd: try XD.parse("""
    105         Title: Specials
    106         CmVer: 3
    107         Specials: @=circle *=shaded
    108 
    109 
    110         @B@
    111         D**
    112 
    113 
    114         A1. Row 1 ~ ABC
    115         A4. Row 2 ~ DEF
    116         D1. Col 1 ~ AD
    117         D2. Col 2 ~ BE
    118         D3. Col 3 ~ CF
    119         """))
    120 
    121         #expect(puzzle.cells[0][0].special == .circled)
    122         #expect(puzzle.cells[0][2].special == .circled)
    123         #expect(puzzle.cells[1][1].special == .shaded)
    124         #expect(puzzle.cells[1][2].special == .shaded)
    125         #expect(puzzle.cells[0][1].special == nil)
    126         #expect(puzzle.cells[0][0].solution == "A")
    127         #expect(puzzle.cells[1][1].solution == "E")
    128     }
    129 
    130     @Test("Conflicting inferred special symbols fail parsing")
    131     func conflictingInferredSpecialSymbolsFailParsing() throws {
    132         #expect(throws: XD.ParseError.self) {
    133             try XD.parse("""
    134             Title: Conflicting Specials
    135             CmVer: 3
    136             Specials: @=circle
    137 
    138 
    139             @BC
    140             DEF
    141 
    142 
    143             A1. Row 1 ~ ABC
    144             A4. Row 2 ~ DEF
    145             D1. Col 1 ~ ZD
    146             D2. Col 2 ~ BE
    147             D3. Col 3 ~ CF
    148             """)
    149         }
    150     }
    151 
    152     @Test("Unfilled special symbols fail parsing")
    153     func unfilledSpecialSymbolsFailParsing() throws {
    154         #expect(throws: XD.ParseError.self) {
    155             try XD.parse("""
    156             Title: Unfilled Specials
    157             CmVer: 3
    158             Specials: @=circle
    159 
    160 
    161             @
    162 
    163 
    164             """)
    165         }
    166     }
    167 
    168     @Test("Ambiguous inferred special symbols fail parsing")
    169     func ambiguousInferredSpecialSymbolsFailParsing() throws {
    170         #expect(throws: XD.ParseError.self) {
    171             try XD.parse("""
    172             Title: Ambiguous Specials
    173             CmVer: 3
    174             Specials: @=circle
    175 
    176 
    177             @B@
    178 
    179 
    180             A1. Row 1 ~ ABBBC
    181             D1. Col 1 ~ A
    182             D2. Col 2 ~ B
    183             D3. Col 3 ~ C
    184             """)
    185         }
    186     }
    187 
    188     @Test("Standard XD Special header marks lowercase cells")
    189     func standardXDSpecialHeaderMarksLowercaseCells() throws {
    190         let puzzle = Puzzle(xd: try XD.parse("""
    191         Title: Standard Special
    192         CmVer: 3
    193         Special: circle
    194 
    195 
    196         aBC
    197 
    198 
    199         A1. Row ~ ABC
    200         D1. Col 1 ~ A
    201         D2. Col 2 ~ B
    202         D3. Col 3 ~ C
    203         """))
    204 
    205         #expect(puzzle.cells[0][0].special == .circled)
    206         #expect(puzzle.cells[0][1].special == nil)
    207     }
    208 
    209     @Test("Clue metadata is parsed generically")
    210     func clueMetadataParsesGenerically() throws {
    211         let source = """
    212         Title: Metadata Test
    213 
    214 
    215         ABC
    216 
    217 
    218         A1. Gardener's concerns. ~ ABC
    219         A1 ^Refs: A2 D4
    220         A1 ^Note: first value
    221         A1 ^Note: second value
    222         """
    223 
    224         let xd = try XD.parse(source)
    225         let clue = try #require(xd.acrossClues.first)
    226 
    227         #expect(clue.text == "Gardener's concerns.")
    228         #expect(clue.answer == "ABC")
    229         #expect(clue.metadata["Refs"] == ["A2 D4"])
    230         #expect(clue.metadata["Note"] == ["first value", "second value"])
    231     }
    232 
    233     @Test("Accept metadata parses escaped tokens onto matching rebus cells")
    234     func acceptMetadataParsesEscapedTokens() throws {
    235         let source = """
    236         Title: Accept Test
    237         Rebus: 1=PHI
    238 
    239 
    240         1A
    241 
    242 
    243         A1. Greek letter represented in the puzzle. ~ PHI
    244         A1 ^Accept: IO OI I/O NEW\\ YORK A\\|B BACK\\\\SLASH
    245         """
    246 
    247         let puzzle = Puzzle(xd: try XD.parse(source))
    248         let acceptClue = try #require(try XD.parse(source).acrossClues.first)
    249         let cell = puzzle.cells[0][0]
    250 
    251         #expect(acceptClue.metadata["Accept"] == ["IO OI I/O NEW\\ YORK A\\|B BACK\\\\SLASH"])
    252         #expect(acceptClue.acceptedAnswers == ["IO", "OI", "I/O", "NEW YORK", "A|B", "BACK\\SLASH"])
    253         #expect(cell.accepts("PHI"))
    254         #expect(cell.accepts("io"))
    255         #expect(cell.accepts("NEW YORK"))
    256         #expect(cell.accepts("A|B"))
    257         #expect(cell.accepts("BACK\\SLASH"))
    258         #expect(!cell.accepts("NYC"))
    259     }
    260 
    261     @Test("Accepted answers are case-insensitive and NFC-normalized")
    262     func acceptedAnswersNormalizeForCompletion() throws {
    263         let source = """
    264         Title: Normalize Test
    265         Rebus: 1=PHI
    266 
    267 
    268         1A
    269 
    270 
    271         A1. Greek letter represented in the puzzle. ~ PHI
    272         A1 ^Accept: CAF\\É
    273         """
    274 
    275         let game = Game(puzzle: Puzzle(xd: try XD.parse(source)))
    276         game.setLetter("cafe\u{301}", atRow: 0, atCol: 0, pencil: false)
    277 
    278         #expect(game.completionState == .solved)
    279     }
    280 
    281     @Test("Whole-clue accepted answers project onto changed cells")
    282     func acceptedClueAnswersProjectOntoCells() throws {
    283         let source = """
    284         Title: Projection Test
    285         Rebus: 1=Φ
    286 
    287 
    288         P1NG
    289 
    290 
    291         A1. Paddle sport starter. ~ PΦNG
    292         A1 ^Accept: PPHING PI/ONG
    293         """
    294 
    295         let puzzle = Puzzle(xd: try XD.parse(source))
    296         let cell = puzzle.cells[0][1]
    297 
    298         #expect(cell.solution == "Φ")
    299         #expect(cell.accepts("PHI"))
    300         #expect(cell.accepts("I/O"))
    301         #expect(!puzzle.cells[0][0].accepts("PHI"))
    302     }
    303 
    304     @Test("Slash ~ field accepts a single magic-square alternate")
    305     func slashFieldAcceptsMagicSquare() throws {
    306         let source = """
    307         Title: Magic Square
    308         CmVer: 3
    309 
    310 
    311         ABC
    312 
    313 
    314         A1. Three letters ~ ABC / ADC
    315         """
    316 
    317         let xd = try XD.parse(source)
    318         let clue = try #require(xd.acrossClues.first)
    319 
    320         #expect(clue.answer == "ABC")
    321         #expect(clue.alternativeAnswers == ["ADC"])
    322         #expect(clue.acceptedAnswers == ["ADC"])
    323 
    324         let puzzle = Puzzle(xd: xd)
    325         #expect(puzzle.cells[0][1].solution == "B")
    326         #expect(puzzle.cells[0][1].accepts("D"))
    327         #expect(!puzzle.cells[0][0].accepts("D"))
    328         #expect(!puzzle.cells[0][2].accepts("D"))
    329     }
    330 
    331     @Test("Slash ~ field accepts a whole-word alternate where every cell differs")
    332     func slashFieldAcceptsWholeWordAlternate() throws {
    333         let source = """
    334         Title: Whole Word
    335         CmVer: 3
    336 
    337 
    338         CIGAR
    339 
    340 
    341         A1. Cigar, to a Freudian ~ CIGAR / PENIS
    342         """
    343 
    344         let xd = try XD.parse(source)
    345         let clue = try #require(xd.acrossClues.first)
    346 
    347         #expect(clue.answer == "CIGAR")
    348         #expect(clue.alternativeAnswers == ["PENIS"])
    349 
    350         let puzzle = Puzzle(xd: xd)
    351         let canonical = Array("CIGAR")
    352         let alternate = Array("PENIS")
    353         for column in 0..<5 {
    354             #expect(puzzle.cells[0][column].solution == String(canonical[column]))
    355             #expect(puzzle.cells[0][column].accepts(String(alternate[column])))
    356         }
    357     }
    358 
    359     @Test("Check accepts alternate entries")
    360     func checkAcceptsAlternateEntries() throws {
    361         let source = """
    362         Title: Check Test
    363         Rebus: 1=PHI
    364 
    365 
    366         1A
    367 
    368 
    369         A1. Greek letter represented in the puzzle. ~ PHI
    370         A1 ^Accept: IO
    371         """
    372 
    373         let game = Game(puzzle: Puzzle(xd: try XD.parse(source)))
    374         game.setLetter("IO", atRow: 0, atCol: 0, pencil: false)
    375         game.checkCells([game.puzzle.cells[0][0]])
    376 
    377         #expect(game.squares[0][0].mark == .pen(checked: .right))
    378     }
    379 }