Files
lanework/KanbanTests/FacetsGeneratorTests.swift
T

469 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CoreGraphics
import Foundation
import ImageIO
import Testing
@testable import Kanban
/// `FacetsGenerator` — the Swift port of the reviewed faceted gallery
/// (DESIGN/explorations/board-backgrounds.md ▸ Faceted gallery; `board-backgrounds-faceted.html`).
///
/// What is worth pinning here is not "the picture looks nice", which no test can say, but the four
/// properties the feature is built on:
///
/// - **A seed names a composition** — so the picker's preview and the file written from it are the
/// same picture.
/// - **The triangulation is a triangulation** — it tiles its points' convex hull exactly and every
/// face is Delaunay, checked against the hull rather than against a remembered number.
/// - **The mesh is full bleed** — proved twice over, once as arithmetic on the density constants
/// (margin ≥ 0.92 × cell, which holds for every seed at once) and once on the meshes themselves
/// (boundary points clear the frame, the frame's corners are covered). This is the property the
/// reviewed gallery did *not* have; see `FacetsRecipe.Density`.
/// - **The colour model is HSL** — the reason the swatches look like the ones that were reviewed.
// MARK: - Fixtures
private func recipe(
hue: FacetsRecipe.Hue = .sky,
strategy: FacetsRecipe.Strategy = .duo,
density: FacetsRecipe.Density = .medium,
tone: FacetsRecipe.Tone = .light,
saturation: FacetsRecipe.Saturation = .mid,
seed: UInt64 = 0x5EED
) -> FacetsRecipe {
FacetsRecipe(
hue: hue, strategy: strategy, density: density,
tone: tone, saturation: saturation, seed: seed
)
}
/// The convex hull, by monotone chain — the region a Delaunay triangulation of these points must
/// cover exactly, which is the sharpest thing that can be asked of the port.
private func convexHull(_ points: [CGPoint]) -> [CGPoint] {
let sorted = points.sorted { $0.x == $1.x ? $0.y < $1.y : $0.x < $1.x }
guard sorted.count >= 3 else { return sorted }
func cross(_ o: CGPoint, _ a: CGPoint, _ b: CGPoint) -> Double {
Double(a.x - o.x) * Double(b.y - o.y) - Double(a.y - o.y) * Double(b.x - o.x)
}
func chain(_ points: [CGPoint]) -> [CGPoint] {
var hull: [CGPoint] = []
for point in points {
while hull.count >= 2, cross(hull[hull.count - 2], hull[hull.count - 1], point) <= 0 {
hull.removeLast()
}
hull.append(point)
}
return hull
}
// Each half drops its own last point, which is the other half's first.
return Array(chain(sorted).dropLast()) + Array(chain(sorted.reversed()).dropLast())
}
/// The gallery's own cell dimensions: its 1.15 × 0.775 region (552 × 372 at the 480-wide scale, in
/// unit terms) divided by its 5×3, 9×6 and 14×9 grids.
private let reviewedCells: [(density: FacetsRecipe.Density, width: Double, height: Double)] = [
(.coarse, 1.15 / 5, 0.775 / 3),
(.medium, 1.15 / 9, 0.775 / 6),
(.fine, 1.15 / 14, 0.775 / 9),
]
/// Whether `point` is inside `face`, edges included — the three edge cross-products agreeing in
/// sign. The tolerance admits a point exactly on an edge, which every frame corner shared by two
/// faces is.
private func contains(_ face: FacetsGenerator.Face, _ point: CGPoint) -> Bool {
func side(_ a: CGPoint, _ b: CGPoint) -> Double {
Double(b.x - a.x) * Double(point.y - a.y) - Double(b.y - a.y) * Double(point.x - a.x)
}
let first = side(face.a, face.b)
let second = side(face.b, face.c)
let third = side(face.c, face.a)
let epsilon = 1e-12
return (first >= -epsilon && second >= -epsilon && third >= -epsilon)
|| (first <= epsilon && second <= epsilon && third <= epsilon)
}
/// A simple polygon's area, by the shoelace formula.
private func polygonArea(_ polygon: [CGPoint]) -> Double {
guard polygon.count >= 3 else { return 0 }
var total = 0.0
for index in polygon.indices {
let a = polygon[index]
let b = polygon[(index + 1) % polygon.count]
total += Double(a.x) * Double(b.y) - Double(b.x) * Double(a.y)
}
return abs(total) / 2
}
/// The image a PNG payload decodes to — the only way to ask what was actually encoded rather than
/// what was handed to the encoder.
private func decoded(_ data: Data) -> CGImage? {
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }
return CGImageSourceCreateImageAtIndex(source, 0, nil)
}
// MARK: - A seed names a composition
@MainActor
@Suite("Facets ▸ determinism")
struct FacetsDeterminismTests {
/// The whole reason the generator is pure: the picker's preview, the file written to the board
/// folder, and a re-render on another Mac next year are one picture.
@Test("The same recipe and seed render identical bytes")
func sameSeedSameBytes() throws {
let first = try #require(FacetsGenerator.pngData(recipe: recipe(), pixelWidth: 96))
let second = try #require(FacetsGenerator.pngData(recipe: recipe(), pixelWidth: 96))
#expect(first == second)
}
/// Reroll's whole job.
@Test("A different seed renders different bytes")
func differentSeedDiffers() throws {
let first = try #require(FacetsGenerator.pngData(recipe: recipe(seed: 1), pixelWidth: 96))
let second = try #require(FacetsGenerator.pngData(recipe: recipe(seed: 2), pixelWidth: 96))
#expect(first != second)
}
/// **Normalized space, doing its job**: the composition is the same mesh at any output size, so
/// the geometry a small preview shows is the geometry the 3072 px file has.
@Test("Size changes the pixels, never the mesh")
func sizeDoesNotChangeTheMesh() {
let mesh = FacetsGenerator.facets(recipe: recipe())
let again = FacetsGenerator.facets(recipe: recipe())
#expect(mesh == again)
let small = FacetsGenerator.render(recipe: recipe(), pixelWidth: 64)
let large = FacetsGenerator.render(recipe: recipe(), pixelWidth: 640)
#expect(small?.width == 64)
#expect(large?.width == 640)
}
/// Every axis is part of the identity — a picker that changed one of them and got the same
/// picture back would be a picker with a dead control.
@Test("Each axis changes the picture")
func everyAxisMatters() {
let base = FacetsGenerator.facets(recipe: recipe())
#expect(FacetsGenerator.facets(recipe: recipe(hue: .rose)) != base)
#expect(FacetsGenerator.facets(recipe: recipe(strategy: .trio)) != base)
#expect(FacetsGenerator.facets(recipe: recipe(density: .fine)) != base)
#expect(FacetsGenerator.facets(recipe: recipe(tone: .dark)) != base)
#expect(FacetsGenerator.facets(recipe: recipe(saturation: .rich)) != base)
}
}
// MARK: - The mesh
@MainActor
@Suite("Facets ▸ the mesh")
struct FacetsMeshTests {
/// **The triangulation tiles its points' convex hull exactly** — no hole, no overlap, nothing
/// left over. Total face area against the hull's own area is the whole claim in one number, and
/// it is the claim that matters here: a hole is a patch of flat ground colour in the middle of
/// the picture, which is exactly the artefact the small super-triangle in the gallery's own
/// generator produces and this port's larger one does not (`FacetsGenerator.triangulate`).
///
/// Checked across every density and a dozen seeds rather than one, because a triangulator's
/// failures are input-shaped.
///
/// The tolerance is what a *hull* can honestly promise: when a point lands essentially on the
/// line between its two neighbours, the sliver between them has no circumcircle to speak of and
/// is not made. Swept over 600 meshes the largest such gap is 5.5 × 10⁻⁵ of a unit square; the
/// smallest hole a *missing face* could leave is a fraction of a cell, and the smallest cell in
/// the table is fine's at 7.2 × 10⁻³. The threshold sits between the two.
@Test("The mesh tiles the hull exactly", arguments: [FacetsRecipe.Density.coarse, .medium, .fine])
func meshTilesTheHull(density: FacetsRecipe.Density) {
for seed in UInt64(1)...12 {
var random = FacetsRandom(seed: seed)
let points = FacetsGenerator.scatter(density, using: &random)
#expect(points.count == density.columns * density.rows)
let hull = polygonArea(convexHull(points))
let tiled = FacetsGenerator.triangulate(points).reduce(0.0) { total, triangle in
total + FacetsGenerator.Face(
a: points[triangle.a], b: points[triangle.b], c: points[triangle.c],
color: FacetsColor(hue: 0, saturation: 0, lightness: 0)
).area
}
#expect(abs(tiled - hull) < 3e-4, "density \(density), seed \(seed): \(tiled) vs hull \(hull)")
}
}
/// **The Delaunay property itself**: no point sits inside another triangle's circumcircle. A
/// tiling alone could be any triangulation — this is the one the recipe names, and the one whose
/// fat triangles make the mesh read as facets rather than as splinters.
///
/// The tolerance is relative and tiny; it exists because four points can be *nearly* cocircular,
/// not because the predicate is soft.
@Test("Every face is Delaunay", arguments: [FacetsRecipe.Density.coarse, .medium, .fine])
func facesAreDelaunay(density: FacetsRecipe.Density) {
for seed in UInt64(1)...12 {
var random = FacetsRandom(seed: seed)
let points = FacetsGenerator.scatter(density, using: &random)
for triangle in FacetsGenerator.triangulate(points) {
guard let circle = FacetsGenerator.circumcircle(
points[triangle.a], points[triangle.b], points[triangle.c]
) else {
Issue.record("a face with no circumcircle survived")
continue
}
for (index, point) in points.enumerated()
where index != triangle.a && index != triangle.b && index != triangle.c {
let dx = Double(point.x) - circle.x
let dy = Double(point.y) - circle.y
#expect(dx * dx + dy * dy >= circle.radiusSquared * (1 - 1e-9),
"density \(density), seed \(seed): point \(index) is inside a face's circumcircle")
}
}
}
}
/// **The full-bleed inequality** — the one line the whole boundary ring exists to satisfy
/// (`FacetsRecipe.Density.scatterMargin`).
///
/// A boundary cell's point is placed anywhere in the middle 84% of it, so the worst draw pushes
/// it 0.92 of a cell *inward* from the outer edge. `margin ≥ 0.92 × cell` on both axes is
/// therefore exactly "no draw can put a boundary point inside the picture", which is what makes
/// the frame interior to the hull for **every** seed rather than for most of them.
///
/// It is arithmetic on constants, so it holds for all seeds at once — the sharpest form the
/// claim has, and the one that would catch a future density added with a margin copied from its
/// neighbour.
@Test("Every density's margin outruns its worst jitter draw", arguments: FacetsRecipe.Density.allCases)
func marginOutrunsTheJitter(density: FacetsRecipe.Density) {
let cellWidth = (1 + 2 * density.scatterMargin) / Double(density.columns)
let cellHeight = (FacetsGenerator.frameHeight + 2 * density.scatterMargin) / Double(density.rows)
#expect(density.scatterMargin >= FacetsGenerator.jitterReach * cellWidth,
"\(density): margin \(density.scatterMargin) < \(FacetsGenerator.jitterReach * cellWidth)")
#expect(density.scatterMargin >= FacetsGenerator.jitterReach * cellHeight,
"\(density): margin \(density.scatterMargin) < \(FacetsGenerator.jitterReach * cellHeight)")
}
/// **The reviewed facet size, preserved** — the number a viewer actually reads as "coarse" or
/// "fine" (`FacetsRecipe.Density`).
///
/// The grid grew when the boundary ring went in (5×3 → 7×5, 9×6 → 10×7, 14×9 → 15×10), and this
/// is the guard that says it grew *outward*: the cell is still the gallery's 1.15/5, 1.15/9 and
/// 1.15/14 in unit terms, so the same number of facets falls inside the picture as did in the
/// swatches that were reviewed. Stretching the cells to reach the edges instead would have kept
/// the point counts and changed every density's character.
@Test("The cell size is the gallery's", arguments: reviewedCells)
func cellSizeMatchesTheGallery(density: FacetsRecipe.Density, width: Double, height: Double) {
let cellWidth = (1 + 2 * density.scatterMargin) / Double(density.columns)
let cellHeight = (FacetsGenerator.frameHeight + 2 * density.scatterMargin) / Double(density.rows)
// 5%, which is what round margins cost: medium and fine land within half a percent on both
// axes, and coarse's cell comes out 4% shorter — its ring is 1.3 cells deep, so squaring the
// grid up moved the height and left the width exactly where it was.
#expect(abs(cellWidth - width) / width < 0.05, "\(density) width \(cellWidth) vs \(width)")
#expect(abs(cellHeight - height) / height < 0.05, "\(density) height \(cellHeight) vs \(height)")
}
/// The facet counts that follow from those cells: how many faces land **inside the picture**,
/// which is the number the gallery's "≈20 · ≈97 · ≈230" was describing. The ring's own faces are
/// cropped away and are not part of what anyone judged.
@Test("The visible facet count matches the reviewed density")
func visibleDensityMatchesTheGallery() {
func visible(_ density: FacetsRecipe.Density, seed: UInt64) -> Int {
FacetsGenerator.facets(recipe: recipe(density: density, seed: seed)).faces.count { face in
let x = Double(face.a.x + face.b.x + face.c.x) / 3
let y = Double(face.a.y + face.b.y + face.c.y) / 3
return x >= 0 && x <= 1 && y >= 0 && y <= FacetsGenerator.frameHeight
}
}
for seed in UInt64(1)...12 {
#expect((14...30).contains(visible(.coarse, seed: seed)), "coarse: \(visible(.coarse, seed: seed))")
#expect((62...84).contains(visible(.medium, seed: seed)), "medium: \(visible(.medium, seed: seed))")
#expect((160...185).contains(visible(.fine, seed: seed)), "fine: \(visible(.fine, seed: seed))")
}
}
/// **Full bleed, checked on the points** — the inequality above, arrived at from the other end.
///
/// Every point in the first and last column sits at or beyond the left and right frame edges,
/// and every point in the first and last row at or beyond the top and bottom. That is what makes
/// the picture interior to the convex hull: each of its four sides has a wall of points past it.
@Test("Every boundary point lands outside the frame on its own side",
arguments: FacetsRecipe.Density.allCases)
func boundaryPointsClearTheFrame(density: FacetsRecipe.Density) {
let rows = density.rows
for seed in UInt64(1)...12 {
var random = FacetsRandom(seed: seed)
let points = FacetsGenerator.scatter(density, using: &random)
for (index, point) in points.enumerated() {
// Column-major: index = column × rows + row (see `FacetsGenerator.scatter`).
let column = index / rows
let row = index % rows
if column == 0 { #expect(Double(point.x) <= 0, "\(density)/\(seed): left \(point.x)") }
if column == density.columns - 1 {
#expect(Double(point.x) >= 1, "\(density)/\(seed): right \(point.x)")
}
if row == 0 { #expect(Double(point.y) <= 0, "\(density)/\(seed): top \(point.y)") }
if row == rows - 1 {
#expect(Double(point.y) >= FacetsGenerator.frameHeight,
"\(density)/\(seed): bottom \(point.y)")
}
}
}
}
/// **And full bleed, checked on the pixels that matter**: the four frame corners are each inside
/// some face. A corner is where a triangulation's coverage fails first, and a corner showing flat
/// ground colour is the artefact this whole ring was built to remove.
///
/// The total-area check rides along. Over 200 seeds a density the *worst* mesh still covers
/// 2.15× the frame at coarse and 1.49× at medium and fine, so the arithmetic is never close —
/// which is the point of a ring sized against the jitter rather than against a taste for how
/// much overhang looks like enough.
@Test("The frame's corners are covered", arguments: FacetsRecipe.Density.allCases)
func frameCornersAreCovered(density: FacetsRecipe.Density) {
let height = FacetsGenerator.frameHeight
let corners = [
CGPoint(x: 0, y: 0), CGPoint(x: 1, y: 0),
CGPoint(x: 1, y: height), CGPoint(x: 0, y: height),
]
for seed in UInt64(1)...12 {
let mesh = FacetsGenerator.facets(recipe: recipe(density: density, seed: seed))
#expect(mesh.faces.reduce(0) { $0 + $1.area } >= height, "\(density)/\(seed): total area")
for corner in corners {
#expect(mesh.faces.contains { contains($0, corner) },
"density \(density), seed \(seed): corner \(corner) shows ground colour")
}
}
}
/// No face may be degenerate: a zero-area triangle is a circumcircle the guard should have
/// refused, and a stroked sliver is a visible scratch across the picture.
@Test("No face is degenerate")
func facesHaveArea() {
for seed in UInt64(1)...12 {
let mesh = FacetsGenerator.facets(recipe: recipe(density: .fine, seed: seed))
#expect(mesh.faces.allSatisfy { $0.area > 0 })
}
}
/// Points land inside their own cell's middle band, which is what keeps neighbours from
/// coinciding — and inside the outset region, which everything above rests on.
@Test("The scatter stays inside the outset region", arguments: FacetsRecipe.Density.allCases)
func scatterStaysInTheRegion(density: FacetsRecipe.Density) {
var random = FacetsRandom(seed: 7)
let points = FacetsGenerator.scatter(density, using: &random)
let margin = density.scatterMargin
#expect(points.allSatisfy { Double($0.x) >= -margin && Double($0.x) <= 1 + margin })
#expect(points.allSatisfy {
Double($0.y) >= -margin && Double($0.y) <= FacetsGenerator.frameHeight + margin
})
}
}
// MARK: - Colour
@MainActor
@Suite("Facets ▸ colour is HSL")
struct FacetsColorTests {
/// **Hand-computed CSS `hsl()`**, which is the whole claim: the gallery's swatches are `hsl()`
/// strings, so a port that reached for `NSColor`'s hue/saturation/**brightness** would render a
/// different set of colours from the ones that were reviewed.
@Test("primaryColorHex is the recipe's level as CSS reads it", arguments: [
(FacetsRecipe.Hue.sky, FacetsRecipe.Tone.light, FacetsRecipe.Saturation.soft, "#E0E5EB"),
(.amber, .dark, .rich, "#513D1A"),
(.forest, .light, .rich, "#BFF3D0"),
(.rose, .dark, .soft, "#32242A"),
(.clay, .light, .mid, "#EDD7D4"),
(.iris, .dark, .mid, "#2C2041"),
])
func primaryColorMatchesHSL(
hue: FacetsRecipe.Hue,
tone: FacetsRecipe.Tone,
saturation: FacetsRecipe.Saturation,
expected: String
) {
let recipe = recipe(hue: hue, tone: tone, saturation: saturation)
#expect(recipe.primaryColorHex == expected)
}
/// The ground the generator paints under the mesh is the recipe's own primary — the value the
/// board's `background.color` is set to, so the underlay and the picture agree.
@Test("The ground is the primary colour")
func groundIsThePrimary() {
let recipe = recipe(hue: .teal, tone: .light, saturation: .soft)
#expect(FacetsGenerator.facets(recipe: recipe).ground == recipe.primaryColor)
#expect(recipe.primaryColorHex == "#E0EBEA")
}
/// The strategies' weighted lists, as the gallery states them — mono one hue, duo the complement
/// at 65/35, trio the triad at 50/30/20 — each summing to 1, which is what the single-draw pick
/// assumes.
@Test("The hue lists are the reviewed ones")
func hueListsMatchTheGallery() {
#expect(recipe(hue: .sky, strategy: .mono).hues.map(\.degrees) == [215])
#expect(recipe(hue: .sky, strategy: .duo).hues.map(\.degrees) == [215, 395])
#expect(recipe(hue: .sky, strategy: .trio).hues.map(\.degrees) == [215, 335, 95])
for strategy in FacetsRecipe.Strategy.allCases {
let total = recipe(strategy: strategy).hues.reduce(0) { $0 + $1.weight }
#expect(abs(total - 1) < 1e-12, "\(strategy) weights must sum to 1")
}
}
/// Lightness stays in the narrow band the recipe names — "brightness stays a narrow per-triangle
/// jitter around the tone base", which is the property that makes a whole swatch read as one
/// surface. Hue and saturation jitter around theirs the same way.
@Test("Every face jitters inside its recipe's bands")
func facesStayInTheirBands() {
let recipe = recipe(hue: .forest, strategy: .mono, density: .fine, tone: .dark, saturation: .rich)
let level = recipe.level
for face in FacetsGenerator.facets(recipe: recipe).faces {
#expect(abs(face.color.lightness - level.lightness) <= FacetsRecipe.lightnessJitter)
#expect(face.color.saturation >= level.saturation * 0.85)
#expect(face.color.saturation <= level.saturation * 1.15)
// Mono: one hue, ±3° — wrapped, so 140 ± 3 stays comfortably away from the seam.
#expect(abs(face.color.hue - 140) <= 3)
}
}
/// The wrap that a triad needs: `H 120` is negative for every hue below 120°, and a colour at
/// 112° is a colour at 248°, not a colour at 0.
@Test("A negative triad hue wraps rather than clamping")
func negativeHuesWrap() {
#expect(FacetsColor(hue: -112, saturation: 50, lightness: 50).hue == 248)
#expect(FacetsColor(hue: 395, saturation: 50, lightness: 50).hue == 35)
// Saturation and lightness clamp instead — they are percentages, not angles.
#expect(FacetsColor(hue: 0, saturation: 140, lightness: -8).saturation == 100)
#expect(FacetsColor(hue: 0, saturation: 140, lightness: -8).lightness == 0)
}
}
// MARK: - The encoded file
@MainActor
@Suite("Facets ▸ the PNG")
struct FacetsPNGTests {
/// 16:10, rounded — the aspect the board window is judged at and the one every swatch was
/// reviewed in.
@Test("The payload decodes at the requested width and a 16:10 height", arguments: [64, 480, 1024])
func decodesAtTheRequestedSize(width: Int) throws {
let data = try #require(FacetsGenerator.pngData(recipe: recipe(), pixelWidth: width))
let image = try #require(decoded(data))
#expect(image.width == width)
#expect(image.height == FacetsGenerator.pixelHeight(forWidth: width))
#expect(image.height == Int((Double(width) * 10 / 16).rounded()))
}
/// It really is a PNG — the first eight bytes of the format's own signature — because the board
/// frontmatter is about to name this file and `BoardBackdrop.decode` will be asked to read it.
@Test("The payload is a PNG")
func payloadIsPNG() throws {
let data = try #require(FacetsGenerator.pngData(recipe: recipe(), pixelWidth: 64))
#expect(Array(data.prefix(8)) == [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
}
/// Opaque, and stated: the backdrop sits under lane plates and card faces, and an image carrying
/// alpha would let the window's own background through in a way no swatch was reviewed with.
@Test("The render is opaque")
func renderIsOpaque() throws {
let image = try #require(FacetsGenerator.render(recipe: recipe(), pixelWidth: 64))
#expect(image.alphaInfo == .noneSkipLast)
}
}