Files
lanework/KanbanTests/StyleModelTests.swift
T
rzen db863ba011 Three curated symbol vocabularies — boards, lanes, and cards each pick from their own shelf
`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
2026-08-09 01:26:36 -04:00

372 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ~3040 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)
}
}