Both the symbol picker and the background color control give up the two-zone combo chrome from the 2026-08-09 rework: no more face/trigger split, no more trailing chevron square. Each is now a single bordered rectangle with one hit zone, 2.1em tall and 1.5x that wide (a 4:6 ratio, 50% taller than the retired chrome's 18pt). A click anywhere opens the same curated popover the trigger used to gate. The background rectangle's popover is new: a None-plus-sixteen palette grid mirroring the Style… popover's own wells, with an Other… row onto the shared, pre-debounced Colors panel session — replacing the old NSMenu dropdown outright. The symbol rectangle keeps its existing popover (search, curated grid, tint colors, More Symbols…) verbatim; only its entry point collapsed to one zone. ComboFieldControl/ComboFieldMetrics (ComboField.swift) are replaced by PickerRectControl/PickerRectMetrics (PickerRect.swift). ColorComboView and its NSMenu-building pure model are retired wholesale in favor of ColorSwatchPicker. SymbolComboControl becomes SymbolGlyphControl. PaletteSwatch.rectImage, the last caller of which was the retired dropdown's menu rows, goes with it. In the card window sidebar, the "Style" section header becomes "Appearance" (sidebar only — the board context menu's Style… item and StyleEditorView's own naming are untouched), and the symbol and background controls move from stacked rows to side-by-side columns, each captioned above rather than leading. CardStyleSection no longer carries its own debounce Task for background panel picks — the shared Colors-panel session now delivers an already-settled value. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
321 lines
17 KiB
Swift
321 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 rectangle chrome
|
||
|
||
/// **The rhyme, asserted.** The owner's 2026-08-10 ruling asked for both pickers to become "a
|
||
/// rectangle (slightly oversized)... about 4:6 ratio of height to width" — and the way that is 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("PickerRect ▸ the shared chrome")
|
||
struct PickerRectMetricsTests {
|
||
|
||
@Test("Every figure scales with the body font, and reproduces the shipped numbers at 13pt")
|
||
func metricsAtTheStandardBody() {
|
||
let metrics = PickerRectMetrics.metrics(bodyPointSize: 13)
|
||
// Owner's ruling, 2026-08-10: "50% taller" than the retired two-zone chrome's 18pt (→ 27pt),
|
||
// and "about 4:6 ratio" — width is height × 1.5 (`PickerRectMetrics`'s own doc comment).
|
||
#expect(metrics.height == 27)
|
||
#expect(metrics.width == 41)
|
||
#expect(metrics.cornerRadius == 3)
|
||
#expect(metrics.fieldRadius == 4)
|
||
}
|
||
|
||
@Test("A larger text size grows every figure, and none collapses to zero")
|
||
func metricsScale() {
|
||
let small = PickerRectMetrics.metrics(bodyPointSize: 11)
|
||
let large = PickerRectMetrics.metrics(bodyPointSize: 24)
|
||
#expect(large.height > small.height)
|
||
#expect(large.width > small.width)
|
||
for metrics in [PickerRectMetrics.metrics(bodyPointSize: 8), small, large] {
|
||
#expect(metrics.height >= 1)
|
||
#expect(metrics.width >= 1)
|
||
#expect(metrics.cornerRadius >= 1)
|
||
}
|
||
}
|
||
|
||
/// **The owner's 2026-08-10 figures, pinned.** Height is 2.1 em; width is derived from height
|
||
/// (`height × 1.5`) rather than its own independent em multiple, which is what keeps the 4:6
|
||
/// 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, at every body size")
|
||
func heightAndWidthHoldTheRatio() {
|
||
for size in [8.0, 11.0, 13.0, 17.0, 24.0, 36.0] as [CGFloat] {
|
||
let metrics = PickerRectMetrics.metrics(bodyPointSize: size)
|
||
#expect(metrics.height == max(1, (size * 2.1).rounded()), "height drifted from 2.1 em at \(size)pt")
|
||
#expect(metrics.width == max(1, (metrics.height * 1.5).rounded()), "width drifted from 1.5 × height at \(size)pt")
|
||
// 4:6 as a ratio, within the slack one rounding step introduces at the smallest sizes.
|
||
#expect(abs(metrics.width / metrics.height - 1.5) < 0.06, "ratio drifted from 4:6 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.
|
||
@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)
|
||
#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")
|
||
}
|
||
|
||
/// **The two controls are the same control.** 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, so intrinsic size is the whole of what "same chrome" means now.
|
||
@MainActor
|
||
@Test("Both rectangles are the same chrome, at the same size")
|
||
func bothRectanglesShareTheChrome() {
|
||
let colour = ColorSwatchControl(frame: .zero)
|
||
let symbol = SymbolGlyphControl(frame: .zero)
|
||
for control in [colour as PickerRectControl, symbol] {
|
||
control.metrics = .metrics(bodyPointSize: 13)
|
||
}
|
||
#expect(colour.intrinsicContentSize.height == symbol.intrinsicContentSize.height)
|
||
#expect(colour.intrinsicContentSize.width == symbol.intrinsicContentSize.width)
|
||
}
|
||
}
|