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 }