Files
lanework/KanbanTests/SymbolCatalogTests.swift
T
rzen f191e5c3ce Two widths, one height — the symbol picker squares to 5:4, colour holds 6:4, and None's slash follows the swatch out of the grid
The owner's follow-up review on the shipped rectangles (Pipeline card
5004c540): height was close, so it stays exactly 2.1em on both controls;
width now diverges by role via PickerRectMetrics.WidthRatio (color 1.5,
symbol 1.25) instead of one shared multiplier. The symbol glyph gets a
small em-derived inset back (SymbolGlyphControl.glyphInset) — a sliver of
breath, not the old padded ring — applied to the face rect before sizing
and fitting, still centred on the same midpoint. The collapsed colour
swatch reuses ColorSwatchNoneStrike's own geometry to draw the popover's
None slash whenever the stored value is nil or unresolvable, rather than
sitting empty. Both mounting anchors (the card sidebar's side-by-side
columns, the board popover beside the rename field) pick up .fixedSize()
so the controls render at their own intrinsic size instead of stretching
into whatever slack an HStack proposal leaves them.

KanbanTests/SymbolCatalogTests.swift and ColorSwatchPickerTests.swift
updated for the per-role widths, the shared-height claim, the glyph
inset rule, and the None-face predicate.
2026-08-09 21:37:29 -04:00

