The two pickers rhyme — one two-zone chrome, a face onto a standalone browser, a trigger onto the popover

The colour combo was a wide two-zone field with a second door onto the Colors
panel; the symbol picker was a small square button with one. Both now subclass
one `ComboFieldControl`, so they are the same width, height, radius and trigger
by construction: click the face for the standalone picker, click the chevron for
the quick list. The symbol face opens a new floating browser over the OS's own
category, ordering and keyword plists out of CoreGlyphs.bundle — searchable,
categorised, trademark-restricted glyphs withheld.

The palette grows twelve to sixteen per table, filling the hue ring's four
widest gaps with lime, jade, indigo and magenta at each table's own saturation
and brightness. That gives the Style… popover's background grid a third row and
the tint grid its third row of four, and both grids gain an Other… row onto the
system colour picker — which the card sidebar's combo has had all along and the
primary styling surface never did. An arbitrary hex already round-tripped; it is
asserted now, including that an unquoted one is a YAML comment and no value.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 09:44:30 -04:00
parent ca0328be2e
commit ece33bbf78
17 changed files with 2030 additions and 322 deletions
+3 -3
View File
@@ -98,7 +98,7 @@ struct ColorComboTests {
}
/// A name from the *other* picker's table `carnation` is foreground-only is not one of
/// `.background`'s twelve, so it falls to the dynamic row, titled with its own display name
/// `.background`'s own table, so it falls to the dynamic row, titled with its own display name
/// since the other table does know it.
@Test func aForeignPaletteNameFallsToTheDynamicRowNamedFromTheOtherTable() {
let match = ColorComboModel.match(role: .background, value: "carnation")
@@ -132,7 +132,7 @@ struct ColorComboTests {
struct Menu {
/// None first, a separator, then exactly the role's twelve, in the palette's own order.
/// None first, a separator, then exactly the role's own entries, in the palette's own order.
@Test func baseOrderIsNoneSeparatorThenTheRolesTwelve() {
let menu = ColorComboModel.menu(role: .background, value: nil)
var expected: [ColorComboItem] = [.none, .separator]
@@ -159,7 +159,7 @@ struct ColorComboTests {
}
/// No dynamic row, and no selected item beyond the palette rows, when the value is `nil` or
/// one of the role's own twelve.
/// one of the role's own entries.
@Test func noDynamicRowWhenTheValueIsNoneOrAPaletteName() {
let none = ColorComboModel.menu(role: .background, value: nil)
#expect(!none.items.contains { if case .current = $0 { return true }; return false })
+14 -10
View File
@@ -21,8 +21,8 @@ import Testing
/// paints (palette name and hand-written hex alike, one path), that a translucent one resolves
/// differently in the two appearances, and that a board painting nothing is left alone.
///
/// The palette's own suite closes the loop. 03-board-ui.md Styling Controls promises the twelve
/// wells are "AA-verified at design time pinned by a computed-contrast unit test over all 12
/// The palette's own suite closes the loop. 03-board-ui.md Styling Controls promises the palette
/// wells are "AA-verified at design time pinned by a computed-contrast unit test over all
/// pairs"; a pair is a background *and its ink*, so the promise is only keepable by checking the ink
/// the seam chooses which `PaletteContrastTests` does, in both appearances. That is the whole of
/// the difference between the two paths: the palette is a fixed set and can be checked in advance,
@@ -384,7 +384,7 @@ struct BoardTextInkTests {
#expect(BoardTextInk.paintedColor(.valid("#1e1e1e")) != nil)
#expect(BoardTextInk.paintedColor(.valid("smokey-ocean")) != nil)
#expect(BoardTextInk.paintedColor(.valid("chalk")) != nil)
// A foreground-table name in the `background` field resolves, because the 12+12 split is a
// A foreground-table name in the `background` field resolves, because the two-table split is a
// picker split and not a namespace (`Palette`) so it paints, so it decides an ink.
#expect(BoardTextInk.paintedColor(.valid("carnation")) != nil)
#expect(BoardTextInk.paintedColor(.valid("#12345")) == nil)
@@ -479,21 +479,25 @@ struct BoardTextInkTests {
struct PaletteContrastTests {
/// **03-board-ui.md Styling Controls' promise, computed and this test *is* the promise**:
/// "the background grid offers the 12 palette colors every pair AA-verified at design time
/// (10-accessibility.md), the claim pinned by a computed-contrast unit test over all 12 pairs so
/// "the background grid offers the palette colors every pair AA-verified at design time
/// (10-accessibility.md), the claim pinned by a computed-contrast unit test over all pairs so
/// palette drift can never silently break it."
///
/// **This is also the gate the palette grew through.** Four backgrounds were added on 2026-08-09;
/// the reason that was a safe thing to do is that a new well with no readable ink fails here
/// rather than shipping, which is exactly the first failure mode named below.
///
/// A **pair** is a background and the ink its text is drawn in, so the claim cannot be settled by
/// a table of colours alone only by the code that chooses the ink. What is asserted here is
/// therefore end to end and in both appearances: for every well, the scheme `BoardTextInk`
/// *selects* clears 4.5:1 on the colour that well paints. Nothing weaker would be the design's
/// claim; nothing stronger is true, since no single ink reads on all twelve.
/// claim; nothing stronger is true, since no single ink reads on all of them.
///
/// Two ways to fail, both of them the point. A **new well** whose colour has no readable ink at
/// all a mid-grey, the dead zone `InkChoiceTests.neitherPassingTakesTheHigherRatio` documents
/// fails here instead of shipping. And a regression in the *selection* fails here too: with the
/// appearance-native label, which is what the board drew before this card, every one of the
/// twelve failed in one appearance (ten dark wells under Aqua, `chalk` and `aluminum` under Dark
/// wells failed in one appearance (every dark well under Aqua, `chalk` and `aluminum` under Dark
/// Aqua), so this test would have caught the m4 bug it now guards against returning.
@Test("The ink the board picks clears 4.5:1 on every palette background, in both appearances")
func everyPaletteBackgroundHasAReadableInk() throws {
@@ -528,8 +532,8 @@ struct PaletteContrastTests {
/// The other half of "verified at design time": **the appearance-native label is not enough**,
/// which is why the selection has to happen at all.
///
/// Every one of the twelve wells is a colour the system's own label fails on in one of the two
/// appearances the ten dark ones under Aqua, `chalk` and `aluminum` under Dark Aqua. Stating
/// Every well is a colour the system's own label fails on in one of the two
/// appearances the dark ones under Aqua, `chalk` and `aluminum` under Dark Aqua. Stating
/// it as a test keeps the reasoning from decaying into folklore: if a future palette were tame
/// enough that the native label always worked, this would fail and the seam's board-side wiring
/// could be reconsidered rather than carried on faith.
@@ -548,7 +552,7 @@ struct PaletteContrastTests {
}
}
/// The twelve are opaque, which is why the design can verify them at all: a palette well with
/// Every well is opaque, which is why the design can verify them at all: a palette well with
/// alpha would make its own contrast a function of the appearance's window background, and
/// "verified at design time" would stop being a statement anyone could check.
@Test("No palette background carries alpha")
+226
View File
@@ -0,0 +1,226 @@
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")
}
}
}
+55 -5
View File
@@ -15,19 +15,69 @@ import Testing
struct PaletteTableTests {
@Test func bothTablesHoldTheTwelveNamedColoursTheDesignCarriesOver() {
/// The pathfinder's twelve, plus the four 2026-08-09 hue-gap fills, each **at its hue position**
/// rather than appended the order is the ring, and this list is what pins it.
@Test func bothTablesHoldTheSixteenNamedColoursInHueOrder() {
#expect(Palette.foregrounds.map(\.name) == [
"obsidian", "aluminum", "soapstone", "chalk",
"carnation", "rich-grapefruit", "smokey-tangerine", "fern",
"light-teal", "deep-sky-blue", "pale-violet", "deep-cool-granite",
"carnation", "rich-grapefruit", "smokey-tangerine", "rich-lime",
"fern", "light-jade", "light-teal", "deep-sky-blue",
"rich-indigo", "pale-violet", "rich-magenta", "deep-cool-granite",
])
#expect(Palette.backgrounds.map(\.name) == [
"obsidian", "shale", "aluminum", "chalk",
"light-cayenne", "light-mocha", "smokey-mocha", "smokey-fern",
"dark-teal", "smokey-ocean", "smokey-rich-eggplant", "intense-cool-shale",
"light-cayenne", "light-mocha", "smokey-mocha", "smokey-lime",
"smokey-fern", "dark-jade", "dark-teal", "smokey-ocean",
"smokey-indigo", "smokey-rich-eggplant", "smokey-magenta", "intense-cool-shale",
])
}
/// The two tables are **one structure** (`Palette`'s own doc comment): four neutrals plus a
/// twelve-stop ring each, paired stop for stop. A table grown on one side alone would break the
/// pairing silently every picker would still work, and the design would quietly stop being a
/// design.
@Test func theTwoTablesAreTheSameShape() {
#expect(Palette.foregrounds.count == 16)
#expect(Palette.backgrounds.count == 16)
#expect(Set(Palette.foregrounds.map(\.name)).count == 16, "a name is listed twice")
#expect(Set(Palette.backgrounds.map(\.name)).count == 16, "a name is listed twice")
// Four neutrals lead each table; the twelve after them are the ring.
#expect(Palette.foregrounds.dropFirst(4).count == 12)
#expect(Palette.backgrounds.dropFirst(4).count == 12)
}
/// Every hex is spelled the one way the app emits them uppercase `#RRGGBB` so a panel pick
/// that lands on a palette colour matches by string (`ColorComboModel.match`'s hex branch) and
/// comes back as the *name*. A lowercase entry would still resolve and would still round-trip;
/// it would just quietly stop being recognised as the palette colour it is.
@Test func everyHexIsSixUppercaseDigits() {
for entry in Palette.foregrounds + Palette.backgrounds {
#expect(entry.hex == entry.hex.uppercased(), "'\(entry.name)' is not uppercase")
#expect(entry.hex.count == 7 && entry.hex.hasPrefix("#"), "'\(entry.name)' is not #RRGGBB")
let digitsAreHex = entry.hex.dropFirst().allSatisfy { $0.isHexDigit }
#expect(digitsAreHex, "'\(entry.name)' has a non-hex digit")
}
}
/// The tint row's source: the icon palette minus the four greys and minus `deep-cool-granite`.
///
/// **Eleven is load-bearing**, not incidental `SymbolPickerLayout`'s colour grid is four wide
/// and its leading None takes the first cell, so eleven is exactly what fills three rows. The
/// palette grew to sixteen partly to make that true; this is the assertion that keeps the two
/// facts tied together.
@Test func theTintRingIsTheIconPaletteWithoutItsNeutrals() {
#expect(Palette.tints.map(\.name) == [
"carnation", "rich-grapefruit", "smokey-tangerine", "rich-lime",
"fern", "light-jade", "light-teal", "deep-sky-blue",
"rich-indigo", "pale-violet", "rich-magenta",
])
#expect(Palette.tints.count == SymbolPickerLayout.colorColumns * SymbolPickerLayout.colorRows - 1)
// Every tint is a foreground, and no grey slipped in.
for tint in Palette.tints {
#expect(Palette.foregrounds.contains { $0.name == tint.name && $0.hex == tint.hex })
}
}
@Test func everyPaletteNameResolvesToItsOwnHex() throws {
for entry in Palette.foregrounds + Palette.backgrounds {
let byName = try #require(
+312
View File
@@ -0,0 +1,312 @@
import AppKit
import Testing
@testable import Kanban
/// **`SymbolCatalog`'s pure seams** the OS category read behind `SymbolBrowserPanel` (2026-08-09).
/// The panel itself, its sidebar and its grid are deliberately untested, exactly as every other
/// SwiftUI surface in this app is; what is asserted here is the shape of the data it browses and the
/// search that narrows it.
///
/// The load reads real system plists, so a few of these are assertions about **this machine's**
/// SF Symbols inventory. That is the point rather than a compromise: the whole reason the categories
/// are read instead of hand-written is that the OS's answer is the true one, and a test that stubbed
/// it would only be checking the stub.
@Suite("SymbolCatalog ▸ the categories")
struct SymbolCatalogCategoryTests {
@Test("The system load yields many categories, each non-empty and uniquely keyed")
func categoriesLoad() {
let categories = SymbolCatalog.categories
#expect(categories.count > 10, "only \(categories.count) categories — the read has degraded to its fallback")
#expect(Set(categories.map(\.key)).count == categories.count, "a category key is listed twice")
for category in categories {
#expect(!category.symbols.isEmpty, "'\(category.key)' is empty")
#expect(!category.title.isEmpty, "'\(category.key)' has no title")
}
}
/// The five non-semantic categories are dropped see `SymbolCatalog`'s doc comment. `multicolor`
/// is the one that would hurt most if it came back: it is the largest category in the file and
/// classifies rendering, not subject.
@Test("Rendering-mode and vintage categories are not offered")
func nonSemanticCategoriesAreExcluded() {
let keys = Set(SymbolCatalog.categories.map(\.key))
for excluded in SymbolCatalog.excludedCategoryKeys {
#expect(!keys.contains(excluded), "'\(excluded)' should not be browsable")
}
}
/// Every offered category has a hand-written title the one curated constant in this file, and
/// the one that decays silently: a key gaining no entry falls back to a title-cased key, which
/// reads as "Objectsandtools" rather than failing.
@Test("Every offered category has a real title, not the title-cased fallback")
func everyCategoryHasATitle() {
for category in SymbolCatalog.categories {
#expect(
SymbolCatalog.categoryTitles[category.key] != nil,
"no title for '\(category.key)' — add one to SymbolCatalog.categoryTitles"
)
}
}
/// Each category's representative glyph is one this system can draw. It is a name out of the OS's
/// own plist, so a failure here means the read is misaligned with the running inventory rather
/// than that somebody made a typo.
@Test("Every category icon renders on this OS")
func categoryIconsRender() {
let missing = SymbolCatalog.categories.filter { !ItemSymbol.exists($0.icon) }
#expect(missing.isEmpty, "unrenderable category icons: \(missing.map(\.key))")
}
/// A spot check that the categories mean what they say `leaf` under Nature, `trash` under
/// Objects & Tools, `arrow.up` under Arrows. Cheap, and it would catch a key/value transposition
/// that every structural assertion above would sail through.
@Test("Well-known glyphs sit in the categories a user would look in")
func membershipIsPlausible() throws {
func symbols(_ key: String) throws -> [String] {
try #require(SymbolCatalog.categories.first { $0.key == key }, "no '\(key)' category").symbols
}
#expect(try symbols("nature").contains("leaf"))
#expect(try symbols("objectsandtools").contains("trash"))
#expect(try symbols("arrows").contains("arrow.up"))
#expect(try symbols("time").contains("timer"))
}
/// Trademark-restricted glyphs are not offered `SymbolCatalog`'s second ruling. `applelogo` is
/// the clearest case; `icloud` is the one a picker would plausibly have surfaced by accident.
@Test("Trademark-restricted glyphs are not offered")
func restrictedSymbolsAreExcluded() {
let offered = Set(SymbolCatalog.allSymbols)
for restricted in ["applelogo", "icloud", "faceid"] where ItemSymbol.exists(restricted) {
#expect(!offered.contains(restricted), "'\(restricted)' is trademark-restricted and should not be offered")
}
}
/// **Everything offered is drawable.** The catalog is not filtered through `ItemSymbol.exists` at
/// read time (that would be thousands of lookups for an answer the plist already gave), so this
/// is the test that earns that shortcut sampled rather than exhaustive, because exhaustive is
/// exactly the cost the shortcut exists to avoid.
@Test("A wide sample of the offered catalog renders on this OS")
func offeredSymbolsRender() {
let all = SymbolCatalog.allSymbols
#expect(all.count > 1000, "only \(all.count) symbols — the read has degraded to its fallback")
let step = max(1, all.count / 400)
let sample = stride(from: 0, to: all.count, by: step).map { all[$0] }
let missing = sample.filter { !ItemSymbol.exists($0) }
#expect(missing.isEmpty, "offered but unrenderable: \(missing)")
}
/// Every category's members are a subset of the "All Symbols" list the sidebar and the
/// all-symbols view cannot disagree about what exists.
@Test("No category offers a symbol the all-symbols list does not")
func categoriesAreSubsetsOfAll() {
let all = Set(SymbolCatalog.allSymbols)
for category in SymbolCatalog.categories {
let strays = category.symbols.filter { !all.contains($0) }
#expect(strays.isEmpty, "'\(category.key)' offers \(strays.prefix(5)) which All Symbols does not")
}
}
/// A bundle that is not there degrades to the app's own curated vocabulary rather than to an
/// empty browser `SymbolPickerCatalog.fullCatalog`'s own fallback posture, restated.
@Test("A nonexistent bundle falls back to the curated sets, never to nothing")
func nonexistentBundleFallsBack() {
let contents = SymbolCatalog.load(bundlePath: "/nonexistent")
let expected = Set(SymbolPickerCatalog.defaultSet + CuratedSymbols.combined).sorted()
#expect(contents.allSymbols == expected)
#expect(contents.categories.count == 1)
#expect(contents.categories.first?.symbols == expected)
#expect(contents.keywords.isEmpty)
}
@Test("Two reads of the system path agree — the cache is coherent")
func cacheIsCoherent() {
#expect(SymbolCatalog.contents().allSymbols == SymbolCatalog.contents().allSymbols)
#expect(SymbolCatalog.categories.map(\.key) == SymbolCatalog.contents().categories.map(\.key))
}
@Test("An unknown key title-cases rather than coming back empty")
func unknownTitleFallsBack() {
#expect(SymbolCatalog.title(forKey: "nature") == "Nature")
#expect(SymbolCatalog.title(forKey: "somethingnew") == "Somethingnew")
}
}
// MARK: - Search
@Suite("SymbolCatalog ▸ search")
struct SymbolCatalogSearchTests {
private let keywords = [
"trash": ["delete", "remove", "garbage"],
"key.slash": ["password", "security"],
"star": ["favorite"],
]
@Test("An empty or whitespace-only query returns the input unchanged")
func emptyQueryIsANoOp() {
let symbols = ["star", "flag", "heart"]
#expect(SymbolCatalog.search("", in: symbols, keywords: [:]) == symbols)
#expect(SymbolCatalog.search(" \t\n", in: symbols, keywords: [:]) == symbols)
}
/// **The reason this exists beside `SymbolPickerCatalog.filter`**: the word a user reaches for is
/// frequently not in the name. A name-only search finds nothing for "delete".
@Test("A keyword matches a symbol whose name does not contain the query at all")
func keywordsAreSearched() {
let symbols = ["trash", "star", "key.slash"]
#expect(SymbolCatalog.search("delete", in: symbols, keywords: keywords) == ["trash"])
#expect(SymbolCatalog.search("password", in: symbols, keywords: keywords) == ["key.slash"])
// The name-only filter genuinely cannot do this the contrast is the justification.
#expect(SymbolPickerCatalog.filter("delete", in: symbols).isEmpty)
}
@Test("Names still match as case-insensitive substrings")
func namesAreSearched() {
let symbols = ["star", "star.fill", "flag"]
#expect(SymbolCatalog.search("STAR", in: symbols, keywords: [:]) == ["star", "star.fill"])
}
@Test("Multiple tokens are an AND across names and keywords together")
func multiTokenIsAnAnd() {
let symbols = ["trash", "trash.slash", "star"]
let keywords = ["trash.slash": ["delete", "disabled"], "trash": ["delete"]]
#expect(SymbolCatalog.search("delete slash", in: symbols, keywords: keywords) == ["trash.slash"])
#expect(SymbolCatalog.search("delete trash", in: symbols, keywords: keywords) == ["trash", "trash.slash"])
}
@Test("Input order is preserved — the canonical ordering survives a search")
func orderPreserved() {
let symbols = ["zebra.star", "apple.star", "mango.star"]
#expect(SymbolCatalog.search("star", in: symbols, keywords: [:]) == symbols)
}
@Test("No match returns an empty list")
func noMatchIsEmpty() {
#expect(SymbolCatalog.search("xyzzy-nonexistent", in: ["star"], keywords: [:]).isEmpty)
}
@Test("Tokenizing trims and lowercases, and an empty query yields no tokens")
func tokenizing() {
#expect(SymbolCatalog.tokens(" Arrow UP ") == ["arrow", "up"])
#expect(SymbolCatalog.tokens(" ").isEmpty)
}
@Test("An empty token list matches everything — 'no query' is not 'match nothing'")
func emptyTokensMatchEverything() {
#expect(SymbolCatalog.matches(query: [], name: "anything", keywords: []))
}
/// Against the real catalog: the words a user would actually type find the glyphs they mean.
@Test("Real searches find real glyphs")
func realSearchesWork() {
let contents = SymbolCatalog.contents()
func find(_ query: String) -> [String] {
SymbolCatalog.search(query, in: contents.allSymbols, keywords: contents.keywords)
}
#expect(find("trash").contains("trash"))
#expect(find("wrench screw").contains("wrench.and.screwdriver"))
#expect(find("calendar").contains("calendar"))
#expect(find("qwertyuiop-nope").isEmpty)
}
}
// MARK: - The shared combo chrome
/// **The rhyme, asserted.** The card's first ask was that the two pickers be "roughly same
/// shape/size", and the way that was made true is structural one metrics value, one base control
/// so the test is about the structure rather than about two numbers that happen to agree today.
@Suite("ComboField ▸ the shared chrome")
struct ComboFieldMetricsTests {
@Test("Every figure scales with the body font, and reproduces the shipped numbers at 13pt")
func metricsAtTheStandardBody() {
let metrics = ComboFieldMetrics.metrics(bodyPointSize: 13)
#expect(metrics.height == 18)
#expect(metrics.triggerWidth == 16)
#expect(metrics.facePaddingH == 7)
#expect(metrics.facePaddingV == 5)
#expect(metrics.cornerRadius == 3)
#expect(metrics.fieldRadius == 4)
#expect(metrics.triggerInset == 2)
}
@Test("A larger text size grows every figure, and none collapses to zero")
func metricsScale() {
let small = ComboFieldMetrics.metrics(bodyPointSize: 11)
let large = ComboFieldMetrics.metrics(bodyPointSize: 24)
#expect(large.height > small.height)
#expect(large.triggerWidth > small.triggerWidth)
#expect(large.glyphPointSize > small.glyphPointSize)
for metrics in [ComboFieldMetrics.metrics(bodyPointSize: 8), small, large] {
#expect(metrics.height >= 1)
#expect(metrics.triggerWidth >= 1)
#expect(metrics.cornerRadius >= 1)
#expect(metrics.glyphPointSize >= 1)
}
}
/// The field radius runs a point outside the face's so the two rounded rects stay concentric
/// a small thing, and exactly the kind of thing that drifts when two files own it.
@Test("The field's radius stays outside the face's")
func radiiAreConcentric() {
for size in [11.0, 13.0, 17.0, 24.0] as [CGFloat] {
let metrics = ComboFieldMetrics.metrics(bodyPointSize: size)
#expect(metrics.fieldRadius >= metrics.cornerRadius)
}
}
/// A glyph must actually fit: the face's height less its own padding is what the symbol is drawn
/// at, and a negative or vanishing figure is the bug the 14pt height had.
@Test("A glyph fills most of the field's height")
func glyphFillsTheField() {
for size in [11.0, 13.0, 17.0, 24.0] as [CGFloat] {
let metrics = ComboFieldMetrics.metrics(bodyPointSize: size)
#expect(metrics.glyphPointSize > metrics.height * 0.6, "the glyph is lost in its own field at \(size)pt")
#expect(metrics.glyphPointSize <= metrics.height)
}
}
/// **The two controls are the same control.** Both are `ComboFieldControl`s and both take their
/// geometry from the same value, so a change to one lands on the other which is the whole of
/// the parity claim, and cheaper to assert than any pair of measurements.
@MainActor
@Test("Both combos are the same chrome, at the same size")
func bothCombosShareTheChrome() {
let colour = ColorComboControl(frame: .zero)
let symbol = SymbolComboControl(frame: .zero)
for control in [colour as ComboFieldControl, symbol] {
control.metrics = .metrics(bodyPointSize: 13)
}
#expect(colour.intrinsicContentSize.height == symbol.intrinsicContentSize.height)
#expect(colour.intrinsicContentSize.width == symbol.intrinsicContentSize.width)
colour.setFrameSize(NSSize(width: 120, height: colour.intrinsicContentSize.height))
symbol.setFrameSize(NSSize(width: 120, height: symbol.intrinsicContentSize.height))
#expect(colour.triggerRect == symbol.triggerRect, "the trigger zones must line up")
#expect(colour.faceZone == symbol.faceZone, "the face zones must line up")
}
/// The two zones tile the control exactly no dead strip between them, no overlap that would
/// make one door swallow the other's clicks.
@MainActor
@Test("The face and the trigger tile the control with no gap and no overlap")
func zonesTileTheControl() {
let control = SymbolComboControl(frame: NSRect(x: 0, y: 0, width: 140, height: 18))
control.metrics = .metrics(bodyPointSize: 13)
#expect(control.faceZone.maxX == control.triggerRect.minX)
#expect(control.faceZone.minX == control.bounds.minX)
#expect(control.triggerRect.maxX == control.bounds.maxX)
#expect(control.faceZone.width + control.triggerRect.width == control.bounds.width)
}
/// A control too narrow for its own trigger must not hand the face a negative width a sidebar
/// squeezed to nothing is a layout bug, not a crash.
@MainActor
@Test("A control narrower than its trigger degrades to an empty face")
func degenerateWidthIsSafe() {
let control = SymbolComboControl(frame: NSRect(x: 0, y: 0, width: 4, height: 18))
control.metrics = .metrics(bodyPointSize: 13)
#expect(control.faceZone.width >= 0)
}
}
+5 -1
View File
@@ -26,10 +26,14 @@ struct SymbolPickerCatalogDefaultSetTests {
@Suite("SymbolPicker ▸ the colour row")
struct SymbolPickerColorSetTests {
@Test("Seven unique tints — the leading None plus these fills the 4×2 grid exactly")
/// Eleven since the palette grew (2026-08-09), seven before it stated as the grid arithmetic
/// rather than as a literal, because the claim is *the leading None plus these fills the grid
/// exactly*, and that is what breaks when either the palette or the column count moves.
@Test("The leading None plus the tints fills the colour grid exactly")
func shape() {
#expect(SymbolPickerCatalog.colorSet.count
== SymbolPickerLayout.colorColumns * SymbolPickerLayout.colorRows - 1)
#expect(SymbolPickerCatalog.colorSet.count == 11)
#expect(Set(SymbolPickerCatalog.colorSet).count == SymbolPickerCatalog.colorSet.count)
}