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 }