crossmate

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

PUZToXDConverterTests.swift (6041B)


      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 explicit special mask")
    113     func circledCellsEmitExplicitSpecialMask() 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: @=circle"))
    134         #expect(source.contains("\n@BC\nD@F\nGHI\n"))
    135 
    136         let puzzle = Puzzle(xd: try XD.parse(source))
    137         #expect(puzzle.cells[0][0].special == .circled)
    138         #expect(puzzle.cells[1][1].special == .circled)
    139         #expect(puzzle.cells[0][0].solution == "A")
    140         #expect(puzzle.cells[1][1].solution == "E")
    141     }
    142 
    143     private func puzData(
    144         width: UInt8,
    145         height: UInt8,
    146         solution: String,
    147         title: String,
    148         author: String,
    149         copyright: String,
    150         clues: [String],
    151         notes: String = "",
    152         circledCells: Set<Int> = []
    153     ) throws -> Data {
    154         let cellCount = Int(width) * Int(height)
    155         let solutionBytes = Array(solution.utf8)
    156         #expect(solutionBytes.count == cellCount)
    157 
    158         var data = Data(repeating: 0, count: 0x34)
    159         for (offset, byte) in "ACROSS&DOWN".utf8.enumerated() {
    160             data[0x02 + offset] = byte
    161         }
    162         data[0x18] = UInt8(ascii: "1")
    163         data[0x19] = UInt8(ascii: ".")
    164         data[0x1A] = UInt8(ascii: "3")
    165         data[0x2C] = width
    166         data[0x2D] = height
    167         data[0x2E] = UInt8(clues.count & 0xFF)
    168         data[0x2F] = UInt8((clues.count >> 8) & 0xFF)
    169 
    170         data.append(contentsOf: solutionBytes)
    171         data.append(contentsOf: Array(repeating: UInt8(ascii: "-"), count: cellCount))
    172 
    173         for string in [title, author, copyright] + clues + [notes] {
    174             data.append(try cp1252Data(string))
    175             data.append(0)
    176         }
    177         if !circledCells.isEmpty {
    178             var payload = Data(repeating: 0, count: cellCount)
    179             for index in circledCells where index >= 0 && index < cellCount {
    180                 payload[index] = 0x80
    181             }
    182             data.append(contentsOf: "GEXT".utf8)
    183             data.append(UInt8(cellCount & 0xFF))
    184             data.append(UInt8((cellCount >> 8) & 0xFF))
    185             data.append(0)
    186             data.append(0)
    187             data.append(contentsOf: payload)
    188             data.append(0)
    189         }
    190         return data
    191     }
    192 
    193     private func cp1252Data(_ string: String) throws -> Data {
    194         try #require(string.data(using: .windowsCP1252))
    195     }
    196 }