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.15–0.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.15–0.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) } }