386 lines
21 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 rectangle chrome
/// **The rhyme, asserted — now with two widths.** The owner's 2026-08-10 ruling first asked for both
/// pickers to become "a rectangle (slightly oversized)... about 4:6 ratio of height to width", then a
/// same-day follow-up refined it per role: "width to height ratio should be closer to 6:4 [colour] ...
/// the symbol picker should be even more square at ratio of 5:4 or so ... the height for the two
/// pickers should be exactly the same." The way both halves of that are made true is structural — one
/// metrics value, one base control, one shared `height`, a `WidthRatio` that only ever changes
/// `width` — so the tests are about the structure rather than about numbers that happen to agree today.
@Suite("PickerRect ▸ the shared chrome")
struct PickerRectMetricsTests {
@Test("Every figure scales with the body font, and reproduces the shipped numbers at 13pt")
func metricsAtTheStandardBody() {
let color = PickerRectMetrics.metrics(bodyPointSize: 13, widthRatio: .color)
let symbol = PickerRectMetrics.metrics(bodyPointSize: 13, widthRatio: .symbol)
// Owner's ruling, 2026-08-10: "50% taller" than the retired two-zone chrome's 18pt (→ 27pt),
// shared by both pickers — and each picker's own width ratio from the same day's follow-up:
// 6:4 (× 1.5) for colour, 5:4 (× 1.25) for symbol.
#expect(color.height == 27)
#expect(symbol.height == 27)
#expect(color.width == 41)
#expect(symbol.width == 34)
for metrics in [color, symbol] {
#expect(metrics.cornerRadius == 3)
#expect(metrics.fieldRadius == 4)
}
}
@Test("A larger text size grows every figure, and none collapses to zero", arguments: [
PickerRectMetrics.WidthRatio.color, .symbol,
])
func metricsScale(widthRatio: PickerRectMetrics.WidthRatio) {
let small = PickerRectMetrics.metrics(bodyPointSize: 11, widthRatio: widthRatio)
let large = PickerRectMetrics.metrics(bodyPointSize: 24, widthRatio: widthRatio)
#expect(large.height > small.height)
#expect(large.width > small.width)
for metrics in [PickerRectMetrics.metrics(bodyPointSize: 8, widthRatio: widthRatio), small, large] {
#expect(metrics.height >= 1)
#expect(metrics.width >= 1)
#expect(metrics.cornerRadius >= 1)
}
}
/// **The owner's 2026-08-10 figures, pinned, per role.** Height is 2.1 em for both pickers alike;
/// width is derived from height (`height × widthRatio`) rather than its own independent em
/// multiple, which is what keeps each ratio exact — to rounding — at every body size instead of
/// the two figures drifting apart.
@Test("Height is 2.1 em; width is 1.5 × height for colour, 1.25 × height for symbol, at every body size")
func heightAndWidthHoldTheRatio() {
for size in [8.0, 11.0, 13.0, 17.0, 24.0, 36.0] as [CGFloat] {
for widthRatio in [PickerRectMetrics.WidthRatio.color, .symbol] {
let metrics = PickerRectMetrics.metrics(bodyPointSize: size, widthRatio: widthRatio)
#expect(metrics.height == max(1, (size * 2.1).rounded()), "height drifted from 2.1 em at \(size)pt (\(widthRatio))")
#expect(
metrics.width == max(1, (metrics.height * widthRatio.rawValue).rounded()),
"width drifted from \(widthRatio.rawValue) × height at \(size)pt"
)
// Within the slack one rounding step introduces at the smallest sizes.
#expect(
abs(metrics.width / metrics.height - widthRatio.rawValue) < 0.06,
"ratio drifted from \(widthRatio.rawValue) at \(size)pt"
)
}
}
}
/// **The owner's most literal ask, pinned on its own**: "the height for the two pickers should be
/// exactly the same" — not merely close, and not merely equal at 13pt, but equal at every body
/// size, since `height` never reads `widthRatio` at all.
@Test("The two pickers share exactly the same height at every body size")
func heightsMatchAcrossRolesAtEverySize() {
for size in [8.0, 11.0, 13.0, 17.0, 24.0, 36.0] as [CGFloat] {
let color = PickerRectMetrics.metrics(bodyPointSize: size, widthRatio: .color)
let symbol = PickerRectMetrics.metrics(bodyPointSize: size, widthRatio: .symbol)
#expect(color.height == symbol.height, "heights diverged at \(size)pt")
// And the widths must genuinely differ — the symbol picker is "even more square" than
// the colour rectangle, not merely renamed to a ratio that happens to compute the same.
#expect(symbol.width < color.width, "the symbol picker is not narrower than the colour picker at \(size)pt")
}
}
/// 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. Ratio-
/// independent (neither radius reads `widthRatio`), so one role stands in for both.
@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 = PickerRectMetrics.metrics(bodyPointSize: size, widthRatio: .color)
#expect(metrics.fieldRadius >= metrics.cornerRadius)
}
}
/// **The actual-rect rule, unaffected by the trigger's removal.** `SymbolGlyphControl` sizes a
/// glyph off whichever face rect it is actually handed at draw time — now always the control's
/// own `bounds`, since there is no trigger strip left to subtract. Both the point-size rule and
/// the overshoot-scaling rule are pure static functions on `SymbolGlyphControl`, assertable with
/// no control on screen and no draw.
@Test("A glyph's point size and drawn size follow the actual face rect, not the pure metrics")
func glyphSizesOffTheActualFaceRect() {
// A short, wide rect — the shape the card sidebar's row actually proposes now that the field
// is wider than it is tall. The binding dimension is height, not width.
let wideRect = NSRect(x: 0, y: 0, width: 200, height: 27)
#expect(SymbolGlyphControl.glyphPointSize(forFace: wideRect) == 27)
// A tall, narrow rect, for symmetry — the binding dimension flips to width.
let tallRect = NSRect(x: 0, y: 0, width: 12, height: 40)
#expect(SymbolGlyphControl.glyphPointSize(forFace: tallRect) == 12)
// A glyph configured within its rect never needs to grow.
let snugSize = NSSize(width: 27, height: 27)
#expect(SymbolGlyphControl.fittedSize(for: snugSize, in: wideRect) == snugSize)
// A glyph wider than the point size it was configured at genuinely overshoots a *narrow*
// rect on its long axis (the realistic case: `tallRect`'s 12pt binding dimension, a symbol a
// touch wider than tall at that size) and must be scaled down proportionally, not clipped.
let elongated = NSSize(width: 16, height: 12)
let fitted = SymbolGlyphControl.fittedSize(for: elongated, in: tallRect)
#expect(fitted.width < elongated.width, "the wide axis must actually shrink")
#expect(fitted.width <= tallRect.width)
#expect(fitted.height <= tallRect.height)
#expect(abs(fitted.width / fitted.height - elongated.width / elongated.height) < 0.001,
"the scale-down must preserve the glyph's own aspect ratio")
}
/// **"A sliver of padding around the symbol"** (owner's 2026-08-10 follow-up) — a pure static
/// figure, so the rule (roughly 0.150.2 em, never zero) is assertable without a draw.
@Test("The glyph's inset scales with the body font, sits in the owner's named range, and never zeroes out")
func glyphInsetHoldsTheOwnersRange() {
var previous: CGFloat = 0
for size in [8.0, 11.0, 13.0, 17.0, 24.0, 36.0] as [CGFloat] {
let inset = SymbolGlyphControl.glyphInset(bodyPointSize: size)
#expect(inset >= 1, "the inset collapsed to nothing at \(size)pt")
#expect(inset >= previous, "the inset shrank as the body font grew, at \(size)pt")
previous = inset
// Within the slack one rounding step introduces at the smallest sizes.
#expect((0.15 - 0.03...0.2 + 0.03).contains(inset / size), "inset drifted outside ~0.150.2 em at \(size)pt")
}
}
/// A glyph inset by `glyphInset(bodyPointSize:)` genuinely narrows the rect `glyphPointSize(
/// forFace:)`/`fittedSize(for:in:)` size against — the sliver has to actually shrink the drawn
/// glyph, not merely exist as an unused figure.
@Test("An inset face rect yields a smaller point size than the same rect uninset")
func insetFaceRectShrinksTheGlyph() {
let rect = NSRect(x: 0, y: 0, width: 34, height: 27)
let inset = SymbolGlyphControl.glyphInset(bodyPointSize: 13)
let insetRect = rect.insetBy(dx: inset, dy: inset)
#expect(SymbolGlyphControl.glyphPointSize(forFace: insetRect) < SymbolGlyphControl.glyphPointSize(forFace: rect))
}
/// **The two controls are the same control, at two different widths.** Both are
/// `PickerRectControl`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. There are no zones left to compare (`PickerRect.swift`'s own retirement of the
/// trigger strip); the whole bounds is the one hit zone on both. Height is the one figure that
/// still has to match exactly (the owner's 2026-08-10 follow-up); width now diverges by role on
/// purpose, so "same chrome" means equal height and each control's own ratio-derived width.
@MainActor
@Test("Both rectangles are the same chrome — equal height, each its own ratio's width")
func bothRectanglesShareTheChrome() {
let colour = ColorSwatchControl(frame: .zero)
let symbol = SymbolGlyphControl(frame: .zero)
colour.metrics = .metrics(bodyPointSize: 13, widthRatio: .color)
symbol.metrics = .metrics(bodyPointSize: 13, widthRatio: .symbol)
#expect(colour.intrinsicContentSize.height == symbol.intrinsicContentSize.height)
#expect(colour.intrinsicContentSize.width == 41)
#expect(symbol.intrinsicContentSize.width == 34)
#expect(symbol.intrinsicContentSize.width < colour.intrinsicContentSize.width)
}
}