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) } }