crossmate

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

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 }