The Background tab fills in — facets rendered to order, eight hues in a carousel

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-07 16:08:29 -04:00
parent 56e37be158
commit fb96e30df0
19 changed files with 3031 additions and 38 deletions
@@ -0,0 +1,68 @@
import SwiftUI
import Testing
@testable import Kanban
/// **`BoardBackgroundFilters`** the Background tab's generated picker: the default-tone rule and
/// the pure filters `FacetsRecipe` assembly, pinned the way the popover's other pure seams are
/// (`BoardInfoMetrics`, `BoardDiskFootprint` in `BoardInfoTabTests`). Everything else about the
/// carousel the strip's layout, the placeholder chip, the apply gesture is SwiftUI and
/// deliberately untested; `FacetsGeneratorTests` and `GeneratedBackgroundTests` already cover the
/// generator and the write path this feeds.
@Suite("Board popover ▸ Background tab filters")
struct BoardBackgroundFiltersTests {
@Test("Dark system appearance opens on Tone Dark, light opens on Tone Light")
func defaultToneFollowsTheSystem() {
#expect(BoardBackgroundFilters.defaultTone(colorScheme: .dark) == .dark)
#expect(BoardBackgroundFilters.defaultTone(colorScheme: .light) == .light)
}
@Test("The opening state is mono, medium, mid — only tone varies with the system")
func initialStateIsTheReviewedDefaults() {
let light = BoardBackgroundFilters.initial(colorScheme: .light)
#expect(light.tone == .light)
#expect(light.colors == .mono)
#expect(light.mesh == .medium)
#expect(light.saturation == .mid)
let dark = BoardBackgroundFilters.initial(colorScheme: .dark)
#expect(dark.tone == .dark)
#expect(dark.colors == .mono)
#expect(dark.mesh == .medium)
#expect(dark.saturation == .mid)
}
@Test("A hue and a seed assemble the exact recipe the filters describe")
func recipeAssemblesEveryAxis() {
let filters = BoardBackgroundFilters(tone: .dark, colors: .trio, mesh: .fine, saturation: .rich)
let recipe = filters.recipe(hue: .iris, seed: 0x5EED)
#expect(recipe.hue == .iris)
#expect(recipe.strategy == .trio)
#expect(recipe.density == .fine)
#expect(recipe.tone == .dark)
#expect(recipe.saturation == .rich)
#expect(recipe.seed == 0x5EED)
}
@Test("Two hues under the same filters and seed differ only in hue")
func onlyHueChangesAcrossTheWheel() {
let filters = BoardBackgroundFilters.initial(colorScheme: .light)
let sky = filters.recipe(hue: .sky, seed: 42)
let rose = filters.recipe(hue: .rose, seed: 42)
#expect(sky.hue == .sky)
#expect(rose.hue == .rose)
#expect(sky.strategy == rose.strategy)
#expect(sky.density == rose.density)
#expect(sky.tone == rose.tone)
#expect(sky.saturation == rose.saturation)
#expect(sky.seed == rose.seed)
}
@Test("The same filters and seed recipe identically — the preview/apply agreement the carousel depends on")
func sameInputsRecipeIdentically() {
let filters = BoardBackgroundFilters(tone: .light, colors: .duo, mesh: .coarse, saturation: .soft)
#expect(filters.recipe(hue: .forest, seed: 7) == filters.recipe(hue: .forest, seed: 7))
}
}
+138
View File
@@ -173,6 +173,144 @@ struct BackgroundWriteTests {
}
}
// MARK: - Writing the image subkey
/// `FrontmatterDocument.setBackgroundImage` the colour write's sibling, one subkey over
/// (BackgroundField.swift), and what `BoardStore.applyGeneratedBackground` points at the PNG it just
/// wrote. Every rule the colour write obeys, this one obeys too: that is the whole reason they share
/// a merge.
@Suite("Board background ▸ the image subkey")
struct BackgroundImageWriteTests {
/// The mirror of `setKeepsTheImage`: the app now writes both subkeys, and neither may take the
/// other with it.
@Test("Setting an image replaces the subkey and keeps the colour")
func setKeepsTheColour() throws {
var document = try document("background: {color: fern, image: sunset.jpg}")
document.setBackgroundImage("facets.png")
#expect(document.background == .valid("fern"))
#expect(document.backgroundImage == .valid("facets.png"))
#expect(backgroundLine(document) == "background: {color: \"fern\", image: \"facets.png\"}")
}
/// A colour-only board every board styled from the wells is the one the generator is most
/// likely to be pointed at.
@Test("Setting an image on a colour-only background adds the subkey")
func setAddsTheSubkeyToAColourOnlyMapping() throws {
var document = try document("background: {color: fern}")
document.setBackgroundImage("facets.png")
#expect(backgroundLine(document) == "background: {color: \"fern\", image: \"facets.png\"}")
}
/// Unknown subkeys ride along here exactly as they do through a colour change the merge is
/// literally the same one.
@Test("Unknown subkeys survive an image change, in their own positions")
func setPreservesUnknownSubkeys() throws {
var document = try document("background: {blend: multiply, image: old.png, opacity: 0.5}")
document.setBackgroundImage("facets.png")
#expect(backgroundLine(document)
== "background: {blend: \"multiply\", image: \"facets.png\", opacity: 0.5}")
}
/// The undo of a first generation: the image subkey goes and the colour the board had or did
/// not have is left to the colour write.
@Test("Removing the image drops that subkey alone")
func removeDropsOnlyTheImage() throws {
var document = try document("background: {color: fern, image: facets.png}")
document.setBackgroundImage(nil)
#expect(document.background == .valid("fern"))
#expect(document.backgroundImage == .missing)
#expect(backgroundLine(document) == "background: {color: \"fern\"}")
}
/// `background: {}` is a key that says nothing the removal's contract is that the field is
/// gone, whichever subkey emptied it.
@Test("A mapping emptied by the removal takes the key with it")
func removeDropsAnEmptiedKey() throws {
var document = try document("background: {image: facets.png}")
document.setBackgroundImage(nil)
#expect(!document.contains(FrontmatterKeys.background))
#expect(backgroundLine(document) == nil)
}
/// The malformed-value-cleared posture, on this subkey: a shape the schema cannot read has no
/// subkeys to preserve and is replaced by the mapping the app writes.
@Test("An image written onto an absent or unreadable key lands as a mapping")
func alwaysWritesTheMapping() throws {
var absent = try document("schema: 1")
absent.setBackgroundImage("facets.png")
#expect(backgroundLine(absent) == "background: {image: \"facets.png\"}")
var scalar = try document("background: fern")
scalar.setBackgroundImage("facets.png")
#expect(backgroundLine(scalar) == "background: {image: \"facets.png\"}")
var sequence = try document("background: [a, b]")
sequence.setBackgroundImage("facets.png")
#expect(backgroundLine(sequence) == "background: {image: \"facets.png\"}")
}
/// Both subkeys written in one edit, which is the shape every generated background lands in
/// and the order the schema spells it in, colour first.
@Test("A colour and an image written together land as one mapping")
func bothSubkeysTogether() throws {
var document = try document("schema: 1")
document.setStyleValue("#E0E5EB", for: FrontmatterKeys.background)
document.setBackgroundImage("facets.png")
#expect(backgroundLine(document) == "background: {color: \"#E0E5EB\", image: \"facets.png\"}")
#expect(document.background == .valid("#E0E5EB"))
#expect(document.backgroundImage == .valid("facets.png"))
}
/// Everything outside the key survives byte for byte, including the comment on the unknown key
/// beside it the verbatim promise, which yields on the one key being rewritten and nowhere else.
@Test("Nothing but the background line moves")
func leavesEverythingElseAlone() throws {
var document = try FrontmatterDocument.parse("""
---
schema: 1
title: Work
project: lanework # agent overlay
background: {color: fern}
icon: tray
---
Board description.
""")
document.setBackgroundImage("facets.png")
#expect(document.serialized() == """
---
schema: 1
title: Work
project: lanework # agent overlay
background: {color: "fern", image: "facets.png"}
icon: tray
---
Board description.
""")
}
/// The emitted mapping is read back by the reader the app uses a name with YAML-significant
/// characters in it included, which is the reason strings are always quoted in flow context.
@Test("An awkward file name round-trips through the emitted mapping")
func awkwardNamesRoundTrip() throws {
var document = try document("background: {color: fern}")
document.setBackgroundImage("a, b}.png")
let reparsed = try FrontmatterDocument.parse(document.serialized())
#expect(reparsed.backgroundImage == .valid("a, b}.png"))
#expect(reparsed.background == .valid("fern"))
}
}
// MARK: - Where an image may point
@Suite("Board background ▸ the image path stays inside the board")
+468
View File
@@ -0,0 +1,468 @@
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)
}
}
+386
View File
@@ -0,0 +1,386 @@
import Foundation
import Testing
@testable import Kanban
/// The write half of generated board backgrounds: `BoardWriter.writeBoardImage` and
/// `BoardStore.applyGeneratedBackground` (03-board-ui.md § Styling Capabilities;
/// DESIGN/explorations/board-backgrounds.md).
///
/// Like every other write suite here these drive a real writer or a real store over a real temp
/// board and read the **bytes on disk** back rather than the app's own read path: the claims are
/// about the file which name the picture landed under, what the frontmatter says afterwards, and
/// what an undo leaves behind. `WriterFixture`, `Ident` and `Item` come from
/// `WriterTestSupport.swift`.
// MARK: - Fixtures
/// A board root carrying whatever background the test needs, plus the unowned baggage every write
/// has to leave alone.
private func boardIndex(background: String? = nil) -> String {
let line = background.map { "background: \($0)\n" } ?? ""
return """
---
schema: 1
title: Work
\(line)project: lanework # agent overlay
created: 2026-01-01T09:00:00Z
---
Board description.
"""
}
@MainActor
private func makeBoard(background: String? = nil) throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", boardIndex(background: background))
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
return fixture
}
@MainActor
private func makeStore(_ fixture: WriterFixture) throws -> (store: BoardStore, history: NativeHistoryProvider) {
let store = try BoardStore(rootURL: fixture.root)
let history = NativeHistoryProvider()
store.history = history
return (store, history)
}
@MainActor
private func reload(_ store: BoardStore) async {
store.handleWatcherEvent(.treeChanged(.appMediated))
await store.awaitQuiescence()
}
private func document(_ fixture: WriterFixture) throws -> FrontmatterDocument {
try FrontmatterDocument.parse(fixture.indexText(""))
}
/// Bytes that are not an image and do not need to be: nothing in the write path decodes them, which
/// is itself worth pinning the Writer moves a payload, it does not validate artwork.
private let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01, 0x02, 0x03])
private let otherPNG = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x09, 0x09])
// MARK: - The writer primitive
@MainActor
@Suite("BoardWriter ▸ writeBoardImage")
struct WriteBoardImageTests {
@Test("The bytes land under the given name, and the name comes back")
func writesTheBytes() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let name = try BoardWriter.writeBoardImage(
data: png, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground
)
#expect(name == "facets.png")
#expect(try fixture.data("facets.png") == png)
}
/// The caller decides the name, so the writer's own contract is simply that the same name is
/// replaced rather than laddered one board, one generated picture.
@Test("A second write to the same name replaces it in place")
func overwritesInPlace() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try BoardWriter.writeBoardImage(
data: png, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground
)
try BoardWriter.writeBoardImage(
data: otherPNG, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground
)
#expect(try fixture.data("facets.png") == otherPNG)
#expect(try fixture.entryNames("") == [Ident.lane1, "facets.png", "index.md"])
}
/// **Atomic, and no residue**: the temp file is hidden and in the same folder, so a listing that
/// sees hidden entries is what proves the rename left nothing behind.
@Test("No temp file survives the write")
func leavesNoResidue() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try BoardWriter.writeBoardImage(
data: png, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground
)
#expect(try !fixture.entryNames("").contains { $0.hasPrefix(".") })
}
/// The mirror of the read side's containment rule (`BoardBackdrop.imageURL(named:inBoardRoot:)`):
/// a background that could be written outside the board folder is not a background.
@Test("A path, an empty name and the dot names are refused", arguments: ["", "art/x.png", "../x.png", ".", ".."])
func refusesAnythingThatIsNotABareName(name: String) throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
#expect(throws: BoardWriteError.self) {
try BoardWriter.writeBoardImage(
data: png, named: name, inRoot: fixture.root, operation: .setBoardBackground
)
}
#expect(try fixture.entryNames("") == [Ident.lane1, "index.md"])
}
/// The receipt: without one the churn the write produces classifies as somebody else's, and the
/// auto-committer would name the commit for a foreign edit.
@Test("The write leaves a content receipt in the ledger")
func dropsAReceipt() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let ledger = EchoLedger()
EchoLedger.$current.withValue(ledger) {
try? BoardWriter.writeBoardImage(
data: png, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground
)
}
let receipts = ledger.outstandingEntries()
#expect(receipts.contains { $0.key.hasSuffix("/facets.png") })
}
}
// MARK: - The gesture
@MainActor
@Suite("BoardStore ▸ applyGeneratedBackground")
struct GeneratedBackgroundWriteTests {
@Test("The picture lands in the folder and both subkeys point at it")
func writesTheFileAndTheFields() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, _) = try makeStore(fixture)
#expect(store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB"))
#expect(try fixture.data("facets.png") == png)
let after = try document(fixture)
#expect(after.background == .valid("#E0E5EB"))
#expect(after.backgroundImage == .valid("facets.png"))
#expect(try fixture.indexText("").contains("background: {color: \"#E0E5EB\", image: \"facets.png\"}"))
#expect(store.banners.oneShots.isEmpty)
}
/// One gesture, one bracket the style batch's rule, which is what makes one reroll one
/// app-mediated reload and one commit on a git board, though it writes two files.
@Test("Two files, one bracket")
func oneBracketForBothFiles() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, _) = try makeStore(fixture)
var begins = 0
var ends = 0
store.watcherBrackets = (begin: { begins += 1 }, end: { ends += 1 })
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
#expect(begins == 1)
#expect(ends == 1)
}
/// A colour the wells wrote is replaced, and everything the app does not own comes through
/// untouched the unknown key with its comment, `created`, the body.
@Test("An existing colour is replaced and the rest of the file survives")
func replacesAnExistingColour() throws {
let fixture = try makeBoard(background: "{color: fern}")
defer { fixture.tearDown() }
let (store, _) = try makeStore(fixture)
store.applyGeneratedBackground(png: png, colorHex: "#513D1A")
let text = try fixture.indexText("")
#expect(text.contains("background: {color: \"#513D1A\", image: \"facets.png\"}"))
#expect(text.contains("project: lanework # agent overlay"))
#expect(text.contains("created: 2026-01-01T09:00:00Z"))
#expect(text.contains("Board description."))
}
/// **Regenerating overwrites**: the whole reason the name is fixed rather than minted. The reload
/// between the two rolls is the ordinary case the snapshot has caught up and names the file.
@Test("A second generation replaces the same file")
func secondGenerationOverwrites() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, _) = try makeStore(fixture)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
await reload(store)
store.applyGeneratedBackground(png: otherPNG, colorHex: "#513D1A")
#expect(try fixture.data("facets.png") == otherPNG)
#expect(try document(fixture).backgroundImage == .valid("facets.png"))
// No ladder: the reload in between also seeds this board's `.gitignore`, so the listing is
// filtered to the pictures rather than compared whole.
#expect(try fixture.entryNames("").filter { $0.hasSuffix(".png") } == ["facets.png"])
}
/// **The reroll's echo**: rolling again before the watcher has rounded the first write back must
/// not ladder onto `facets 2.png`, because a fast reroll is the expected gesture and a folder of
/// abandoned pictures is what the fixed name exists to prevent.
@Test("A reroll before the reload lands still overwrites")
func rerollBeforeTheReloadOverwrites() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, _) = try makeStore(fixture)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
store.applyGeneratedBackground(png: otherPNG, colorHex: "#513D1A")
#expect(try fixture.data("facets.png") == otherPNG)
#expect(try fixture.entryNames("") == [Ident.lane1, "facets.png", "index.md"])
}
/// **Somebody else's `facets.png` is never written through** a file the user put in the board
/// folder is theirs, and the Finder ladder is how the app steps aside from a name it does not own.
@Test("A foreign file on the name pushes the generation to 'facets 2.png'")
func stepsAsideFromAForeignFile() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let mine = Data("not the app's".utf8)
try fixture.file("facets.png", mine)
let (store, _) = try makeStore(fixture)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
#expect(try fixture.data("facets.png") == mine, "the user's file is untouched")
#expect(try fixture.data("facets 2.png") == png)
#expect(try document(fixture).backgroundImage == .valid("facets 2.png"))
}
/// The same ladder when the board already names a *different* image: the hand-written path is
/// the escape hatch and stays on disk, and the generation lands beside it.
@Test("A board naming another image keeps it and generates alongside")
func keepsAHandWrittenImage() throws {
let fixture = try makeBoard(background: "{image: sunset.jpg}")
defer { fixture.tearDown() }
try fixture.file("sunset.jpg", Data("photo".utf8))
try fixture.file("facets.png", Data("someone else's".utf8))
let (store, _) = try makeStore(fixture)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
#expect(try fixture.data("sunset.jpg") == Data("photo".utf8))
#expect(try document(fixture).backgroundImage == .valid("facets 2.png"))
}
/// A board whose generated file was deleted in Finder is a board with a broken backdrop, and
/// regenerating is exactly the repair so the name is reused rather than laddered.
@Test("A missing file under our own name is rewritten, not laddered")
func rewritesAMissingFile() throws {
let fixture = try makeBoard(background: "{color: fern, image: facets.png}")
defer { fixture.tearDown() }
let (store, _) = try makeStore(fixture)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
#expect(try fixture.data("facets.png") == png)
#expect(try document(fixture).backgroundImage == .valid("facets.png"))
}
/// A locked board writes nothing at all not the picture, not the fields.
@Test("A read-only board refuses before anything is written")
func refusesUnderTheLock() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, _) = try makeStore(fixture)
store.enterVanishedRootLock()
#expect(store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") == false)
#expect(!fixture.exists("facets.png"))
#expect(try document(fixture).backgroundImage == .missing)
}
}
// MARK: - Undo
@MainActor
@Suite("Undo ▸ generated background")
struct GeneratedBackgroundUndoTests {
/// The first generation's undo is a clean return: the board had no background, and afterwards it
/// has none again. (The PNG stays in the folder nothing in the app deletes the user's files
/// and nothing points at it.)
@Test("Undo removes both subkeys and redo puts them back")
func roundTrip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
#expect(history.undoActionName == "Restyle Board")
history.undo()
let undone = try document(fixture)
#expect(undone.background == .missing)
#expect(undone.backgroundImage == .missing)
#expect(!undone.contains(FrontmatterKeys.background))
history.redo()
let redone = try document(fixture)
#expect(redone.background == .valid("#E0E5EB"))
#expect(redone.backgroundImage == .valid("facets.png"))
}
/// A prior colour comes back as itself rather than as an absence the same reading `applyStyle`'s
/// inverse has.
@Test("A prior colour and image are restored, not removed")
func priorValuesComeBack() throws {
let fixture = try makeBoard(background: "{color: fern, image: sunset.jpg}")
defer { fixture.tearDown() }
try fixture.file("sunset.jpg", Data("photo".utf8))
let (store, history) = try makeStore(fixture)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
history.undo()
let undone = try document(fixture)
#expect(undone.background == .valid("fern"))
#expect(undone.backgroundImage == .valid("sunset.jpg"))
}
/// **The undo restores fields, never bytes** stated as a test so the limit is visible rather
/// than folklore: regenerating over the app's own output leaves the second picture on disk, and
/// Z points the (unchanged) name back at it.
@Test("Undo does not bring the overwritten pixels back")
func undoDoesNotRestoreBytes() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
await reload(store)
store.applyGeneratedBackground(png: otherPNG, colorHex: "#513D1A")
history.undo()
#expect(try document(fixture).background == .valid("#E0E5EB"))
#expect(try document(fixture).backgroundImage == .valid("facets.png"))
#expect(try fixture.data("facets.png") == otherPNG, "the first generation's bytes are gone")
}
/// A foreign edit to the field the step wrote stales it the field-level predicate, applied to
/// the subkey this gesture owns.
@Test("A foreign edit to the image subkey skips the undo")
func foreignEditStalesTheStep() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
var foreign = try document(fixture)
foreign.setBackgroundImage("elsewhere.png")
try fixture.item("", foreign.serialized())
history.undo()
#expect(try document(fixture).backgroundImage == .valid("elsewhere.png"))
#expect(store.banners.signposts.isEmpty == false, "the skip says so on the strip")
}
}