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 }