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
350 lines
16 KiB
Swift
350 lines
16 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// `BoardStore.applyStyle` — the one commit point every style anchor shares (03-board-ui.md §
|
|
/// Styling ▸ Controls).
|
|
///
|
|
/// Like `LaneWidthWriteTests` and `InlineEditWriteTests`, these drive a real store over a real temp
|
|
/// board and read the **raw bytes** back rather than the app's own read path: the claims are about
|
|
/// the file — which key lands or leaves, what the stamps do, and everything else surviving
|
|
/// byte-for-byte. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
|
|
|
|
// MARK: - Fixtures
|
|
|
|
/// An item index carrying whatever style keys a test needs, plus the usual unowned baggage: an
|
|
/// unknown key with an inline comment, a `created` from before today, a foreign `modified-by`, and a
|
|
/// body — all of which a style write has to leave exactly as it found them.
|
|
private func styled(order: String, title: String, keys: [String] = []) -> String {
|
|
let extra = keys.map { "\($0)\n" }.joined()
|
|
return """
|
|
---
|
|
schema: 1
|
|
title: \(title)
|
|
order: \(order)
|
|
\(extra)project: lanework # agent overlay
|
|
created: 2026-01-01T09:00:00Z
|
|
modified: 2026-02-02T09:00:00Z
|
|
modified-by: claude
|
|
---
|
|
\(title) body.
|
|
|
|
"""
|
|
}
|
|
|
|
/// The board root's own `index.md` — styled like any other item (`StyleTarget.board`), and carrying
|
|
/// an `iconColor` the app must never touch (schema yes, control no).
|
|
private let boardIndex = """
|
|
---
|
|
schema: 1
|
|
title: Board
|
|
iconColor: carnation
|
|
---
|
|
Board description.
|
|
|
|
"""
|
|
|
|
/// Two live lanes with cards, an uneditable lane, and a tombstoned lane with a live card inside it —
|
|
/// the ancestor-walk case.
|
|
@MainActor
|
|
private func makeBoard() throws -> WriterFixture {
|
|
let fixture = try WriterFixture()
|
|
try fixture.item("", boardIndex)
|
|
try fixture.item(Ident.lane1, styled(order: "1024", title: "Todo"))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", styled(order: "1024", title: "First", keys: ["background: fern", "iconColor: chalk"]))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", styled(order: "2048", title: "Second"))
|
|
try fixture.item(Ident.lane2, styled(order: "2048", title: "Doing", keys: ["background: chalk", "icon: tray"]))
|
|
try fixture.item("\(Ident.lane2)/\(Ident.card3)", styled(order: "1024", title: "Third", keys: ["deleted: 2026-03-03T09:00:00Z"]))
|
|
try fixture.item(Ident.lane3, Item.uneditable)
|
|
try fixture.item(Ident.lane4, styled(order: "4096", title: "Gone", keys: ["deleted: 2026-03-03T09:00:00Z"]))
|
|
try fixture.item("\(Ident.lane4)/\(Ident.card4)", styled(order: "1024", title: "Hidden"))
|
|
return fixture
|
|
}
|
|
|
|
private let lane1 = ItemID(rawValue: Ident.lane1)
|
|
private let lane2 = ItemID(rawValue: Ident.lane2)
|
|
private let lane3 = ItemID(rawValue: Ident.lane3)
|
|
private let lane4 = ItemID(rawValue: Ident.lane4)
|
|
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)
|
|
|
|
/// The file's lines minus the ones a style write is *supposed* to change. `iconColor:` deliberately
|
|
/// survives the filter — it is not `icon:`, and the app offers no control for it.
|
|
private func untouchedLines(_ text: String) -> [Substring] {
|
|
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
|
|
!$0.hasPrefix("modified") && !$0.hasPrefix("background:") && !$0.hasPrefix("icon:")
|
|
}
|
|
}
|
|
|
|
/// Counts the bracket calls a store makes, standing in for the watcher the registry wires up — the
|
|
/// only way to assert "one gesture, one commit" from outside.
|
|
@MainActor
|
|
private final class BracketLog {
|
|
private(set) var begins = 0
|
|
private(set) var ends = 0
|
|
|
|
func attach(to store: BoardStore) {
|
|
store.watcherBrackets = (begin: { self.begins += 1 }, end: { self.ends += 1 })
|
|
}
|
|
}
|
|
|
|
private func load(_ fixture: WriterFixture) throws -> BoardModel {
|
|
try BoardLoader.load(boardRoot: fixture.root).model
|
|
}
|
|
|
|
private func card(_ id: ItemID, in model: BoardModel) -> Card? {
|
|
model.lanes.flatMap(\.cards).first { $0.id == id }
|
|
}
|
|
|
|
private func lane(_ id: ItemID, in model: BoardModel) -> Lane? {
|
|
model.lanes.first { $0.id == id }
|
|
}
|
|
|
|
// MARK: - Tests
|
|
|
|
@MainActor
|
|
@Suite("BoardStore ▸ applyStyle")
|
|
struct StyleWriteTests {
|
|
|
|
@Test("Setting a background writes exactly that key, stamps, and touches nothing else")
|
|
func setsTheBackgroundKey() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
|
|
|
|
store.applyStyle(to: .items([card2]), background: .set("smokey-ocean"))
|
|
|
|
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
|
|
#expect(after.contains("background: smokey-ocean"))
|
|
#expect(!after.contains("icon:"), "the untouched dimension writes no key at all")
|
|
#expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
|
|
#expect(untouchedLines(after) == untouchedLines(before))
|
|
|
|
let written = try #require(card(card2, in: load(fixture)))
|
|
#expect(written.background == .valid("smokey-ocean"))
|
|
let modified = try #require(written.modified.value)
|
|
#expect(abs(modified.timeIntervalSinceNow) < 60)
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
@Test("Both dimensions land in one rewrite, and iconColor is never touched")
|
|
func setsBothDimensionsAtOnce() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.applyStyle(to: .items([card1]), background: .set("dark-teal"), icon: .set("flag"))
|
|
|
|
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
|
#expect(after.contains("background: dark-teal"))
|
|
#expect(after.contains("icon: flag"))
|
|
// "iconColor: resolved — schema yes, control no" (03 § Styling ▸ Capabilities): the field
|
|
// renders when hand-written and the app offers no control for it, so a style write must
|
|
// carry it through untouched like any unknown key.
|
|
#expect(after.contains("iconColor: chalk"))
|
|
#expect(!after.contains("background: fern"), "the old value is replaced, not duplicated")
|
|
}
|
|
|
|
@Test("The None and default wells remove their key rather than writing a blank value")
|
|
func removesTheKey() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.applyStyle(to: .items([lane2]), background: .remove, icon: .remove)
|
|
|
|
let after = try fixture.indexText(Ident.lane2)
|
|
#expect(!after.contains("background"))
|
|
#expect(!after.contains("icon"))
|
|
#expect(!after.contains("\"\""), "a removal is a missing key, never an empty string")
|
|
|
|
let written = try #require(lane(lane2, in: load(fixture)))
|
|
#expect(written.background.isMissing)
|
|
#expect(written.icon.isMissing)
|
|
}
|
|
|
|
@Test("A batch rewrites every target inside a single bracket")
|
|
func batchesInOneBracket() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let log = BracketLog()
|
|
log.attach(to: store)
|
|
|
|
// "Choosing a well applies to the whole selection — one gesture, one commit on git boards"
|
|
// (03 § Styling ▸ Controls): the churn has to round back as ONE app-mediated reload.
|
|
store.applyStyle(to: .items([card1, card2]), background: .set("light-cayenne"))
|
|
|
|
#expect(log.begins == 1)
|
|
#expect(log.ends == 1)
|
|
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)").contains("background: light-cayenne"))
|
|
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: light-cayenne"))
|
|
}
|
|
|
|
@Test("A value a target already carries writes nothing — per target and per dimension")
|
|
func skipsNoOps() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let log = BracketLog()
|
|
log.attach(to: store)
|
|
let untouchedCard = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
|
|
|
// `card1` is already `fern` and `card2` has no background at all: only the second file may
|
|
// move. A well clicked twice must not stamp `modified` or mint a commit on what was already
|
|
// right (`setLaneWidth`'s rule).
|
|
store.applyStyle(to: .items([card1, card2]), background: .set("fern"))
|
|
|
|
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == untouchedCard)
|
|
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: fern"))
|
|
#expect(log.begins == 1, "the batch still opens exactly one bracket for the target that moved")
|
|
}
|
|
|
|
@Test("A gesture that changes nothing anywhere opens no bracket at all")
|
|
func wholeGestureNoOpWritesNothing() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let log = BracketLog()
|
|
log.attach(to: store)
|
|
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
|
|
|
// Both dimensions already read this way: `background: fern` is set and `icon` is absent, so
|
|
// the removal is a no-op too.
|
|
store.applyStyle(to: .items([card1]), background: .set("fern"), icon: .remove)
|
|
|
|
#expect(log.begins == 0)
|
|
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
|
|
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)") == ["index.md"], "no temp-file residue either")
|
|
}
|
|
|
|
@Test("A malformed value is replaced — choosing a well always wins")
|
|
func replacesAMalformedValue() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", styled(order: "2048", title: "Second", keys: ["background: [a, b]"]))
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
#expect(card(card2, in: store.snapshot)?.background == .malformed(raw: "[a, b]"))
|
|
|
|
store.applyStyle(to: .items([card2]), background: .set("shale"))
|
|
|
|
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
|
|
#expect(after.contains("background: shale"))
|
|
#expect(!after.contains("[a, b]"))
|
|
}
|
|
|
|
@Test("Board styling writes the board root's own index.md")
|
|
func stylesTheBoardRoot() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
store.applyStyle(to: .board, background: .set("intense-cool-shale"), icon: .set("square.stack"))
|
|
|
|
let after = try fixture.indexText("")
|
|
#expect(after.contains("background: intense-cool-shale"))
|
|
#expect(after.contains("icon: square.stack"))
|
|
#expect(after.contains("iconColor: carnation"))
|
|
#expect(after.contains("Board description."))
|
|
|
|
let model = try load(fixture)
|
|
#expect(model.background == .valid("intense-cool-shale"))
|
|
// The lanes are none of a board-level gesture's business.
|
|
#expect(lane(lane1, in: model)?.background.isMissing == true)
|
|
}
|
|
|
|
@Test("Vanished, tombstoned and hidden targets are skipped silently")
|
|
func skipsTargetsThatRenderNowhere() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let log = BracketLog()
|
|
log.attach(to: store)
|
|
let tombstoned = try fixture.indexData("\(Ident.lane2)/\(Ident.card3)")
|
|
let hidden = try fixture.indexData("\(Ident.lane4)/\(Ident.card4)")
|
|
|
|
// An id that names nothing, a tombstoned card, and a live card under a tombstoned lane —
|
|
// "nothing is ever written into a vanished folder", ancestor walk included.
|
|
store.applyStyle(
|
|
to: .items([ItemID(rawValue: Ident.indexless), card3, card4]),
|
|
background: .set("obsidian")
|
|
)
|
|
|
|
#expect(log.begins == 0)
|
|
#expect(try fixture.indexData("\(Ident.lane2)/\(Ident.card3)") == tombstoned)
|
|
#expect(try fixture.indexData("\(Ident.lane4)/\(Ident.card4)") == hidden)
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
}
|
|
|
|
@Test("A readable-but-uneditable target refuses the write, banners it, and keeps its bytes")
|
|
func uneditableTargetBanners() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let before = try fixture.indexData(Ident.lane3)
|
|
|
|
store.applyStyle(to: .items([lane3]), background: .set("fern"))
|
|
|
|
#expect(try fixture.indexData(Ident.lane3) == before)
|
|
#expect(store.banners.oneShots.count == 1)
|
|
let posted = try #require(store.banners.oneShots.first)
|
|
#expect(posted.error.operation == .style(title: "Odd"),
|
|
"the title is enriched off the document the write refused")
|
|
#expect(posted.error.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
|
#expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't restyle 'Odd' — "))
|
|
}
|
|
|
|
@Test("A read-only board refuses the write without a second banner")
|
|
func readOnlyBoardRefusesQuietly() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
store.enterVanishedRootLock()
|
|
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
|
|
|
store.applyStyle(to: .items([card1]), background: .set("obsidian"))
|
|
|
|
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
|
|
#expect(store.banners.oneShots.isEmpty, "the lock row is already standing")
|
|
#expect(store.bannerRows.contains { $0.id == "read-only-lock" })
|
|
}
|
|
|
|
// MARK: Subjects and levels
|
|
|
|
@Test("Subjects are the live targets in display order, with their current values")
|
|
func subjectsAreLiveTargetsInDisplayOrder() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
let subjects = store.styleSubjects(of: .items([card2, card1, card3, lane1]))
|
|
#expect(subjects.map(\.id) == [lane1, card1, card2], "lane first, then its cards top to bottom")
|
|
#expect(subjects.map(\.background) == [.missing, .valid("fern"), .missing])
|
|
#expect(subjects.last?.folder.lastPathComponent == Ident.card2)
|
|
|
|
// The board is always exactly one subject, at the root.
|
|
let board = store.styleSubjects(of: .board)
|
|
#expect(board.count == 1)
|
|
let boardSubject = try #require(board.first)
|
|
#expect(boardSubject.id == nil, "a board root has no ItemID by design")
|
|
#expect(boardSubject.folder == fixture.root)
|
|
}
|
|
|
|
@Test("The level a target sits at decides the symbol grid's leading well")
|
|
func levelFollowsTheTarget() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
#expect(store.styleLevel(of: .board) == .board)
|
|
#expect(store.styleLevel(of: .items([lane1, lane2])) == .lane)
|
|
#expect(store.styleLevel(of: .items([card1])) == .card)
|
|
#expect(ItemSymbol.default(for: store.styleLevel(of: .items([card1]))) == ItemSymbol.card)
|
|
#expect(ItemSymbol.default(for: store.styleLevel(of: .items([lane1]))) == ItemSymbol.lane)
|
|
#expect(ItemSymbol.default(for: .board) == ItemSymbol.board)
|
|
}
|
|
}
|