Files
lanework/KanbanTests/SymbolPickerTests.swift
T
rzen ece33bbf78 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
2026-08-09 09:44:30 -04:00

111 lines
5.1 KiB
Swift

import Testing
@testable import Kanban
/// **The symbol picker's pure seams** (03-board-ui.md § Styling ▸ Controls, the general-purpose
/// picker `SymbolPicker.swift` builds beside the style editor's own curated grid): the curated
/// default set, the full-catalog loader and its cache, and the search filter's AND semantics.
/// SwiftUI rendering — the well grid, the popover's arrow-key navigation — is deliberately untested,
/// exactly as `StyleEditor.swift`'s own wells are.
@Suite("SymbolPicker ▸ the curated default set")
struct SymbolPickerCatalogDefaultSetTests {
@Test("Exactly 36 entries, all unique")
func shape() {
#expect(SymbolPickerCatalog.defaultSet.count == 36)
#expect(Set(SymbolPickerCatalog.defaultSet).count == SymbolPickerCatalog.defaultSet.count)
}
@Test("Every entry is one this system can actually draw — pins the list against typos")
func everyNameResolves() {
let missing = SymbolPickerCatalog.defaultSet.filter { !ItemSymbol.exists($0) }
#expect(missing.isEmpty, "unknown SF Symbol names: \(missing)")
#expect(SymbolPickerCatalog.available.count == SymbolPickerCatalog.defaultSet.count)
}
}
@Suite("SymbolPicker ▸ the colour row")
struct SymbolPickerColorSetTests {
/// 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)
}
@Test("Every entry is a palette name that resolves — pins the list against typos and palette drift")
func everyNameResolves() {
let missing = SymbolPickerCatalog.colorSet.filter { Palette.color(named: $0) == nil }
#expect(missing.isEmpty, "unresolvable palette names: \(missing)")
}
}
@Suite("SymbolPicker ▸ filter")
struct SymbolPickerFilterTests {
@Test("An empty or whitespace-only query returns the input unchanged")
func emptyQueryIsANoOp() {
let symbols = ["star", "flag", "heart"]
#expect(SymbolPickerCatalog.filter("", in: symbols) == symbols)
#expect(SymbolPickerCatalog.filter(" ", in: symbols) == symbols)
#expect(SymbolPickerCatalog.filter("\t\n", in: symbols) == symbols)
}
@Test("A single token matches case-insensitively, as a substring")
func singleTokenSubstring() {
let symbols = ["star", "star.fill", "flag", "flag.checkered"]
#expect(SymbolPickerCatalog.filter("star", in: symbols) == ["star", "star.fill"])
#expect(SymbolPickerCatalog.filter("STAR", in: symbols) == ["star", "star.fill"])
#expect(SymbolPickerCatalog.filter("Fla", in: symbols) == ["flag", "flag.checkered"])
}
@Test("Multiple tokens are an AND — every token must appear somewhere in the name")
func multiTokenIsAnAnd() {
let symbols = ["wrench.and.screwdriver", "screwdriver", "wrench"]
#expect(SymbolPickerCatalog.filter("wrench screw", in: symbols) == ["wrench.and.screwdriver"])
#expect(SymbolPickerCatalog.filter("screw wrench", in: symbols) == ["wrench.and.screwdriver"])
}
@Test("Input order is preserved")
func orderPreserved() {
let symbols = ["zebra.star", "apple.star", "mango.star"]
#expect(SymbolPickerCatalog.filter("star", in: symbols) == symbols)
}
@Test("No match returns an empty array")
func noMatchIsEmpty() {
#expect(SymbolPickerCatalog.filter("xyzzy-nonexistent", in: ["star", "flag"]).isEmpty)
}
}
@Suite("SymbolPicker ▸ the full catalog")
struct SymbolPickerFullCatalogTests {
@Test("The default path returns a sorted, unique, non-empty list containing well-known names")
func defaultPathLoads() {
let catalog = SymbolPickerCatalog.fullCatalog()
#expect(!catalog.isEmpty)
#expect(catalog == catalog.sorted())
#expect(Set(catalog).count == catalog.count)
#expect(catalog.contains("star"))
#expect(catalog.contains("folder"))
}
@Test("Two calls against the default path return identical results — the cache is coherent")
func cacheIsCoherent() {
#expect(SymbolPickerCatalog.fullCatalog() == SymbolPickerCatalog.fullCatalog())
}
@Test("A nonexistent bundle path falls back to the merged curated set, sorted and unique")
func nonexistentPathFallsBack() {
// `CuratedSymbols.all` was one flat list; 2026-08-09 split it into three level-specific sets
// plus `combined`, the union this fallback still reads (`SymbolPicker.swift`'s `load`).
let expected = Set(SymbolPickerCatalog.defaultSet + CuratedSymbols.combined).sorted()
#expect(SymbolPickerCatalog.fullCatalog(bundlePath: "/nonexistent") == expected)
}
}