`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
372 lines
18 KiB
Swift
372 lines
18 KiB
Swift
import Foundation
|
||
import Testing
|
||
@testable import Kanban
|
||
|
||
/// The styling system's rules that need neither a screen nor (mostly) a disk: the per-dimension
|
||
/// mixed state the editor displays, the Style… popover's live target set, the quick-style recents
|
||
/// list, and the curated symbol grid (03-board-ui.md § Styling).
|
||
///
|
||
/// The popover half drives a **real store over a real temp board** for `TransientBoardStateTests`'
|
||
/// reason — the lifecycle's contract includes being re-resolved by the store's reload path, and a
|
||
/// suite that only called `resolved(against:)` by hand could pass with that wire cut.
|
||
|
||
// MARK: - Fixtures
|
||
|
||
private func tombstoned(order: String, title: String) -> String {
|
||
"""
|
||
---
|
||
schema: 1
|
||
title: \(title)
|
||
order: \(order)
|
||
deleted: 2026-03-03T09:00:00Z
|
||
---
|
||
\(title) body.
|
||
|
||
"""
|
||
}
|
||
|
||
@MainActor
|
||
private func makeBoard() throws -> WriterFixture {
|
||
let fixture = try WriterFixture()
|
||
try fixture.item("", Item.board)
|
||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
|
||
return fixture
|
||
}
|
||
|
||
private let lane1 = ItemID(rawValue: Ident.lane1)
|
||
private let lane2 = ItemID(rawValue: Ident.lane2)
|
||
private let card1 = ItemID(rawValue: Ident.card1)
|
||
private let card2 = ItemID(rawValue: Ident.card2)
|
||
private let card3 = ItemID(rawValue: Ident.card3)
|
||
private let card4 = ItemID(rawValue: Ident.card4)
|
||
|
||
@MainActor
|
||
private func reload(_ store: BoardStore) async {
|
||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||
await store.awaitQuiescence()
|
||
}
|
||
|
||
// MARK: - Mixed state
|
||
|
||
@Suite("Styling ▸ mixed state")
|
||
struct StyleFieldStateTests {
|
||
|
||
@Test("Agreement reads uniform, disagreement reads mixed, absence reads unset")
|
||
func theThreeStates() {
|
||
#expect(StyleFieldState.resolve([]) == .unset)
|
||
#expect(StyleFieldState.resolve([.missing, .missing]) == .unset)
|
||
#expect(StyleFieldState.resolve([.valid("fern"), .valid("fern")]) == .uniform("fern"))
|
||
#expect(StyleFieldState.resolve([.valid("fern"), .valid("chalk")]) == .mixed)
|
||
// A set value and an absent one disagree: half the batch is coloured, which is exactly the
|
||
// case "—" exists for.
|
||
#expect(StyleFieldState.resolve([.valid("fern"), .missing]) == .mixed)
|
||
}
|
||
|
||
@Test("An off-palette value is uniform like any other — the display, not this rule, is what differs")
|
||
func offPaletteValuesAreOrdinary() {
|
||
let state = StyleFieldState.resolve([.valid("#112233AA"), .valid("#112233AA")])
|
||
#expect(state == .uniform("#112233AA"))
|
||
#expect(!Palette.backgrounds.contains { $0.name == "#112233AA" },
|
||
"it is the editor's verbatim chip that treats this specially, outside the grids")
|
||
}
|
||
|
||
@Test("A malformed value reads as the bytes on disk, and two of a kind agree")
|
||
func malformedValuesReadVerbatim() {
|
||
#expect(StyleFieldState.written(.malformed(raw: "[a, b]")) == "[a, b]")
|
||
#expect(StyleFieldState.written(.missing) == nil)
|
||
#expect(StyleFieldState.resolve([.malformed(raw: "[a, b]"), .malformed(raw: "[a, b]")]) == .uniform("[a, b]"))
|
||
#expect(StyleFieldState.resolve([.malformed(raw: "[a, b]"), .missing]) == .mixed)
|
||
}
|
||
|
||
@Test("Only a change that would rewrite the same bytes is skipped")
|
||
func noOpNarrowing() {
|
||
#expect(BoardStore.effective(.set("fern"), against: .valid("fern")) == .keep)
|
||
#expect(BoardStore.effective(.set("fern"), against: .valid("chalk")) == .set("fern"))
|
||
#expect(BoardStore.effective(.set("fern"), against: .missing) == .set("fern"))
|
||
// A malformed value is never equal to a palette name, so a well always replaces it.
|
||
#expect(BoardStore.effective(.set("fern"), against: .malformed(raw: "[a, b]")) == .set("fern"))
|
||
#expect(BoardStore.effective(.remove, against: .missing) == .keep)
|
||
#expect(BoardStore.effective(.remove, against: .valid("fern")) == .remove)
|
||
#expect(BoardStore.effective(.remove, against: .malformed(raw: "[a, b]")) == .remove)
|
||
#expect(BoardStore.effective(.keep, against: .valid("fern")) == .keep)
|
||
}
|
||
}
|
||
|
||
// MARK: - The popover's target
|
||
|
||
@MainActor
|
||
@Suite("Styling ▸ the Style… popover's target")
|
||
struct StyleEditorSessionTests {
|
||
|
||
@Test("A vanished member leaves the set; the survivors keep the popover open")
|
||
func vanishedMemberLeavesTheSet() async throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
store.transient.beginStyleEditor(for: .items([card1, card2]))
|
||
|
||
try fixture.move("\(Ident.lane1)/\(Ident.card2)", toTrash: Ident.card2)
|
||
await reload(store)
|
||
|
||
let session = try #require(store.transient.styleEditor)
|
||
#expect(session.target == .items([card1]))
|
||
// And the display recomputes off the survivors, which is the point of narrowing rather than
|
||
// dismissing.
|
||
#expect(store.styleSubjects(of: session.target).map(\.id) == [card1])
|
||
}
|
||
|
||
@Test("A set emptied by a foreign reload dismisses the popover")
|
||
func emptiedSetDismisses() async throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
store.transient.beginStyleEditor(for: .items([card3]))
|
||
|
||
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
|
||
await reload(store)
|
||
|
||
// The card's own flag never changed — its lane's did. Effective liveness is ancestor-walked,
|
||
// so the card renders nowhere and the session has nothing left to style.
|
||
#expect(store.transient.styleEditor == nil, "the editor is closed")
|
||
}
|
||
|
||
@Test("It never silently retargets to the board")
|
||
func neverRetargetsToTheBoard() async throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
store.transient.beginStyleEditor(for: .items([card1]))
|
||
|
||
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
||
await reload(store)
|
||
|
||
#expect(store.transient.styleEditor?.target != .board)
|
||
#expect(store.transient.styleEditor == nil)
|
||
}
|
||
|
||
@Test("A board-targeted editor has no vanish case")
|
||
func boardSessionsSurvive() async throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
store.transient.beginStyleEditor(for: .board)
|
||
|
||
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
|
||
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
|
||
await reload(store)
|
||
|
||
#expect(store.transient.styleEditor?.target == .board)
|
||
}
|
||
|
||
@Test("An unknown id is gone from the start — a session of nothing but strangers closes")
|
||
func unknownIdsResolveAway() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
let stranger = StyleEditorSession(target: .items([card4]))
|
||
#expect(stranger.resolved(against: store.snapshot) == nil)
|
||
|
||
let mixed = StyleEditorSession(target: .items([card4, card1]))
|
||
#expect(mixed.resolved(against: store.snapshot)?.target == .items([card1]))
|
||
}
|
||
|
||
@Test("The presenting anchor is the first live target in display order, board sessions none")
|
||
func anchorFollowsDisplayOrder() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
#expect(StyleEditorSession(target: .board).presentationAnchor(in: store.snapshot) == nil)
|
||
#expect(StyleEditorSession(target: .items([card3, card2])).presentationAnchor(in: store.snapshot) == card2)
|
||
#expect(StyleEditorSession(target: .items([lane2, lane1])).presentationAnchor(in: store.snapshot) == lane1)
|
||
// A lane outranks a card in its own lane — a cards-XOR-lanes selection never mixes the two,
|
||
// but the walk has to be total.
|
||
#expect(StyleEditorSession(target: .items([card1, lane1])).presentationAnchor(in: store.snapshot) == lane1)
|
||
}
|
||
|
||
@Test("The style editor is not an inline editor")
|
||
func notAnInlineEditor() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.transient.beginStyleEditor(for: .board)
|
||
// `isEditingInline` gates every board command; a popover that claimed the text domain would
|
||
// disable the very menu items that opened it.
|
||
#expect(!store.isEditingInline)
|
||
#expect(store.acceptsBoardMutations)
|
||
|
||
store.transient.discardStyleEditor()
|
||
#expect(store.transient.styleEditor == nil)
|
||
}
|
||
}
|
||
|
||
// MARK: - Recents
|
||
|
||
@MainActor
|
||
@Suite("Styling ▸ quick-style recents")
|
||
struct StyleRecentsTests {
|
||
|
||
/// A defaults domain of this test's own — the list is a real user preference, and a suite that
|
||
/// wrote into `UserDefaults.standard` would be editing the developer's own quick-style row.
|
||
private func makeRecents() -> (recents: StyleRecents, teardown: () -> Void) {
|
||
let name = "StyleRecentsTests-\(UUID().uuidString)"
|
||
guard let defaults = UserDefaults(suiteName: name) else {
|
||
Issue.record("could not create a defaults suite")
|
||
return (StyleRecents(defaults: .standard), {})
|
||
}
|
||
return (StyleRecents(defaults: defaults), { defaults.removePersistentDomain(forName: name) })
|
||
}
|
||
|
||
@Test("Most-recent-first, deduped by move-to-front, capped")
|
||
func listRule() {
|
||
#expect(StyleRecents.updated([], with: "fern") == ["fern"])
|
||
#expect(StyleRecents.updated(["fern"], with: "chalk") == ["chalk", "fern"])
|
||
// A repeat is a move, never a second entry.
|
||
#expect(StyleRecents.updated(["chalk", "fern"], with: "fern") == ["fern", "chalk"])
|
||
#expect(StyleRecents.updated(["fern"], with: "fern") == ["fern"])
|
||
|
||
let full = ["a", "b", "c", "d", "e", "f"]
|
||
#expect(StyleRecents.updated(full, with: "g", cap: 6) == ["g", "a", "b", "c", "d", "e"])
|
||
#expect(StyleRecents.updated(full, with: "g", cap: 6).count == 6)
|
||
}
|
||
|
||
@Test("An empty value is not a colour anyone applied")
|
||
func emptyValuesAreIgnored() {
|
||
// The None well is a *removal* — nothing to remember — and the call site never records for
|
||
// it; this is the belt behind that brace.
|
||
#expect(StyleRecents.updated(["fern"], with: "") == ["fern"])
|
||
}
|
||
|
||
@Test("Recording persists, and a fresh instance reads the same list back")
|
||
func recordingRoundTrips() throws {
|
||
let name = "StyleRecentsTests-\(UUID().uuidString)"
|
||
let store = try #require(UserDefaults(suiteName: name))
|
||
defer { store.removePersistentDomain(forName: name) }
|
||
|
||
let recents = StyleRecents(defaults: store)
|
||
recents.record("fern")
|
||
recents.record("chalk")
|
||
recents.record("fern")
|
||
|
||
#expect(recents.backgrounds == ["fern", "chalk"])
|
||
#expect(StyleRecents(defaults: store).backgrounds == ["fern", "chalk"])
|
||
#expect(store.array(forKey: AppPreferences.quickStyleBackgroundsKey) as? [String] == ["fern", "chalk"])
|
||
}
|
||
|
||
@Test("A garbage preference reads as an empty list rather than taking the row down")
|
||
func toleratesGarbage() throws {
|
||
let (recents, teardown) = makeRecents()
|
||
defer { teardown() }
|
||
#expect(recents.backgrounds.isEmpty, "a first launch has no recents and no row")
|
||
|
||
let name = "StyleRecentsTests-garbage-\(UUID().uuidString)"
|
||
let store = try #require(UserDefaults(suiteName: name))
|
||
defer { store.removePersistentDomain(forName: name) }
|
||
store.set(42, forKey: AppPreferences.quickStyleBackgroundsKey)
|
||
#expect(StyleRecents(defaults: store).backgrounds.isEmpty)
|
||
}
|
||
}
|
||
|
||
// MARK: - The curated grid
|
||
|
||
/// **2026-08-09: one flat ~65-entry list split into three level-specific sets** (`boards`, `lanes`,
|
||
/// `cards`) plus `combined`, their stable-order union — this suite pins the new shape per set rather
|
||
/// than the old single-list bounds.
|
||
@Suite("Styling ▸ the curated symbol sets")
|
||
struct CuratedSymbolsTests {
|
||
|
||
@Test("Each level's set is in the ~30–40 range, no duplicates", arguments: [
|
||
CuratedSymbols.boards, CuratedSymbols.lanes, CuratedSymbols.cards,
|
||
])
|
||
func shape(_ set: [String]) {
|
||
#expect(set.count >= 30)
|
||
#expect(set.count <= 40)
|
||
#expect(Set(set).count == set.count)
|
||
}
|
||
|
||
@Test("Every curated name, at every level, is one this system can actually draw", arguments: [
|
||
CuratedSymbols.boards, CuratedSymbols.lanes, CuratedSymbols.cards,
|
||
])
|
||
func everyNameResolves(_ set: [String]) {
|
||
// A curated list is a convenience, never a claim about the running OS — `available(for:)`
|
||
// filters it — but a name that fails here on the *deployment target* is a typo, not an
|
||
// inventory difference, and the grid would show an empty well.
|
||
let missing = set.filter { !ItemSymbol.exists($0) }
|
||
#expect(missing.isEmpty, "unknown SF Symbol names: \(missing)")
|
||
}
|
||
|
||
@Test("available(for:) reads the matching level's set, filtered")
|
||
func availableForLevelMatchesTheSet() {
|
||
#expect(CuratedSymbols.available(for: .board) == CuratedSymbols.availableBoards)
|
||
#expect(CuratedSymbols.available(for: .lane) == CuratedSymbols.availableLanes)
|
||
#expect(CuratedSymbols.available(for: .card) == CuratedSymbols.availableCards)
|
||
// Nothing here fails to resolve, so filtering is a no-op on the deployment target — the same
|
||
// claim `everyNameResolves` makes, restated for the filtered accessors the app actually calls.
|
||
#expect(CuratedSymbols.availableBoards.count == CuratedSymbols.boards.count)
|
||
#expect(CuratedSymbols.availableLanes.count == CuratedSymbols.lanes.count)
|
||
#expect(CuratedSymbols.availableCards.count == CuratedSymbols.cards.count)
|
||
}
|
||
|
||
@Test("Overlap between sets is allowed — a status marker fits at every level")
|
||
func genuineOverlapIsAllowed() {
|
||
for name in ["flag", "star"] {
|
||
#expect(CuratedSymbols.boards.contains(name))
|
||
#expect(CuratedSymbols.lanes.contains(name))
|
||
#expect(CuratedSymbols.cards.contains(name))
|
||
}
|
||
}
|
||
|
||
@Test("combined is the three sets' union, stable order, deduplicated")
|
||
func combinedIsTheStableUnion() {
|
||
let combined = CuratedSymbols.combined
|
||
#expect(Set(combined).count == combined.count, "no duplicate across the three sets")
|
||
#expect(Set(combined) == Set(CuratedSymbols.boards + CuratedSymbols.lanes + CuratedSymbols.cards))
|
||
// Stable order: boards' own members appear in `combined` in the same relative order boards
|
||
// itself lists them, and the first new (non-boards) name is the first lanes-only entry.
|
||
let boardsOnly = combined.filter(CuratedSymbols.boards.contains)
|
||
#expect(boardsOnly == CuratedSymbols.boards)
|
||
#expect(CuratedSymbols.availableCombined.count == combined.filter(ItemSymbol.exists).count)
|
||
}
|
||
|
||
@Test("The level defaults are drawable too — they are the grid's leading well")
|
||
func levelDefaultsResolve() {
|
||
#expect(ItemSymbol.exists(ItemSymbol.board))
|
||
#expect(ItemSymbol.exists(ItemSymbol.lane))
|
||
#expect(ItemSymbol.exists(ItemSymbol.card))
|
||
}
|
||
|
||
// MARK: - Context wiring
|
||
|
||
/// **The style editor's grid content**: one level's set for a homogeneous target, the combined set
|
||
/// for one that spans levels — `StyleEditorView.curatedSymbols`' whole decision, pulled out as
|
||
/// `CuratedSymbols.availableForStyleEditor` so it is testable without a view
|
||
/// (`BoardStore.styleTargetSpansLevels` supplies the second argument from a real target in
|
||
/// `StyleWriteTests.spansLevelsDetectsAGenuineMix`).
|
||
@Test("The style editor's grid: a homogeneous target reads its own level's set, a spanning target reads the combined set")
|
||
func styleEditorGridChoosesByLevelOrSpan() {
|
||
#expect(CuratedSymbols.availableForStyleEditor(level: .board, spansLevels: false) == CuratedSymbols.availableBoards)
|
||
#expect(CuratedSymbols.availableForStyleEditor(level: .lane, spansLevels: false) == CuratedSymbols.availableLanes)
|
||
#expect(CuratedSymbols.availableForStyleEditor(level: .card, spansLevels: false) == CuratedSymbols.availableCards)
|
||
// `spansLevels` wins regardless of which level `styleLevel(of:)` collapsed to — a spanning
|
||
// target must never read as a single level's narrower set.
|
||
#expect(CuratedSymbols.availableForStyleEditor(level: .card, spansLevels: true) == CuratedSymbols.availableCombined)
|
||
#expect(CuratedSymbols.availableForStyleEditor(level: .lane, spansLevels: true) == CuratedSymbols.availableCombined)
|
||
}
|
||
|
||
/// **`BoardInfoPopover`'s board glyph** passes a fixed board-level set rather than asking
|
||
/// `styleLevel` at all — there is no target to ask, since this anchor only ever styles the board.
|
||
/// The tripwire is the same one `CardSidebarTests.theCuratedSetPassedToTheSidebarPickerDoesNotShrinkIt`
|
||
/// pins for the card anchor: the set stays wider than the picker's own domain-agnostic default, so
|
||
/// the anchor's explicit `symbols:` argument can never quietly become a no-op.
|
||
@Test("The board glyph's set stays wider than the picker's own default")
|
||
func boardAnchorSetStaysWiderThanThePickerDefault() {
|
||
#expect(CuratedSymbols.availableBoards.count > SymbolPickerCatalog.defaultSet.count)
|
||
}
|
||
}
|