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
313 lines
16 KiB
Swift
313 lines
16 KiB
Swift
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)
|
|
}
|
|
}
|