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:
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user