`CuratedSymbols` was one flat ~65-glyph list serving every style-editor target alike. It is now three level-specific sets — `boards` (project/container/identity), `lanes` (workflow/stage/status), `cards` (work-item/content) — each ~30-40 entries, seeded from the original list and `SymbolPickerCatalog.defaultSet`, reorganized by which level a glyph actually reads as being about. Overlap is kept where a glyph genuinely fits everywhere (`flag`, `star`). Wiring: - `BoardInfoPopover`'s board-glyph `SymbolPicker` now passes `CuratedSymbols.availableBoards` instead of the picker's domain-agnostic default. - The card sidebar's `SymbolPicker` (`CardSidebarSections`) now passes `CuratedSymbols.availableCards` instead of the old flat `available`. - The style editor's own curated grid (`StyleEditorView`, the Style… popover's only remaining anchor) reads `CuratedSymbols.availableForStyleEditor(level:spansLevels:)`: a homogeneous target reads its own level's set, and a target that somehow spans more than one level (today unreachable — 04-interactions.md's cards-XOR-lanes rule keeps a live selection homogeneous) reads the three combined, via a new `BoardStore.styleTargetSpansLevels` seam that asks the question `styleLevel(of:)` deliberately collapses. - `CuratedSymbols.combined` (the three sets' stable-order union) also replaces the old `.all` in `SymbolPickerCatalog`'s full-catalog fallback. - `SymbolPickerCatalog.defaultSet` is kept as the fallback for a caller naming no level (a future saved-search picker, say) rather than retired. DESIGN/03-board-ui.md and DESIGN/05-card-window.md's Styling/sidebar prose amended minimally where they named "the curated set" as a single list. Tests: three new/rewritten suites in KanbanTests/StyleModelTests.swift (set shape, availability, overlap, `combined`, the style-editor level/span decision, the board-anchor width tripwire), one new test in KanbanTests/StyleWriteTests.swift (`styleTargetSpansLevels`), and the old single-list-pinning tests in KanbanTests/SymbolPickerTests.swift and KanbanTests/CardSidebarTests.swift updated to the new set names. 2834 tests, 487 suites green (KanbanTests, arm64); one unrelated flaky failure (RootRecoveryTests.vanishAndReturn under full-suite load) passed clean in isolation. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
107 lines
4.8 KiB
Swift
107 lines
4.8 KiB
Swift
import Testing
|
||
@testable import Kanban
|
||
|
||
/// **The symbol picker's pure seams** (03-board-ui.md § Styling ▸ Controls, the general-purpose
|
||
/// picker `SymbolPicker.swift` builds beside the style editor's own curated grid): the curated
|
||
/// default set, the full-catalog loader and its cache, and the search filter's AND semantics.
|
||
/// SwiftUI rendering — the well grid, the popover's arrow-key navigation — is deliberately untested,
|
||
/// exactly as `StyleEditor.swift`'s own wells are.
|
||
@Suite("SymbolPicker ▸ the curated default set")
|
||
struct SymbolPickerCatalogDefaultSetTests {
|
||
|
||
@Test("Exactly 36 entries, all unique")
|
||
func shape() {
|
||
#expect(SymbolPickerCatalog.defaultSet.count == 36)
|
||
#expect(Set(SymbolPickerCatalog.defaultSet).count == SymbolPickerCatalog.defaultSet.count)
|
||
}
|
||
|
||
@Test("Every entry is one this system can actually draw — pins the list against typos")
|
||
func everyNameResolves() {
|
||
let missing = SymbolPickerCatalog.defaultSet.filter { !ItemSymbol.exists($0) }
|
||
#expect(missing.isEmpty, "unknown SF Symbol names: \(missing)")
|
||
#expect(SymbolPickerCatalog.available.count == SymbolPickerCatalog.defaultSet.count)
|
||
}
|
||
}
|
||
|
||
@Suite("SymbolPicker ▸ the colour row")
|
||
struct SymbolPickerColorSetTests {
|
||
|
||
@Test("Seven unique tints — the leading None plus these fills the 4×2 grid exactly")
|
||
func shape() {
|
||
#expect(SymbolPickerCatalog.colorSet.count
|
||
== SymbolPickerLayout.colorColumns * SymbolPickerLayout.colorRows - 1)
|
||
#expect(Set(SymbolPickerCatalog.colorSet).count == SymbolPickerCatalog.colorSet.count)
|
||
}
|
||
|
||
@Test("Every entry is a palette name that resolves — pins the list against typos and palette drift")
|
||
func everyNameResolves() {
|
||
let missing = SymbolPickerCatalog.colorSet.filter { Palette.color(named: $0) == nil }
|
||
#expect(missing.isEmpty, "unresolvable palette names: \(missing)")
|
||
}
|
||
}
|
||
|
||
@Suite("SymbolPicker ▸ filter")
|
||
struct SymbolPickerFilterTests {
|
||
|
||
@Test("An empty or whitespace-only query returns the input unchanged")
|
||
func emptyQueryIsANoOp() {
|
||
let symbols = ["star", "flag", "heart"]
|
||
#expect(SymbolPickerCatalog.filter("", in: symbols) == symbols)
|
||
#expect(SymbolPickerCatalog.filter(" ", in: symbols) == symbols)
|
||
#expect(SymbolPickerCatalog.filter("\t\n", in: symbols) == symbols)
|
||
}
|
||
|
||
@Test("A single token matches case-insensitively, as a substring")
|
||
func singleTokenSubstring() {
|
||
let symbols = ["star", "star.fill", "flag", "flag.checkered"]
|
||
#expect(SymbolPickerCatalog.filter("star", in: symbols) == ["star", "star.fill"])
|
||
#expect(SymbolPickerCatalog.filter("STAR", in: symbols) == ["star", "star.fill"])
|
||
#expect(SymbolPickerCatalog.filter("Fla", in: symbols) == ["flag", "flag.checkered"])
|
||
}
|
||
|
||
@Test("Multiple tokens are an AND — every token must appear somewhere in the name")
|
||
func multiTokenIsAnAnd() {
|
||
let symbols = ["wrench.and.screwdriver", "screwdriver", "wrench"]
|
||
#expect(SymbolPickerCatalog.filter("wrench screw", in: symbols) == ["wrench.and.screwdriver"])
|
||
#expect(SymbolPickerCatalog.filter("screw wrench", in: symbols) == ["wrench.and.screwdriver"])
|
||
}
|
||
|
||
@Test("Input order is preserved")
|
||
func orderPreserved() {
|
||
let symbols = ["zebra.star", "apple.star", "mango.star"]
|
||
#expect(SymbolPickerCatalog.filter("star", in: symbols) == symbols)
|
||
}
|
||
|
||
@Test("No match returns an empty array")
|
||
func noMatchIsEmpty() {
|
||
#expect(SymbolPickerCatalog.filter("xyzzy-nonexistent", in: ["star", "flag"]).isEmpty)
|
||
}
|
||
}
|
||
|
||
@Suite("SymbolPicker ▸ the full catalog")
|
||
struct SymbolPickerFullCatalogTests {
|
||
|
||
@Test("The default path returns a sorted, unique, non-empty list containing well-known names")
|
||
func defaultPathLoads() {
|
||
let catalog = SymbolPickerCatalog.fullCatalog()
|
||
#expect(!catalog.isEmpty)
|
||
#expect(catalog == catalog.sorted())
|
||
#expect(Set(catalog).count == catalog.count)
|
||
#expect(catalog.contains("star"))
|
||
#expect(catalog.contains("folder"))
|
||
}
|
||
|
||
@Test("Two calls against the default path return identical results — the cache is coherent")
|
||
func cacheIsCoherent() {
|
||
#expect(SymbolPickerCatalog.fullCatalog() == SymbolPickerCatalog.fullCatalog())
|
||
}
|
||
|
||
@Test("A nonexistent bundle path falls back to the merged curated set, sorted and unique")
|
||
func nonexistentPathFallsBack() {
|
||
// `CuratedSymbols.all` was one flat list; 2026-08-09 split it into three level-specific sets
|
||
// plus `combined`, the union this fallback still reads (`SymbolPicker.swift`'s `load`).
|
||
let expected = Set(SymbolPickerCatalog.defaultSet + CuratedSymbols.combined).sorted()
|
||
#expect(SymbolPickerCatalog.fullCatalog(bundlePath: "/nonexistent") == expected)
|
||
}
|
||
}
|