The owner's evening review superseded the same day's 4:5-ratio pass: 'reduce vertical size of both pickers by 50%, on symbol picker by reducing padding.' ComboFieldMetrics.height goes back to 1.4 em (18pt at the standard body, exactly half the 4:5 pass's 36) — both combos get this for free, since it's shared. width stops being height's own restatement (widthToHeightRatio is gone); it holds the 4:5 pass's pixel figure as its own em number instead, because re-deriving it at the new height would starve the trigger strip and empty the face zone entirely. SymbolComboControl.drawFace now sizes its glyph off the actual face rect it is handed at draw time rather than the pure ComboFieldMetrics.glyphPointSize (removed, along with its doc — it only ever described the control's intrinsic portrait width, not the stretched-wide shape the card sidebar's row actually proposes). No inset — SF Symbols carry their own margins — with the sizing and overshoot-scaling rules split into two pure static functions so the fit is assertable without a live draw. ComboFieldMetricsTests updated for the new figures and the new actual-rect glyph rule; the superseded 4:5-ratio-at-six-sizes test is replaced by one pinning height/width independently and asserting faceWidth stays positive at every size the old test used. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
349 lines
18 KiB
Swift
349 lines
18 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)
|
|
// Evening 2026-08-09 review: "reduce vertical size of both pickers by 50%" — height is half
|
|
// the 4:5 pass's 36, and width holds the 4:5 pass's own pixel figure instead of re-deriving
|
|
// from the now-shorter height (`ComboFieldMetrics`'s own doc comment).
|
|
#expect(metrics.height == 18)
|
|
#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)
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// **The owner's evening figures, held still.** "Reduce vertical size of both pickers by 50%"
|
|
/// (2026-08-09 evening) superseded the earlier "almost square, about 4:5 ratio" ruling outright —
|
|
/// `height` and `width` are now independent em figures rather than one derived from the other, so
|
|
/// this asserts both hold at every body size the superseded ratio test used, and that `faceWidth`
|
|
/// never collapses the way it would have if `width` had stayed a function of `height`.
|
|
@Test("Height is 1.4 em, width is 2.2 em, and the face zone stays positive at every body size")
|
|
func heightAndWidthAreIndependentEmFigures() {
|
|
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.height == max(1, (size * 1.4).rounded()), "height drifted from 1.4 em at \(size)pt")
|
|
#expect(metrics.width == max(1, (size * 2.2).rounded()), "width drifted from 2.2 em at \(size)pt")
|
|
#expect(metrics.faceWidth > 0, "the face zone collapsed 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 = ComboFieldMetrics.metrics(bodyPointSize: size)
|
|
#expect(metrics.fieldRadius >= metrics.cornerRadius)
|
|
}
|
|
}
|
|
|
|
/// **The new actual-rect rule.** `ComboFieldMetrics.glyphPointSize` is gone; `SymbolComboControl`
|
|
/// now sizes a glyph off whichever face rect it is actually handed at draw time (this is what lets
|
|
/// the sidebar's stretched-wide control still read as "no padding" rather than a small glyph in a
|
|
/// big field). Both the point-size rule and the overshoot-scaling rule are pure static functions
|
|
/// on `SymbolComboControl`, 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: 18)
|
|
#expect(SymbolComboControl.glyphPointSize(forFace: wideRect) == 18)
|
|
|
|
// A tall, narrow rect, for symmetry — the binding dimension flips to width.
|
|
let tallRect = NSRect(x: 0, y: 0, width: 12, height: 40)
|
|
#expect(SymbolComboControl.glyphPointSize(forFace: tallRect) == 12)
|
|
|
|
// A glyph configured within its rect never needs to grow.
|
|
let snugSize = NSSize(width: 18, height: 18)
|
|
#expect(SymbolComboControl.fittedSize(for: snugSize, in: wideRect) == snugSize)
|
|
|
|
// A horizontally elongated glyph (wider than the point size it was configured at) overshoots
|
|
// the rect on its long axis and must be scaled down proportionally, not clipped.
|
|
let elongated = NSSize(width: 36, height: 18)
|
|
let fitted = SymbolComboControl.fittedSize(for: elongated, in: wideRect)
|
|
#expect(fitted.width <= wideRect.width)
|
|
#expect(fitted.height <= wideRect.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 `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)
|
|
}
|
|
}
|