crossmate

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

DecorationRenderingTests.swift (6517B)


      1 import CoreGraphics
      2 import Foundation
      3 import ImageIO
      4 import SwiftUI
      5 import Testing
      6 import UniformTypeIdentifiers
      7 
      8 @testable import Crossmate
      9 
     10 @Suite("Decoration rendering")
     11 @MainActor
     12 struct DecorationRenderingTests {
     13     /// A real 8×8 PNG, base64-encoded, so the decode path is exercised against
     14     /// actual image bytes rather than a magic literal.
     15     private static func samplePNGBase64() throws -> String {
     16         let context = try #require(CGContext(
     17             data: nil, width: 8, height: 8, bitsPerComponent: 8, bytesPerRow: 0,
     18             space: CGColorSpaceCreateDeviceRGB(),
     19             bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
     20         ))
     21         context.setFillColor(CGColor(red: 1, green: 0, blue: 0, alpha: 1))
     22         context.fill(CGRect(x: 0, y: 0, width: 8, height: 8))
     23         let image = try #require(context.makeImage())
     24         let data = NSMutableData()
     25         let destination = try #require(CGImageDestinationCreateWithData(
     26             data, UTType.png.identifier as CFString, 1, nil
     27         ))
     28         CGImageDestinationAddImage(destination, image, nil)
     29         #expect(CGImageDestinationFinalize(destination))
     30         return (data as Data).base64EncodedString()
     31     }
     32 
     33     private static func dataDecoration(
     34         mimeType: String = "image/png",
     35         encoding: String = "base64",
     36         payload: String
     37     ) -> Puzzle.Decoration {
     38         Puzzle.Decoration(
     39             content: .data(mimeType: mimeType, encoding: encoding, payload: payload),
     40             phase: .after
     41         )
     42     }
     43 
     44     // MARK: - Phase gating
     45 
     46     @Test("before layers are always visible; after layers only once solved")
     47     func phaseGating() {
     48         let before = Puzzle.Decoration(content: .mark(.circled), phase: .before)
     49         let after = Puzzle.Decoration(content: .text("!"), phase: .after)
     50 
     51         #expect(before.isVisible(solved: false))
     52         #expect(before.isVisible(solved: true))
     53         #expect(!after.isVisible(solved: false))
     54         #expect(after.isVisible(solved: true))
     55     }
     56 
     57     // MARK: - Colour scheme
     58 
     59     @Test("A colour with a dark variant selects on the current appearance")
     60     func darkVariantSelectsOnAppearance() {
     61         let red = Color(.sRGB, red: 1, green: 0, blue: 0, opacity: 1)
     62         let blue = Color(.sRGB, red: 0, green: 0, blue: 1, opacity: 1)
     63 
     64         #expect(Puzzle.Decoration.Content.color(
     65             light: "#FF0000", dark: "#0000FF", for: .light
     66         ) == red)
     67         #expect(Puzzle.Decoration.Content.color(
     68             light: "#FF0000", dark: "#0000FF", for: .dark
     69         ) == blue)
     70     }
     71 
     72     @Test("A colour with no dark variant is used in both appearances")
     73     func singleColorAppliesToBothAppearances() {
     74         let red = Color(.sRGB, red: 1, green: 0, blue: 0, opacity: 1)
     75         #expect(Puzzle.Decoration.Content.color(light: "#FF0000", dark: nil, for: .light) == red)
     76         #expect(Puzzle.Decoration.Content.color(light: "#FF0000", dark: nil, for: .dark) == red)
     77     }
     78 
     79     // MARK: - Hex colours
     80 
     81     @Test("Hex colours parse in their three accepted lengths")
     82     func hexColorsParse() {
     83         let red = Color(.sRGB, red: 1, green: 0, blue: 0, opacity: 1)
     84         #expect(Puzzle.Decoration.Content.color(fromHex: "#FF0000") == red)
     85         #expect(Puzzle.Decoration.Content.color(fromHex: "#F00") == red)
     86         #expect(Puzzle.Decoration.Content.color(fromHex: "FF0000") == red)
     87         #expect(Puzzle.Decoration.Content.color(fromHex: "#ff0000") == red)
     88 
     89         let halfRed = Color(.sRGB, red: 1, green: 0, blue: 0, opacity: 0.5)
     90         let parsed = Puzzle.Decoration.Content.color(fromHex: "#FF000080")
     91         #expect(parsed != nil)
     92         // 0x80/255 is 0.502, so compare against the same construction rather
     93         // than a rounded literal.
     94         #expect(parsed == Color(.sRGB, red: 1, green: 0, blue: 0, opacity: Double(0x80) / 255))
     95         #expect(parsed != halfRed)
     96     }
     97 
     98     @Test("An unparseable hex colour is skipped rather than guessed at")
     99     func unparseableHexColorsAreNil() {
    100         #expect(Puzzle.Decoration.Content.color(fromHex: "") == nil)
    101         #expect(Puzzle.Decoration.Content.color(fromHex: "#") == nil)
    102         #expect(Puzzle.Decoration.Content.color(fromHex: "#GGGGGG") == nil)
    103         #expect(Puzzle.Decoration.Content.color(fromHex: "#FF00") == nil)
    104         #expect(Puzzle.Decoration.Content.color(fromHex: "rebeccapurple") == nil)
    105     }
    106 
    107     // MARK: - Image decoding
    108 
    109     @Test("A base64 PNG payload decodes to a drawable image")
    110     func base64PNGDecodes() throws {
    111         let payload = try Self.samplePNGBase64()
    112         let images = DecorationImages.decode([
    113             GridPosition(row: 0, col: 0): [Self.dataDecoration(payload: payload)]
    114         ])
    115         #expect(images.count == 1)
    116         #expect(images[payload] != nil)
    117     }
    118 
    119     @Test("Identical payloads on different cells decode once")
    120     func identicalPayloadsDecodeOnce() throws {
    121         let payload = try Self.samplePNGBase64()
    122         let images = DecorationImages.decode([
    123             GridPosition(row: 0, col: 0): [Self.dataDecoration(payload: payload)],
    124             GridPosition(row: 3, col: 4): [Self.dataDecoration(payload: payload)]
    125         ])
    126         #expect(images.count == 1)
    127     }
    128 
    129     @Test("Unsupported encodings and mime types are left undrawn")
    130     func unsupportedPayloadsAreSkipped() throws {
    131         let payload = try Self.samplePNGBase64()
    132         let images = DecorationImages.decode([
    133             GridPosition(row: 0, col: 0): [
    134                 Self.dataDecoration(encoding: "hex", payload: payload),
    135                 Self.dataDecoration(mimeType: "text/plain", payload: payload)
    136             ]
    137         ])
    138         #expect(images.isEmpty)
    139     }
    140 
    141     @Test("A payload that isn't valid base64 or isn't an image is skipped")
    142     func malformedPayloadsAreSkipped() {
    143         let images = DecorationImages.decode([
    144             GridPosition(row: 0, col: 0): [
    145                 Self.dataDecoration(payload: "not base64!!"),
    146                 Self.dataDecoration(payload: Data("not a png".utf8).base64EncodedString())
    147             ]
    148         ])
    149         #expect(images.isEmpty)
    150     }
    151 
    152     @Test("Non-data decorations contribute no images")
    153     func nonDataDecorationsContributeNoImages() {
    154         let images = DecorationImages.decode([
    155             GridPosition(row: 0, col: 0): [
    156                 Puzzle.Decoration(content: .mark(.shaded), phase: .before),
    157                 Puzzle.Decoration(content: .text("x"), phase: .after)
    158             ]
    159         ])
    160         #expect(images.isEmpty)
    161     }
    162 }