Build the styling system and shared style editor
One style-editor component, anchor-agnostic: a background grid (None well plus the 12 palette colors) and a curated symbol grid (the pathfinder's five-dozen set, leading well removing the icon key for the level default), selection-aware across cards, lanes, and the board itself. Batch edits compute per-dimension state — uniform, mixed (no well selected), or an off-palette value labeled verbatim outside the grids — and choosing a well applies to the whole target set as one write bracket, skipping no-ops per field. The popover tracks its target set live per the freshly ratified rule: targets re-resolve by UUID on every reload, a vanished target leaves the set, an emptied set dismisses the editor, and nothing ever silently retargets to the board. Anchors landing now: Board > Style (Opt-Cmd-S) and the card/lane context menus, which also carry the quick-style recents row (app-wide, persisted, capped at six, None never recorded) and the lane's width control twinning the menu chords. The styling system's other two renders arrive with it: a lane's background paints the C7 top-edge band, the board's paints the window content background — malformed values paint nothing and stay byte-identical on disk. 31 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
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.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second"))
|
||||
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 fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
|
||||
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.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First"))
|
||||
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 fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
|
||||
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
|
||||
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
|
||||
|
||||
@Suite("Styling ▸ the curated symbol grid")
|
||||
struct CuratedSymbolsTests {
|
||||
|
||||
@Test("Roughly five dozen symbols, no duplicates")
|
||||
func shape() {
|
||||
#expect(CuratedSymbols.all.count >= 55)
|
||||
#expect(CuratedSymbols.all.count <= 72)
|
||||
#expect(Set(CuratedSymbols.all).count == CuratedSymbols.all.count)
|
||||
}
|
||||
|
||||
@Test("Every curated name is one this system can actually draw")
|
||||
func everyNameResolves() {
|
||||
// A curated list is a convenience, never a claim about the running OS — `available` 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 = CuratedSymbols.all.filter { !ItemSymbol.exists($0) }
|
||||
#expect(missing.isEmpty, "unknown SF Symbol names: \(missing)")
|
||||
#expect(CuratedSymbols.available.count == CuratedSymbols.all.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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user