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")) try fixture.item(Ident.lane3, Item.uneditable) try fixture.item(Ident.lane4, styled(order: "4096", title: "Gone")) 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 { // `kind:` — the on-touch backfill rides every app rewrite of a file that lacks one. !$0.hasPrefix("modified") && !$0.hasPrefix("background:") && !$0.hasPrefix("icon:") && !$0.hasPrefix("kind:") } } /// 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 and trashed targets are skipped silently") func skipsTargetsThatRenderNowhere() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try fixture.move("\(Ident.lane2)/\(Ident.card3)", toTrash: Ident.card3) try FileManager.default.removeItem(at: fixture.url(Ident.lane4)) let store = try BoardStore(rootURL: fixture.root) let log = BracketLog() log.attach(to: store) let trashedBytes = try fixture.indexData(".trash/\(Ident.card3)") // An id that names nothing, and a card that has been moved to the trash — "everything // edit-shaped is disabled on trash selections" (04 ▸ The trash), and "nothing is ever // written into a vanished folder". store.applyStyle( to: .items([ItemID(rawValue: Ident.indexless), card3, card4]), background: .set("obsidian") ) #expect(log.begins == 0) #expect(try fixture.indexData(".trash/\(Ident.card3)") == trashedBytes) #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 board's targets in display order, with their current values") func subjectsAreBoardTargetsInDisplayOrder() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let subjects = store.styleSubjects(of: .items([card2, card1, 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) } }