Files
lanework/KanbanTests/SymbolCatalogTests.swift
T
rzen 50e26efdb9 The combo drops its padding and turns portrait — a 4:5 field puts the symbol dead center
Owner's first review of the combo rework (2026-08-09): remove the face padding, make the
field taller and narrower at about a 4:5 width:height ratio, and center the symbol glyph in
its face. All three land in ComboFieldMetrics, so ColorComboControl and SymbolComboControl
stay the identical shape they were built to share.

- ComboFieldMetrics grows a width figure (height * widthToHeightRatio, 0.8), replacing
  NSView.noIntrinsicMetric — every combo now carries its own taller, narrower intrinsic size
  instead of stretching to whatever a caller's frame proposed.
- facePaddingH/facePaddingV/glyphPadding are gone; a face fills its zone edge to edge.
  glyphPointSize is now whichever of the face's own width/height is smaller, with nothing
  subtracted for padding that no longer exists.
- SymbolComboControl.drawFace centers the glyph on both axes — it only ever centered
  vertically before, despite its own doc comment claiming otherwise.
- ComboFieldControl.drawTrigger bounds its chevron square by the smaller of the trigger
  strip's own width/height, not height alone, since the strip is no longer close to square
  once the field is much taller than it is wide.
- CardSidebarSections drops the sidebar's old '* 0.55' fixed-width frame on both combo rows;
  each control now sizes itself, and both anchors (card sidebar, board popover) compose the
  narrower field with no other changes needed.
- ComboFieldMetricsTests updated for the new figures, plus a ratio-holds-at-every-size test
  and a rewritten glyph-fit test matching the no-padding rule.

Verification: xcodebuild build succeeded; xcodebuild test -only-testing:KanbanTests — 3220
tests in 559 suites, 3 failures, all PointerLatencyTests (documented locked-screen
environmental mode, confirmed unrelated by isolated rerun). Pixel verification unexercised —
same locked-screen constraint the first pass hit.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 12:06:01 -04:00

336 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
// 2026-08-09 iteration: taller and narrower than the first pass's 18×(whatever the caller
// proposed) bar — `width` is now `ComboFieldMetrics`' own figure, not the caller's.
#expect(metrics.height == 36)
#expect(metrics.width == 29)
#expect(metrics.triggerWidth == 16)
#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.width > small.width)
#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.width >= 1)
#expect(metrics.triggerWidth >= 1)
#expect(metrics.cornerRadius >= 1)
#expect(metrics.glyphPointSize >= 1)
}
}
/// **The owner's own figure, held still.** "Almost square, about 4:5 ratio" (2026-08-09) is not
/// a one-off measurement — `width` is derived from `height` by `widthToHeightRatio`, so this
/// holds at every body size rather than only the one somebody happened to check.
@Test("The field is taller than wide, at about a 4:5 ratio, from height alone")
func fieldIsAlmostSquare() {
for size in [8.0, 11.0, 13.0, 17.0, 24.0, 36.0] as [CGFloat] {
let metrics = ComboFieldMetrics.metrics(bodyPointSize: size)
#expect(metrics.width < metrics.height, "the field should read taller than wide at \(size)pt")
let ratio = metrics.width / metrics.height
#expect(abs(ratio - ComboFieldMetrics.widthToHeightRatio) < 0.05,
"ratio drifted to \(ratio) at \(size)pt, want ~4:5 (0.8)")
}
}
/// 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, and — since the 2026-08-09 iteration removed the face's padding —
/// fit *exactly*: `glyphPointSize` is whichever of the face's own two dimensions is smaller, with
/// nothing subtracted for a padding that no longer exists. A negative or vanishing figure is the
/// bug the first pass's 14pt height had.
@Test("A glyph fills its face zone's limiting dimension, with no padding taken out of it")
func glyphFillsTheField() {
for size in [11.0, 13.0, 17.0, 24.0] as [CGFloat] {
let metrics = ComboFieldMetrics.metrics(bodyPointSize: size)
#expect(metrics.glyphPointSize == min(metrics.faceWidth, metrics.height))
// The field is taller than wide by construction, so the face reads the same way and
// `faceWidth` is the dimension actually doing the limiting.
#expect(metrics.faceWidth < metrics.height, "expected the face to be the binding dimension at \(size)pt")
#expect(metrics.glyphPointSize == metrics.faceWidth)
#expect(metrics.glyphPointSize >= 1)
}
}
/// **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)
}
}