NYTOverlaySlicerTests.swift (13856B)
1 import CoreGraphics 2 import Foundation 3 import ImageIO 4 import Testing 5 import UniformTypeIdentifiers 6 7 @testable import Crossmate 8 9 @Suite("NYT overlay slicer") 10 @MainActor 11 struct NYTOverlaySlicerTests { 12 /// A board SVG in the shape the NYT actually serves: a `viewBox` plus a 13 /// first cell rect that gives the margin and the cell pitch. 14 private static func boardSVG(border: Int, cell: Int, cols: Int) -> String { 15 let side = border * 2 + cell * cols 16 return """ 17 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 \(side) \(side)">\ 18 <g class="cells"><g data-index="0">\ 19 <path d="M\(border) \(border)h\(cell)v\(cell)H\(border)z" fill="none" class="cell"/>\ 20 </g></g></svg> 21 """ 22 } 23 24 /// Draws an overlay-shaped image: fully transparent except for an opaque 25 /// square filling each of `inked`. 26 private static func overlayImage( 27 border: Int, 28 cell: Int, 29 cols: Int, 30 rows: Int, 31 scale: Int, 32 inked: [GridPosition], 33 inset: CGFloat = 4, 34 color: CGColor = CGColor(red: 1, green: 1, blue: 1, alpha: 1), 35 centerMarkColor: CGColor? = nil 36 ) throws -> Data { 37 let width = (border * 2 + cell * cols) * scale 38 let height = (border * 2 + cell * rows) * scale 39 let context = try #require(CGContext( 40 data: nil, 41 width: width, 42 height: height, 43 bitsPerComponent: 8, 44 bytesPerRow: 0, 45 space: CGColorSpaceCreateDeviceRGB(), 46 bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue 47 )) 48 context.clear(CGRect(x: 0, y: 0, width: width, height: height)) 49 context.setFillColor(color) 50 for position in inked { 51 // `CGContext` puts its origin at the bottom left, so row 0 has to be 52 // flipped to the top of the image — otherwise the fixture is a 53 // vertical mirror of the grid it claims to describe. 54 // 55 // The default inset keeps a cell's ink from bleeding into its 56 // neighbour and making the test pass for the wrong reason. 57 let top = (border + position.row * cell) * scale 58 let rect = CGRect( 59 x: CGFloat((border + position.col * cell) * scale) + inset, 60 y: CGFloat(height - top - cell * scale) + inset, 61 width: CGFloat(cell * scale) - inset * 2, 62 height: CGFloat(cell * scale) - inset * 2 63 ) 64 context.fill(rect) 65 if let centerMarkColor { 66 context.setFillColor(centerMarkColor) 67 context.fill(rect.insetBy(dx: rect.width / 3, dy: rect.height / 3)) 68 context.setFillColor(color) 69 } 70 } 71 let image = try #require(context.makeImage()) 72 let data = NSMutableData() 73 let destination = try #require(CGImageDestinationCreateWithData( 74 data, UTType.png.identifier as CFString, 1, nil 75 )) 76 CGImageDestinationAddImage(destination, image, nil) 77 #expect(CGImageDestinationFinalize(destination)) 78 return data as Data 79 } 80 81 @Test("Converter preserves both overlay phases in paint order") 82 func converterPreservesBothOverlayPhases() throws { 83 let board = Self.boardSVG(border: 1, cell: 10, cols: 1) 84 let image = try Self.overlayImage( 85 border: 1, 86 cell: 10, 87 cols: 1, 88 rows: 1, 89 scale: 2, 90 inked: [GridPosition(row: 0, col: 0)] 91 ) 92 let root: [String: Any] = [ 93 "publicationDate": "2025-01-01", 94 "constructors": ["Tester"], 95 "body": [[ 96 "dimensions": ["width": 1, "height": 1], 97 "cells": [["answer": "A"]], 98 "clues": [], 99 "board": board 100 ]] 101 ] 102 let json = try JSONSerialization.data(withJSONObject: root) 103 104 let xd = try NYTToXDConverter.convert( 105 jsonData: json, 106 beforeStartImage: image, 107 afterSolveImage: image 108 ) 109 let definitions = xd.split(separator: "\n").filter { 110 $0.contains("data=image/png;base64,") 111 } 112 try #require(definitions.count == 2) 113 #expect(!definitions[0].hasSuffix(" after")) 114 #expect(definitions[1].hasSuffix(" after")) 115 } 116 117 @Test("Converter replaces a uniform translucent tile with a background colour") 118 func converterReplacesUniformTileWithBackground() throws { 119 let board = Self.boardSVG(border: 1, cell: 10, cols: 1) 120 let image = try Self.overlayImage( 121 border: 1, 122 cell: 10, 123 cols: 1, 124 rows: 1, 125 scale: 2, 126 inked: [GridPosition(row: 0, col: 0)], 127 inset: 0, 128 color: CGColor(red: 0, green: 0, blue: 0, alpha: 0.5) 129 ) 130 let root: [String: Any] = [ 131 "publicationDate": "2025-01-01", 132 "constructors": ["Tester"], 133 "body": [[ 134 "dimensions": ["width": 1, "height": 1], 135 "cells": [["answer": "A"]], 136 "clues": [], 137 "board": board 138 ]] 139 ] 140 let json = try JSONSerialization.data(withJSONObject: root) 141 142 let xd = try NYTToXDConverter.convert(jsonData: json, beforeStartImage: image) 143 #expect(xd.contains("bg=#00000080")) 144 #expect(!xd.contains("data=image/png;base64,")) 145 } 146 147 @Test("Converter preserves a foreground mark over a uniform tile") 148 func converterPreservesMarkedUniformTileAsData() throws { 149 let board = Self.boardSVG(border: 1, cell: 10, cols: 1) 150 let image = try Self.overlayImage( 151 border: 1, 152 cell: 10, 153 cols: 1, 154 rows: 1, 155 scale: 2, 156 inked: [GridPosition(row: 0, col: 0)], 157 inset: 0, 158 color: CGColor(red: 0, green: 0, blue: 0, alpha: 0.2), 159 centerMarkColor: CGColor(red: 0, green: 0, blue: 0, alpha: 1) 160 ) 161 let root: [String: Any] = [ 162 "publicationDate": "2025-01-01", 163 "constructors": ["Tester"], 164 "body": [[ 165 "dimensions": ["width": 1, "height": 1], 166 "cells": [["answer": "A"]], 167 "clues": [], 168 "board": board 169 ]] 170 ] 171 let json = try JSONSerialization.data(withJSONObject: root) 172 173 let xd = try NYTToXDConverter.convert(jsonData: json, beforeStartImage: image) 174 #expect(!xd.contains("bg=#")) 175 #expect(xd.contains("data=image/png;base64,")) 176 } 177 178 // MARK: - Geometry 179 180 @Test("Geometry is read from the board SVG, not assumed") 181 func geometryIsReadFromBoardSVG() throws { 182 // A 15×15 ships 33-unit cells in a 501 viewBox… 183 let fifteen = try #require(NYTOverlaySlicer.geometry( 184 boardSVG: Self.boardSVG(border: 3, cell: 33, cols: 15) 185 )) 186 #expect(fifteen.border == 3) 187 #expect(fifteen.cellSize == 33) 188 #expect(fifteen.viewBoxWidth == 501) 189 190 // …while a 21×21 ships 23-unit cells in a 489 viewBox. Assuming either 191 // shape misaligns every tile on the other. 192 let twentyOne = try #require(NYTOverlaySlicer.geometry( 193 boardSVG: Self.boardSVG(border: 3, cell: 23, cols: 21) 194 )) 195 #expect(twentyOne.border == 3) 196 #expect(twentyOne.cellSize == 23) 197 #expect(twentyOne.viewBoxWidth == 489) 198 } 199 200 @Test("Geometry is nil when the board SVG is unusable") 201 func geometryIsNilForUnusableSVG() { 202 #expect(NYTOverlaySlicer.geometry(boardSVG: "") == nil) 203 #expect(NYTOverlaySlicer.geometry(boardSVG: "<svg viewBox=\"0 0 501 501\"></svg>") == nil) 204 #expect(NYTOverlaySlicer.geometry(boardSVG: "<path d=\"M3 3h33v33H3z\"/>") == nil) 205 } 206 207 // MARK: - Slicing 208 209 @Test("Only cells carrying ink become tiles") 210 func onlyInkedCellsBecomeTiles() throws { 211 let inked = [GridPosition(row: 0, col: 1), GridPosition(row: 2, col: 2)] 212 let image = try Self.overlayImage( 213 border: 3, cell: 33, cols: 3, rows: 3, scale: 4, inked: inked 214 ) 215 let geometry = try #require(NYTOverlaySlicer.geometry( 216 boardSVG: Self.boardSVG(border: 3, cell: 33, cols: 3) 217 )) 218 219 let tiles = try #require(NYTOverlaySlicer.tiles( 220 imageData: image, geometry: geometry, width: 3, height: 3 221 )) 222 #expect(Set(tiles.keys) == Set(inked)) 223 } 224 225 @Test("A fully transparent overlay yields no tiles") 226 func transparentOverlayYieldsNoTiles() throws { 227 let image = try Self.overlayImage( 228 border: 3, cell: 33, cols: 3, rows: 3, scale: 4, inked: [] 229 ) 230 let geometry = try #require(NYTOverlaySlicer.geometry( 231 boardSVG: Self.boardSVG(border: 3, cell: 33, cols: 3) 232 )) 233 234 let tiles = try #require(NYTOverlaySlicer.tiles( 235 imageData: image, geometry: geometry, width: 3, height: 3 236 )) 237 #expect(tiles.isEmpty) 238 } 239 240 @Test("Tiles are emitted as PNGs downscaled to the stored size") 241 func tilesArePNGsAtStoredSize() throws { 242 let image = try Self.overlayImage( 243 border: 3, cell: 33, cols: 3, rows: 3, scale: 8, inked: [GridPosition(row: 1, col: 1)] 244 ) 245 let geometry = try #require(NYTOverlaySlicer.geometry( 246 boardSVG: Self.boardSVG(border: 3, cell: 33, cols: 3) 247 )) 248 let tiles = try #require(NYTOverlaySlicer.tiles( 249 imageData: image, geometry: geometry, width: 3, height: 3 250 )) 251 let png = try #require(tiles[GridPosition(row: 1, col: 1)]) 252 253 // A 33-unit cell at scale 8 is 264px, comfortably above the stored size. 254 let source = try #require(CGImageSourceCreateWithData(png as CFData, nil)) 255 #expect(CGImageSourceGetType(source) as String? == UTType.png.identifier) 256 let decoded = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) 257 #expect(decoded.width == NYTOverlaySlicer.tileSize) 258 #expect(decoded.height == NYTOverlaySlicer.tileSize) 259 } 260 261 @Test("Geometry that doesn't fit the image is rejected") 262 func geometryNotFittingImageIsRejected() throws { 263 // A 3×3 image told it is a 15×15 grid: the derived cells run off the 264 // end, so every tile would be cut from the wrong place. 265 let image = try Self.overlayImage( 266 border: 3, cell: 33, cols: 3, rows: 3, scale: 4, inked: [GridPosition(row: 0, col: 0)] 267 ) 268 let geometry = try #require(NYTOverlaySlicer.geometry( 269 boardSVG: Self.boardSVG(border: 3, cell: 33, cols: 3) 270 )) 271 #expect(NYTOverlaySlicer.tiles( 272 imageData: image, geometry: geometry, width: 15, height: 15 273 ) == nil) 274 } 275 276 @Test("Unreadable image data yields nil") 277 func unreadableImageYieldsNil() throws { 278 let geometry = try #require(NYTOverlaySlicer.geometry( 279 boardSVG: Self.boardSVG(border: 3, cell: 33, cols: 3) 280 )) 281 #expect(NYTOverlaySlicer.tiles( 282 imageData: Data("not an image".utf8), geometry: geometry, width: 3, height: 3 283 ) == nil) 284 } 285 286 @Test("An overlay past the payload budget is dropped whole") 287 func oversizedOverlayIsDroppedWhole() throws { 288 // Nine tiles of incompressible noise clear the 96 KB budget several 289 // times over, so a 3×3 grid is enough — no need to build a huge one. 290 let image = try Self.noiseOverlay(border: 3, cell: 23, cols: 3, scale: 4) 291 let geometry = try #require(NYTOverlaySlicer.geometry( 292 boardSVG: Self.boardSVG(border: 3, cell: 23, cols: 3) 293 )) 294 #expect(NYTOverlaySlicer.tiles( 295 imageData: image, geometry: geometry, width: 3, height: 3 296 ) == nil) 297 } 298 299 /// A fully-inked overlay of random pixels. 300 /// 301 /// Noise is deliberate: flat colour compresses to almost nothing, so it 302 /// would never reach the payload budget this exercises. The buffer is 303 /// filled directly rather than through thousands of `CGContext.fill` calls 304 /// — this suite runs in parallel with timing-sensitive tests elsewhere, and 305 /// a fixture that saturates a core for seconds can starve their deadlines. 306 private static func noiseOverlay( 307 border: Int, 308 cell: Int, 309 cols: Int, 310 scale: Int 311 ) throws -> Data { 312 let side = (border * 2 + cell * cols) * scale 313 var bytes = [UInt8](repeating: 255, count: side * side * 4) 314 var generator = SystemRandomNumberGenerator() 315 for index in stride(from: 0, to: bytes.count, by: 4) { 316 bytes[index] = UInt8.random(in: 0...255, using: &generator) 317 bytes[index + 1] = UInt8.random(in: 0...255, using: &generator) 318 bytes[index + 2] = UInt8.random(in: 0...255, using: &generator) 319 // bytes[index + 3] stays 255: every cell must count as inked. 320 } 321 322 let image: CGImage = try bytes.withUnsafeMutableBytes { raw in 323 let context = try #require(CGContext( 324 data: raw.baseAddress, 325 width: side, 326 height: side, 327 bitsPerComponent: 8, 328 bytesPerRow: side * 4, 329 space: CGColorSpaceCreateDeviceRGB(), 330 bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue 331 )) 332 return try #require(context.makeImage()) 333 } 334 335 let data = NSMutableData() 336 let destination = try #require(CGImageDestinationCreateWithData( 337 data, UTType.png.identifier as CFString, 1, nil 338 )) 339 CGImageDestinationAddImage(destination, image, nil) 340 #expect(CGImageDestinationFinalize(destination)) 341 return data as Data 342 } 343 }