crossmate

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

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 }