import AppKit import Foundation import Testing @testable import Kanban /// **An arbitrary colour picked from the system panel, all the way to disk and back** (2026-08-09, /// the card that put an **Other…** on the Style… popover and the symbol popover's tint row). /// /// The guarantee already held — `FrontmatterValue.emitScalar` quotes a `#` because YAML says it must, /// `BackgroundField.flowText` quotes unconditionally, `Palette.nsColor(for:)` takes the hex branch — /// but it held as an *emergent property of four files that do not mention each other*. Nothing /// asserted it end to end, and the one link that would break silently is the sharpest: `#` opens a /// YAML comment, so an unquoted `background: #2E1A51` parses as a key with **no value at all**. A /// colour would vanish on save and the file would still be valid YAML. /// /// These are the assertions that make the chain a contract rather than a coincidence. // MARK: - The codec @Suite("Custom colour ▸ the hex codec") struct CustomColorCodecTests { /// `NSColor → paletteHexString → NSColor` is the identity on any sRGB colour the panel can /// return, to within the 8-bit quantisation the format has. @Test("A panel colour survives the trip to hex and back") func opaqueColoursRoundTrip() throws { for (red, green, blue) in [ (0.0, 0.0, 0.0), (1.0, 1.0, 1.0), (0.18, 0.10, 0.32), (0.42, 0.71, 0.60), (1.0, 0.0, 0.5), ] { let picked = NSColor(srgbRed: red, green: green, blue: blue, alpha: 1) let hex = try #require(picked.paletteHexString, "no hex for \(red),\(green),\(blue)") let parsed = try #require(NSColor(paletteHex: hex)) let tolerance = 1.0 / 255 + 0.0001 #expect(abs(parsed.redComponent - red) < tolerance) #expect(abs(parsed.greenComponent - green) < tolerance) #expect(abs(parsed.blueComponent - blue) < tolerance) #expect(parsed.alphaComponent == 1) } } /// **Full opacity collapses to six digits.** A colour the user never touched the opacity slider /// on has to be written exactly as a curated palette entry would be, or it would never match one /// (`ColorComboModel.match` compares normalized strings) and a panel pick that landed dead on /// `fern` would store an anonymous hex instead of the name. @Test("Opacity is written only when there is some") func alphaCollapsesAtFullOpacity() throws { let opaque = NSColor(srgbRed: 0.5, green: 0.25, blue: 0.75, alpha: 1) #expect(opaque.paletteHexString == "#8040BF") let translucent = NSColor(srgbRed: 0.5, green: 0.25, blue: 0.75, alpha: 0.5) #expect(translucent.paletteHexString == "#8040BF80") #expect(try #require(NSColor(paletteHex: "#8040BF80")).alphaComponent == 128.0 / 255) } /// A panel colour landing exactly on a palette entry comes back as the **name**, in both /// tables — the rule `SystemColorPanel` applies before handing a value to a caller, and the /// reason picking `Light Cayenne` from the panel twice does not drift into `#B6071E`. @Test("A palette-exact pick resolves to the palette name, never the hex") func paletteExactPicksResolveToNames() throws { for entry in Palette.backgrounds { let picked = try #require(NSColor(paletteHex: entry.hex)) let hex = try #require(picked.paletteHexString) #expect(Palette.name(forHex: hex, in: Palette.backgrounds) == entry.name) } for entry in Palette.foregrounds { let picked = try #require(NSColor(paletteHex: entry.hex)) let hex = try #require(picked.paletteHexString) #expect(Palette.name(forHex: hex, in: Palette.foregrounds) == entry.name) } } /// A colour that is *not* a palette entry stays a hex — the other half of the same rule, which /// would otherwise be satisfied by a function that always returned a name. @Test("An off-palette pick stays a hex") func offPaletteStaysHex() throws { let picked = NSColor(srgbRed: 0.181, green: 0.102, blue: 0.318, alpha: 1) let hex = try #require(picked.paletteHexString) #expect(Palette.name(forHex: hex, in: Palette.backgrounds) == nil) #expect(Palette.name(forHex: hex, in: Palette.foregrounds) == nil) #expect(Palette.nsColor(for: hex) != nil, "a hex the pickers store must still resolve") } } // MARK: - The YAML boundary /// **The one place the chain could break silently**: `#` is YAML's comment introducer, so a hex /// emitted as a plain scalar is a key with no value. @Suite("Custom colour ▸ the YAML boundary") struct CustomColorEmissionTests { @Test("A hex is emitted quoted, because unquoted it would be a comment") func hexIsQuoted() { #expect(FrontmatterValue.string("#2E1A51").yamlText == "\"#2E1A51\"") #expect(FrontmatterValue.string("#2E1A5180").yamlText == "\"#2E1A5180\"") // The control: a palette name needs no quoting and does not get any. #expect(FrontmatterValue.string("smokey-ocean").yamlText == "smokey-ocean") } /// Proof that the quoting is *necessary*, not merely present — the emitter's round-trip probe is /// only as good as the claim it is testing, so state the claim. @Test("Unquoted, a hex really does parse as nothing") func unquotedHexParsesAsNothing() throws { let document = try FrontmatterDocument.parse("---\niconColor: #2E1A51\n---\nBody.\n") #expect(document.iconColor.value == nil, "an unquoted hex must not be readable as a colour") let quoted = try FrontmatterDocument.parse("---\niconColor: \"#2E1A51\"\n---\nBody.\n") #expect(quoted.iconColor.value == "#2E1A51") } } // MARK: - End to end, on disk @MainActor @Suite("Custom colour ▸ through the store to disk") struct CustomColorWriteTests { private func fixture() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", """ --- schema: 1 title: Board --- Board description. """) try fixture.item("11111111-1111-4111-8111-111111111111", """ --- schema: 1 title: Todo order: 1024 --- Lane body. """) return fixture } private let lane = ItemID(rawValue: "11111111-1111-4111-8111-111111111111") /// A hex chosen from the panel lands on disk **quoted**, and the app's own reader gets the same /// colour back out. The `background` half — a flow mapping, always double-quoted. @Test("An arbitrary background hex round-trips through a real write") func backgroundHexRoundTrips() throws { let fixture = try fixture() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let picked = NSColor(srgbRed: 0.181, green: 0.102, blue: 0.318, alpha: 1) let value = try #require(picked.paletteHexString) store.applyStyle(to: .items([lane]), background: .set(value), icon: .keep) let text = try fixture.indexText("11111111-1111-4111-8111-111111111111") #expect(text.contains("background: {color: \"\(value)\"}"), "written as: \(text)") let reloaded = try BoardLoader.load(boardRoot: fixture.root).model let written = try #require(reloaded.lanes.first { $0.id == lane }) #expect(written.background == .valid(value)) // To within the 8-bit quantisation `#RRGGBB` has — the format's own precision, not a // slackness in the round trip. let resolved = try #require(Palette.nsColor(for: value)) let tolerance = 1.0 / 255 + 0.0001 #expect(abs(resolved.redComponent - picked.redComponent) < tolerance) #expect(abs(resolved.greenComponent - picked.greenComponent) < tolerance) #expect(abs(resolved.blueComponent - picked.blueComponent) < tolerance) #expect(resolved.alphaComponent == 1) } /// The `iconColor` half — a plain scalar, so the quoting is `emitScalar`'s round-trip probe /// rather than an unconditional rule. Different code path, same guarantee. @Test("An arbitrary icon-tint hex round-trips through a real write") func iconColorHexRoundTrips() throws { let fixture = try fixture() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let picked = NSColor(srgbRed: 0.85, green: 0.32, blue: 0.74, alpha: 1) let value = try #require(picked.paletteHexString) store.applyStyle(to: .items([lane]), background: .keep, icon: .keep, iconColor: .set(value)) let text = try fixture.indexText("11111111-1111-4111-8111-111111111111") #expect(text.contains("iconColor: \"\(value)\""), "written as: \(text)") let reloaded = try BoardLoader.load(boardRoot: fixture.root).model let written = try #require(reloaded.lanes.first { $0.id == lane }) #expect(written.iconColor == .valid(value)) #expect(Palette.color(for: written.iconColor) != nil) } /// A translucent pick — the panel's opacity slider — keeps its alpha across the trip. Eight /// digits is a shape the schema names (`#RRGGBB[AA]`) and the one the emitter has to quote just /// as carefully. @Test("A translucent pick keeps its alpha on disk") func translucentHexRoundTrips() throws { let fixture = try fixture() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let picked = NSColor(srgbRed: 0.2, green: 0.4, blue: 0.6, alpha: 0.5) let value = try #require(picked.paletteHexString) #expect(value.count == 9, "a translucent colour must carry its alpha pair") store.applyStyle(to: .items([lane]), background: .set(value), icon: .keep) let reloaded = try BoardLoader.load(boardRoot: fixture.root).model let written = try #require(reloaded.lanes.first { $0.id == lane }) let stored = try #require(written.background.value) let colour = try #require(Palette.nsColor(for: stored)) #expect(abs(colour.alphaComponent - 0.5) < 1.0 / 255 + 0.0001) } /// Every one of the four new palette colours is a value the write path handles as a **name**, /// not as the hex it stands for — the additions joined the vocabulary, they did not become a /// special case. @Test("The 2026-08-09 palette additions write as names") func newPaletteNamesWriteAsNames() throws { let fixture = try fixture() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) for name in ["smokey-lime", "dark-jade", "smokey-indigo", "smokey-magenta"] { store.applyStyle(to: .items([lane]), background: .set(name), icon: .keep) let text = try fixture.indexText("11111111-1111-4111-8111-111111111111") #expect(text.contains("background: {color: \"\(name)\"}"), "written as: \(text)") let reloaded = try BoardLoader.load(boardRoot: fixture.root).model let written = try #require(reloaded.lanes.first { $0.id == lane }) #expect(Palette.color(for: written.background) != nil, "'\(name)' did not resolve after a round trip") } } }