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) #expect(metrics.height == 18) #expect(metrics.triggerWidth == 16) #expect(metrics.facePaddingH == 7) #expect(metrics.facePaddingV == 5) #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.triggerWidth > small.triggerWidth) #expect(large.glyphPointSize > small.glyphPointSize) for metrics in [ComboFieldMetrics.metrics(bodyPointSize: 8), small, large] { #expect(metrics.height >= 1) #expect(metrics.triggerWidth >= 1) #expect(metrics.cornerRadius >= 1) #expect(metrics.glyphPointSize >= 1) } } /// 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: the face's height less its own padding is what the symbol is drawn /// at, and a negative or vanishing figure is the bug the 14pt height had. @Test("A glyph fills most of the field's height") func glyphFillsTheField() { for size in [11.0, 13.0, 17.0, 24.0] as [CGFloat] { let metrics = ComboFieldMetrics.metrics(bodyPointSize: size) #expect(metrics.glyphPointSize > metrics.height * 0.6, "the glyph is lost in its own field at \(size)pt") #expect(metrics.glyphPointSize <= metrics.height) } } /// **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) } }