crossmate

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

XDDecorationTests.swift (12436B)


      1 import Foundation
      2 import Testing
      3 
      4 @testable import Crossmate
      5 
      6 @Suite("XD Decorations section")
      7 @MainActor
      8 struct XDDecorationTests {
      9     /// A 3x3 puzzle whose grid and clues stay fixed across these tests so each
     10     /// case varies only the `## Decorations` section under examination.
     11     private static func source(decorations: String?) -> String {
     12         let base = """
     13         Title: Decorations
     14 
     15 
     16         ABC
     17         DEF
     18         GHI
     19 
     20 
     21         A1. Row 1 ~ ABC
     22         A4. Row 2 ~ DEF
     23         A7. Row 3 ~ GHI
     24         D1. Col 1 ~ ADG
     25         D2. Col 2 ~ BEH
     26         D3. Col 3 ~ CFI
     27         """
     28         guard let decorations else { return base }
     29         return base + "\n\n\n## Decorations\n\n" + decorations
     30     }
     31 
     32     // MARK: - Absence
     33 
     34     @Test("A source with no Decorations section parses with no decorations")
     35     func absentSectionYieldsNoDecorations() throws {
     36         let xd = try XD.parse(Self.source(decorations: nil))
     37         #expect(xd.decorations.isEmpty)
     38         #expect(Puzzle(xd: xd).decorations.isEmpty)
     39     }
     40 
     41     // MARK: - Marks and the Specials migration
     42 
     43     @Test("mark definitions populate Cell.special, replacing the Specials header")
     44     func markPopulatesCellSpecial() throws {
     45         let puzzle = Puzzle(xd: try XD.parse(Self.source(decorations: """
     46         O.S
     47         ...
     48         ..O
     49 
     50         O. mark=circle
     51         S. mark=shaded
     52         """)))
     53 
     54         #expect(puzzle.cells[0][0].special == .circled)
     55         #expect(puzzle.cells[0][2].special == .shaded)
     56         #expect(puzzle.cells[2][2].special == .circled)
     57         #expect(puzzle.cells[0][1].special == nil)
     58         #expect(puzzle.cells[1][1].special == nil)
     59         // The fill still comes from the grid, untouched by the decoration.
     60         #expect(puzzle.cells[0][0].solution == "A")
     61     }
     62 
     63     @Test("mark accepts both circle and circled")
     64     func markAcceptsBothCircleSpellings() throws {
     65         let puzzle = Puzzle(xd: try XD.parse(Self.source(decorations: """
     66         AB.
     67         ...
     68         ...
     69 
     70         A. mark=circle
     71         B. mark=circled
     72         """)))
     73 
     74         #expect(puzzle.cells[0][0].special == .circled)
     75         #expect(puzzle.cells[0][1].special == .circled)
     76     }
     77 
     78     @Test("An after-phase mark does not become a Cell.special")
     79     func afterPhaseMarkIsNotACellSpecial() throws {
     80         let xd = try XD.parse(Self.source(decorations: """
     81         O..
     82         ...
     83         ...
     84 
     85         O. mark=circle after
     86         """))
     87         let puzzle = Puzzle(xd: xd)
     88 
     89         // It is still carried as a decoration; it just isn't visible from the
     90         // start, so it must not feed the always-drawn `special` path.
     91         #expect(puzzle.cells[0][0].special == nil)
     92         #expect(puzzle.decorations[GridPosition(row: 0, col: 0)]?.count == 1)
     93     }
     94 
     95     // MARK: - Stacking
     96 
     97     @Test("Repeating a character stacks layers in definition order")
     98     func repeatedCharacterStacksLayers() throws {
     99         let xd = try XD.parse(Self.source(decorations: """
    100         4..
    101         ...
    102         ...
    103 
    104         4. text=🌈
    105         4. bg=#00AA00 after
    106         """))
    107 
    108         let layers = try #require(xd.decorations[GridPosition(row: 0, col: 0)])
    109         #expect(layers.count == 2)
    110         #expect(layers[0] == Puzzle.Decoration(content: .text("🌈"), phase: .before))
    111         #expect(layers[1] == Puzzle.Decoration(
    112             content: .color(layer: .background, light: "#00AA00", dark: nil),
    113             phase: .after
    114         ))
    115     }
    116 
    117     @Test("A stacked character applies every layer to each of its cells")
    118     func stackedCharacterAppliesToAllItsCells() throws {
    119         let xd = try XD.parse(Self.source(decorations: """
    120         X.X
    121         ...
    122         ...
    123 
    124         X. mark=shaded
    125         X. text=!
    126         """))
    127 
    128         #expect(xd.decorations[GridPosition(row: 0, col: 0)]?.count == 2)
    129         #expect(xd.decorations[GridPosition(row: 0, col: 2)]?.count == 2)
    130         #expect(xd.decorations[GridPosition(row: 0, col: 1)] == nil)
    131     }
    132 
    133     // MARK: - Phase
    134 
    135     @Test("Phase defaults to before when the keyword is omitted")
    136     func phaseDefaultsToBefore() throws {
    137         let xd = try XD.parse(Self.source(decorations: """
    138         A..
    139         ...
    140         ...
    141 
    142         A. text=x
    143         """))
    144         #expect(xd.decorations[GridPosition(row: 0, col: 0)]?.first?.phase == .before)
    145     }
    146 
    147     @Test("An explicit before keyword is accepted")
    148     func explicitBeforeIsAccepted() throws {
    149         let xd = try XD.parse(Self.source(decorations: """
    150         A..
    151         ...
    152         ...
    153 
    154         A. text=x before
    155         """))
    156         let layer = try #require(xd.decorations[GridPosition(row: 0, col: 0)]?.first)
    157         #expect(layer.phase == .before)
    158         #expect(layer.content == .text("x"))
    159     }
    160 
    161     // MARK: - Colours
    162 
    163     @Test("bg and fg carry their appearance variants in the value")
    164     func colorKindsCarryVariantsInTheValue() throws {
    165         let xd = try XD.parse(Self.source(decorations: """
    166         12.
    167         34.
    168         ...
    169 
    170         1. bg=#111111
    171         2. bg=#222222;#333333
    172         3. fg=#444444
    173         4. fg=#555555;#666666
    174         """))
    175 
    176         func content(_ row: Int, _ col: Int) throws -> Puzzle.Decoration.Content {
    177             try #require(xd.decorations[GridPosition(row: row, col: col)]?.first?.content)
    178         }
    179 
    180         // A lone colour applies to both appearances, so `dark` stays nil rather
    181         // than being filled in with a copy.
    182         #expect(try content(0, 0) == .color(layer: .background, light: "#111111", dark: nil))
    183         #expect(try content(0, 1) == .color(layer: .background, light: "#222222", dark: "#333333"))
    184         #expect(try content(1, 0) == .color(layer: .foreground, light: "#444444", dark: nil))
    185         #expect(try content(1, 1) == .color(layer: .foreground, light: "#555555", dark: "#666666"))
    186     }
    187 
    188     @Test("A colour value with the wrong number of parts is skipped")
    189     func malformedColorValueIsSkipped() throws {
    190         let xd = try XD.parse(Self.source(decorations: """
    191         12.
    192         3..
    193         ...
    194 
    195         1. bg=#111111;#222222;#333333
    196         2. bg=#111111;
    197         3. bg=;#222222
    198         """))
    199         // Structurally sound lines, so the section still parses and the design
    200         // characters still resolve — the layers themselves are just dropped.
    201         #expect(xd.decorations.isEmpty)
    202     }
    203 
    204     // MARK: - Text
    205 
    206     @Test("A text value keeps its internal spaces")
    207     func textValueKeepsInternalSpaces() throws {
    208         let xd = try XD.parse(Self.source(decorations: """
    209         A..
    210         ...
    211         ...
    212 
    213         A. text=NEW YORK after
    214         """))
    215         let layer = try #require(xd.decorations[GridPosition(row: 0, col: 0)]?.first)
    216         #expect(layer.content == .text("NEW YORK"))
    217         #expect(layer.phase == .after)
    218     }
    219 
    220     // MARK: - Data
    221 
    222     @Test("A data value splits into mime type, encoding and payload")
    223     func dataValueSplitsIntoParts() throws {
    224         let xd = try XD.parse(Self.source(decorations: """
    225         A..
    226         ...
    227         ...
    228 
    229         A. data=image/png;base64,iVBORw0KGgo= after
    230         """))
    231         let layer = try #require(xd.decorations[GridPosition(row: 0, col: 0)]?.first)
    232         #expect(layer.content == .data(
    233             mimeType: "image/png",
    234             encoding: "base64",
    235             payload: "iVBORw0KGgo="
    236         ))
    237         #expect(layer.phase == .after)
    238     }
    239 
    240     @Test("A data value with no encoding defaults to base64")
    241     func dataValueDefaultsToBase64() throws {
    242         let xd = try XD.parse(Self.source(decorations: """
    243         A..
    244         ...
    245         ...
    246 
    247         A. data=image/png,iVBORw0KGgo=
    248         """))
    249         #expect(xd.decorations[GridPosition(row: 0, col: 0)]?.first?.content == .data(
    250             mimeType: "image/png",
    251             encoding: "base64",
    252             payload: "iVBORw0KGgo="
    253         ))
    254     }
    255 
    256     @Test("Base64 padding does not confuse the key/value split")
    257     func base64PaddingSurvivesTheSplit() throws {
    258         // The value is split on the *first* `=`, so trailing padding stays in
    259         // the payload rather than being read as another key/value boundary.
    260         let xd = try XD.parse(Self.source(decorations: """
    261         A..
    262         ...
    263         ...
    264 
    265         A. data=image/png;base64,QUJDRA==
    266         """))
    267         guard case .data(_, _, let payload) =
    268             try #require(xd.decorations[GridPosition(row: 0, col: 0)]?.first?.content) else {
    269             Issue.record("expected a data decoration")
    270             return
    271         }
    272         #expect(payload == "QUJDRA==")
    273     }
    274 
    275     // MARK: - Forward compatibility
    276 
    277     @Test("An unrecognised kind is skipped without failing the section")
    278     func unrecognisedKindIsSkipped() throws {
    279         let xd = try XD.parse(Self.source(decorations: """
    280         AB.
    281         ...
    282         ...
    283 
    284         A. sparkle=magenta
    285         B. mark=shaded
    286         """))
    287 
    288         // The character is still 'defined', so its design-grid cell resolves
    289         // instead of throwing — a newer spec revision must not break the file.
    290         #expect(xd.decorations[GridPosition(row: 0, col: 0)] == nil)
    291         #expect(xd.decorations[GridPosition(row: 0, col: 1)]?.count == 1)
    292     }
    293 
    294     @Test("An unrecognised mark value is skipped without failing the section")
    295     func unrecognisedMarkValueIsSkipped() throws {
    296         let xd = try XD.parse(Self.source(decorations: """
    297         A..
    298         ...
    299         ...
    300 
    301         A. mark=hexagon
    302         """))
    303         #expect(xd.decorations.isEmpty)
    304     }
    305 
    306     // MARK: - Failures
    307 
    308     @Test("A design grid of the wrong size is rejected")
    309     func wrongSizedDesignGridIsRejected() throws {
    310         #expect(throws: XD.ParseError.self) {
    311             try XD.parse(Self.source(decorations: """
    312             A..
    313             ...
    314 
    315             A. mark=circle
    316             """))
    317         }
    318     }
    319 
    320     @Test("A design grid with a ragged row is rejected")
    321     func raggedDesignGridIsRejected() throws {
    322         #expect(throws: XD.ParseError.self) {
    323             try XD.parse(Self.source(decorations: """
    324             A..
    325             ....
    326             ...
    327 
    328             A. mark=circle
    329             """))
    330         }
    331     }
    332 
    333     @Test("A design character with no definition is rejected")
    334     func undefinedDesignCharacterIsRejected() throws {
    335         #expect(throws: XD.ParseError.self) {
    336             try XD.parse(Self.source(decorations: """
    337             A.Z
    338             ...
    339             ...
    340 
    341             A. mark=circle
    342             """))
    343         }
    344     }
    345 
    346     @Test("A definition line with no key/value pair is rejected")
    347     func definitionWithoutPairIsRejected() throws {
    348         #expect(throws: XD.ParseError.self) {
    349             try XD.parse(Self.source(decorations: """
    350             A..
    351             ...
    352             ...
    353 
    354             A. circle
    355             """))
    356         }
    357     }
    358 
    359     @Test("A definition line with a multi-character key is rejected")
    360     func multiCharacterKeyIsRejected() throws {
    361         #expect(throws: XD.ParseError.self) {
    362             try XD.parse(Self.source(decorations: """
    363             A..
    364             ...
    365             ...
    366 
    367             AB. mark=circle
    368             """))
    369         }
    370     }
    371 
    372     // MARK: - Section handling
    373 
    374     @Test("The grid may follow the definitions")
    375     func gridMayFollowDefinitions() throws {
    376         // Grid and definition lines are told apart by whitespace, not order.
    377         let xd = try XD.parse(Self.source(decorations: """
    378         A. mark=circle
    379 
    380         A..
    381         ...
    382         ...
    383         """))
    384         #expect(xd.decorations[GridPosition(row: 0, col: 0)]?.count == 1)
    385     }
    386 
    387     @Test("An unrecognised named section is ignored")
    388     func unrecognisedNamedSectionIsIgnored() throws {
    389         let source = Self.source(decorations: """
    390         A..
    391         ...
    392         ...
    393 
    394         A. mark=circle
    395         """) + "\n\n\n## Notes\n\nSome trailing prose."
    396 
    397         let xd = try XD.parse(source)
    398         #expect(xd.decorations[GridPosition(row: 0, col: 0)]?.count == 1)
    399         #expect(xd.acrossClues.count == 3)
    400     }
    401 
    402     @Test("An all-block grid row is not mistaken for a section header")
    403     func allBlockRowIsNotASectionHeader() throws {
    404         // `#` is the block character, so a row of blocks superficially looks
    405         // like a `##` section marker.
    406         let xd = try XD.parse("""
    407         Title: Blocks
    408 
    409 
    410         ABC
    411         ###
    412         DEF
    413 
    414 
    415         A1. Row 1 ~ ABC
    416         A4. Row 3 ~ DEF
    417         D1. Col 1 ~ A
    418         D2. Col 2 ~ B
    419         D3. Col 3 ~ C
    420         """)
    421         #expect(xd.height == 3)
    422         #expect(xd.width == 3)
    423     }
    424 }