Files
lanework/KanbanTests/CustomColorRoundTripTests.swift
T
rzen 4210bbe0a7 The combo's trigger retires — each picker is one rectangle again, taller, and Style becomes Appearance in the sidebar
Both the symbol picker and the background color control give up the two-zone combo chrome from the 2026-08-09 rework: no more face/trigger split, no more trailing chevron square. Each is now a single bordered rectangle with one hit zone, 2.1em tall and 1.5x that wide (a 4:6 ratio, 50% taller than the retired chrome's 18pt). A click anywhere opens the same curated popover the trigger used to gate.

The background rectangle's popover is new: a None-plus-sixteen palette grid mirroring the Style… popover's own wells, with an Other… row onto the shared, pre-debounced Colors panel session — replacing the old NSMenu dropdown outright. The symbol rectangle keeps its existing popover (search, curated grid, tint colors, More Symbols…) verbatim; only its entry point collapsed to one zone.

ComboFieldControl/ComboFieldMetrics (ComboField.swift) are replaced by PickerRectControl/PickerRectMetrics (PickerRect.swift). ColorComboView and its NSMenu-building pure model are retired wholesale in favor of ColorSwatchPicker. SymbolComboControl becomes SymbolGlyphControl. PaletteSwatch.rectImage, the last caller of which was the retired dropdown's menu rows, goes with it.

In the card window sidebar, the "Style" section header becomes "Appearance" (sidebar only — the board context menu's Style… item and StyleEditorView's own naming are untouched), and the symbol and background controls move from stacked rows to side-by-side columns, each captioned above rather than leading. CardStyleSection no longer carries its own debounce Task for background panel picks — the shared Colors-panel session now delivers an already-settled value.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 20:44:47 -04:00

228 lines
11 KiB
Swift

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
/// (`Palette.name(forHex:in:)`'s case-insensitive compare, `SystemColorPanel.changeColor(_:)`'s
/// own rule) 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")
}
}
}