PUZToXDConverterTests.swift (14294B)
1 import Foundation 2 import Testing 3 4 @testable import Crossmate 5 6 @Suite("PUZToXDConverter") 7 struct PUZToXDConverterTests { 8 @Test("Across Lite binary converts to XD and parses") 9 func basicPuzzleConvertsAndParses() throws { 10 let data = try puzData( 11 width: 3, 12 height: 3, 13 solution: "ABCDEFGHI", 14 title: "Mini PUZ", 15 author: "Tester", 16 copyright: "\u{00A9}2026", 17 clues: [ 18 "Across 1", 19 "Down 1", 20 "Down 2", 21 "Down 3", 22 "Across 4", 23 "Across 5" 24 ] 25 ) 26 27 let source = try PUZToXDConverter.convert(puzData: data) 28 let xd = try XD.parse(source) 29 let puzzle = Puzzle(xd: xd) 30 31 #expect(puzzle.title == "Mini PUZ") 32 #expect(puzzle.author == "Tester") 33 #expect(xd.copyright == "\u{00A9}2026") 34 #expect(puzzle.width == 3) 35 #expect(puzzle.height == 3) 36 #expect(puzzle.acrossClues.map(\.number) == [1, 4, 5]) 37 #expect(puzzle.downClues.map(\.number) == [1, 2, 3]) 38 #expect(xd.acrossClues.first?.answer == "ABC") 39 #expect(xd.downClues.first?.answer == "ADG") 40 } 41 42 @Test("Blocks are translated to XD blocks") 43 func blocksConvert() throws { 44 let data = try puzData( 45 width: 3, 46 height: 3, 47 solution: "ABC.D.EFG", 48 title: "Blocks", 49 author: "", 50 copyright: "", 51 clues: [ 52 "Across 1", 53 "Down 2", 54 "Across 4" 55 ] 56 ) 57 58 let source = try PUZToXDConverter.convert(puzData: data) 59 #expect(source.contains("ABC\n#D#\nEFG")) 60 61 let puzzle = Puzzle(xd: try XD.parse(source)) 62 #expect(puzzle.cells[1][0].isBlock) 63 #expect(puzzle.cells[1][2].isBlock) 64 } 65 66 @Test("All-uppercase PUZ title is displayed in title case") 67 func uppercaseTitleIsTitleCased() throws { 68 let data = try puzData( 69 width: 3, 70 height: 3, 71 solution: "ABCDEFGHI", 72 title: "THEMELESS MONDAY #875", 73 author: "", 74 copyright: "", 75 clues: [ 76 "Across 1", 77 "Down 1", 78 "Down 2", 79 "Down 3", 80 "Across 4", 81 "Across 5" 82 ] 83 ) 84 85 let puzzle = Puzzle(xd: try XD.parse(try PUZToXDConverter.convert(puzData: data))) 86 #expect(puzzle.title == "Themeless Monday #875") 87 } 88 89 @Test("Mixed-case PUZ title is preserved") 90 func mixedCaseTitleIsPreserved() throws { 91 let data = try puzData( 92 width: 3, 93 height: 3, 94 solution: "ABCDEFGHI", 95 title: "Mini PUZ", 96 author: "", 97 copyright: "", 98 clues: [ 99 "Across 1", 100 "Down 1", 101 "Down 2", 102 "Down 3", 103 "Across 4", 104 "Across 5" 105 ] 106 ) 107 108 let puzzle = Puzzle(xd: try XD.parse(try PUZToXDConverter.convert(puzData: data))) 109 #expect(puzzle.title == "Mini PUZ") 110 } 111 112 @Test("Circled cells emit a decorations section and keep their letters") 113 func circledCellsEmitDecorationsSection() throws { 114 let data = try puzData( 115 width: 3, 116 height: 3, 117 solution: "ABCDEFGHI", 118 title: "Circles", 119 author: "", 120 copyright: "", 121 clues: [ 122 "Across 1", 123 "Down 1", 124 "Down 2", 125 "Down 3", 126 "Across 4", 127 "Across 5" 128 ], 129 circledCells: [0, 4] 130 ) 131 132 let source = try PUZToXDConverter.convert(puzData: data) 133 #expect(!source.contains("Specials:")) 134 #expect(source.contains("\nABC\nDEF\nGHI\n")) 135 #expect(source.contains("## Decorations")) 136 #expect(source.contains("\nO..\n.O.\n...\n")) 137 #expect(source.contains("O. mark=circle")) 138 139 let puzzle = Puzzle(xd: try XD.parse(source)) 140 #expect(puzzle.cells[0][0].special == .circled) 141 #expect(puzzle.cells[1][1].special == .circled) 142 #expect(puzzle.cells[0][0].solution == "A") 143 #expect(puzzle.cells[1][1].solution == "E") 144 } 145 146 @Test("Rebus fills convert, dedupe by value, and round-trip") 147 func rebusFillsConvertAndDedupe() throws { 148 // Cells 0 and 8 share the fill CAT; cell 4 is DOG. The two CAT cells 149 // must collapse to a single Rebus header entry and reuse one grid 150 // placeholder — keying by cell index (the old bug) would mint three. 151 let data = try puzData( 152 width: 3, 153 height: 3, 154 solution: "ABCDEFGHI", 155 title: "Rebus", 156 author: "", 157 copyright: "", 158 clues: [ 159 "Across 1", 160 "Down 1", 161 "Down 2", 162 "Down 3", 163 "Across 4", 164 "Across 5" 165 ], 166 rebusGrid: [0: 0, 4: 1, 8: 0], 167 rebusTable: [0: "CAT", 1: "DOG"] 168 ) 169 170 let source = try PUZToXDConverter.convert(puzData: data) 171 #expect(header("Rebus", in: source) == "1=CAT 2=DOG") 172 #expect(source.contains("\n1BC\nD2F\nGH1\n")) 173 174 let puzzle = Puzzle(xd: try XD.parse(source)) 175 #expect(puzzle.cells[0][0].solution == "CAT") 176 #expect(puzzle.cells[1][1].solution == "DOG") 177 #expect(puzzle.cells[2][2].solution == "CAT") 178 } 179 180 @Test("A single rebus value at RTBL key 0 decodes rather than vanishing") 181 func singleRebusValueDecodes() throws { 182 // The common case: one rebus fill, `RTBL` key 0, so the `GRBS` byte is 1. 183 // Reading the grid byte directly as the table key (the off-by-one bug) 184 // looked up the absent key 1, silently dropping the rebus and leaving the 185 // cell's raw solution letter behind. 186 let data = try puzData( 187 width: 3, 188 height: 3, 189 solution: "ABCDEFGHI", 190 title: "Rebus", 191 author: "", 192 copyright: "", 193 clues: [ 194 "Across 1", 195 "Down 1", 196 "Down 2", 197 "Down 3", 198 "Across 4", 199 "Across 5" 200 ], 201 rebusGrid: [4: 0], 202 rebusTable: [0: "HEART"] 203 ) 204 205 let source = try PUZToXDConverter.convert(puzData: data) 206 #expect(header("Rebus", in: source) == "1=HEART") 207 208 let puzzle = Puzzle(xd: try XD.parse(source)) 209 #expect(puzzle.cells[1][1].solution == "HEART") 210 } 211 212 @Test("A cell both circled and rebus keeps its fill and circle") 213 func circledRebusCellKeepsFillAndCircle() throws { 214 let data = try puzData( 215 width: 3, 216 height: 3, 217 solution: "ABCDEFGHI", 218 title: "Both", 219 author: "", 220 copyright: "", 221 clues: [ 222 "Across 1", 223 "Down 1", 224 "Down 2", 225 "Down 3", 226 "Across 4", 227 "Across 5" 228 ], 229 circledCells: [0, 4], 230 rebusGrid: [4: 0], 231 rebusTable: [0: "HEART"] 232 ) 233 234 let source = try PUZToXDConverter.convert(puzData: data) 235 #expect(header("Rebus", in: source) == "1=HEART") 236 #expect(header("Specials", in: source) == nil) 237 #expect(source.contains("\nABC\nD1F\nGHI\n")) 238 #expect(source.contains("\nO..\n.O.\n...\n")) 239 #expect(source.contains("O. mark=circle")) 240 241 let puzzle = Puzzle(xd: try XD.parse(source)) 242 #expect(puzzle.cells[1][1].solution == "HEART") 243 #expect(puzzle.cells[0][0].special == .circled) 244 #expect(puzzle.cells[1][1].special == .circled) 245 } 246 247 @Test("Many distinct rebus fills never collide with reserved grid syntax") 248 func manyRebusFillsAvoidReservedKeys() throws { 249 // Keying placeholders by cell index used to reach '=' (the Rebus header 250 // delimiter) on the 13th rebus cell and '@' (the circle marker) on the 251 // 16th, and to trap on UInt8 overflow past ~207. Twenty distinct fills 252 // must all draw from the curated placeholder set and steer clear of 253 // every reserved grid/header character. 254 var grid: [Int: Int] = [:] 255 var table: [Int: String] = [:] 256 let values = (0..<20).map { "R\($0)X" } 257 for (offset, value) in values.enumerated() { 258 grid[offset] = offset 259 table[offset] = value 260 } 261 262 let data = try puzData( 263 width: 5, 264 height: 5, 265 solution: String(repeating: "A", count: 25), 266 title: "Rebus", 267 author: "", 268 copyright: "", 269 clues: Array(repeating: "Clue", count: 10), 270 rebusGrid: grid, 271 rebusTable: table 272 ) 273 274 let source = try PUZToXDConverter.convert(puzData: data) 275 let rebus = try #require(header("Rebus", in: source)) 276 for entry in rebus.split(separator: " ") { 277 let key = try #require(entry.first) 278 #expect(!["#", "_", ".", "@", "*", "="].contains(key)) 279 } 280 281 // The whole puzzle now parses rather than throwing, with each distinct 282 // fill landing as its own multi-letter rebus solution. 283 let puzzle = Puzzle(xd: try XD.parse(source)) 284 #expect(puzzle.cells[0][0].solution == "R0X") 285 #expect(puzzle.cells[3][4].solution == "R19X") 286 } 287 288 @Test("A Data slice with a non-zero start index converts identically") 289 func sliceWithNonZeroStartIndexConverts() throws { 290 let data = try puzData( 291 width: 3, 292 height: 3, 293 solution: "ABCDEFGHI", 294 title: "Sliced", 295 author: "Tester", 296 copyright: "", 297 clues: [ 298 "Across 1", 299 "Down 1", 300 "Down 2", 301 "Down 3", 302 "Across 4", 303 "Across 5" 304 ] 305 ) 306 307 var padded = Data([0xFF, 0xFF, 0xFF, 0xFF]) 308 padded.append(data) 309 let slice = padded[4...] 310 #expect(slice.startIndex != 0) 311 312 let source = try PUZToXDConverter.convert(puzData: slice) 313 #expect(source == (try PUZToXDConverter.convert(puzData: data))) 314 } 315 316 @Test("A valid small grid with a tail past the raw cap is rejected") 317 func oversizedRawDataIsRejected() throws { 318 var data = try puzData( 319 width: 3, 320 height: 3, 321 solution: "ABCDEFGHI", 322 title: "Big Tail", 323 author: "", 324 copyright: "", 325 clues: [ 326 "Across 1", 327 "Down 1", 328 "Down 2", 329 "Down 3", 330 "Across 4", 331 "Across 5" 332 ] 333 ) 334 data.append(Data(count: PUZToXDConverter.maxSourceBytes + 1 - data.count)) 335 336 #expect(throws: PUZToXDConverter.ConversionError.self) { 337 _ = try PUZToXDConverter.convert(puzData: data) 338 } 339 } 340 341 private func puzData( 342 width: UInt8, 343 height: UInt8, 344 solution: String, 345 title: String, 346 author: String, 347 copyright: String, 348 clues: [String], 349 notes: String = "", 350 circledCells: Set<Int> = [], 351 rebusGrid: [Int: Int] = [:], 352 rebusTable: [Int: String] = [:] 353 ) throws -> Data { 354 let cellCount = Int(width) * Int(height) 355 let solutionBytes = Array(solution.utf8) 356 #expect(solutionBytes.count == cellCount) 357 358 var data = Data(repeating: 0, count: 0x34) 359 for (offset, byte) in "ACROSS&DOWN".utf8.enumerated() { 360 data[0x02 + offset] = byte 361 } 362 data[0x18] = UInt8(ascii: "1") 363 data[0x19] = UInt8(ascii: ".") 364 data[0x1A] = UInt8(ascii: "3") 365 data[0x2C] = width 366 data[0x2D] = height 367 data[0x2E] = UInt8(clues.count & 0xFF) 368 data[0x2F] = UInt8((clues.count >> 8) & 0xFF) 369 370 data.append(contentsOf: solutionBytes) 371 data.append(contentsOf: Array(repeating: UInt8(ascii: "-"), count: cellCount)) 372 373 for string in [title, author, copyright] + clues + [notes] { 374 data.append(try cp1252Data(string)) 375 data.append(0) 376 } 377 if !circledCells.isEmpty { 378 var payload = Data(repeating: 0, count: cellCount) 379 for index in circledCells where index >= 0 && index < cellCount { 380 payload[index] = 0x80 381 } 382 appendExtension("GEXT", payload: payload, into: &data) 383 } 384 // Rebus grid (`GRBS`) + table (`RTBL`) follow the real .puz convention: 385 // `rebusGrid` maps a cell to its 0-based `RTBL` key, and the grid byte is 386 // stored 1-indexed (`key + 1`), reserving 0 for "no rebus". 387 if !rebusGrid.isEmpty { 388 var payload = Data(repeating: 0, count: cellCount) 389 for (index, key) in rebusGrid where index >= 0 && index < cellCount { 390 payload[index] = UInt8(key + 1) 391 } 392 appendExtension("GRBS", payload: payload, into: &data) 393 394 let table = rebusTable 395 .sorted { $0.key < $1.key } 396 .map { " \($0.key):\($0.value);" } 397 .joined() 398 appendExtension("RTBL", payload: try cp1252Data(table), into: &data) 399 } 400 return data 401 } 402 403 private func appendExtension(_ code: String, payload: Data, into data: inout Data) { 404 data.append(contentsOf: code.utf8) 405 data.append(UInt8(payload.count & 0xFF)) 406 data.append(UInt8((payload.count >> 8) & 0xFF)) 407 data.append(0) 408 data.append(0) 409 data.append(payload) 410 data.append(0) 411 } 412 413 private func cp1252Data(_ string: String) throws -> Data { 414 try #require(string.data(using: .windowsCP1252)) 415 } 416 417 private func header(_ name: String, in xd: String) -> String? { 418 let prefix = "\(name): " 419 return xd.split(separator: "\n") 420 .first { $0.hasPrefix(prefix) } 421 .map { String($0.dropFirst(prefix.count)) } 422 } 423 }