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) } }