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 }