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
336 lines
17 KiB
Swift
336 lines
17 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)
|
||
// 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)
|
||
}
|
||
}
|