import Foundation import Testing @testable import Kanban /// `BoardStore.setLaneWidth` — the one commit point both width mechanisms share (03-board-ui.md § /// Lane): the right-edge drag's release and the ⌥⌘→/⌥⌘← stepper. /// /// These drive a real store over a real temp board and then read the **raw bytes** back, never the /// app's own read path, because the interesting claims are about the file: the integer that lands, /// the stamps that follow it, and everything else surviving byte-for-byte. `WriterFixture`, `Ident` /// and `Item` come from `WriterTestSupport.swift`, as they do for every other write suite. // MARK: - Fixtures /// A lane index carrying an explicit `width:` — `Item.rich` deliberately has none, and half of what /// is under test here is what happens to a value that is already there (valid or not). private func laneText(order: String, title: String, width: String) -> String { """ --- schema: 1 title: \(title) order: \(order) width: \(width) project: lanework # agent overlay created: 2026-01-01T09:00:00Z modified: 2026-02-02T09:00:00Z modified-by: claude --- \(title) body. """ } /// A board with one plain lane (no `width`), one already at 3×, and one whose frontmatter is /// readable but uneditable. @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.lane2, laneText(order: "2048", title: "Doing", width: "3")) try fixture.item(Ident.lane3, Item.uneditable) return fixture } /// The file's lines minus the ones an app-mediated write is *supposed* to change — what must come /// through a resize byte-for-byte, in order. private func untouchedLines(_ text: String) -> [Substring] { text.split(separator: "\n", omittingEmptySubsequences: false).filter { !$0.hasPrefix("modified") && !$0.hasPrefix("width:") } } private func loadedLane(_ id: String, in fixture: WriterFixture) throws -> Lane { let model = try BoardLoader.load(boardRoot: fixture.root).model return try #require(model.lanes.first { $0.id.rawValue == id }) } // MARK: - Tests @MainActor @Suite("BoardStore ▸ lane width") struct LaneWidthWriteTests { @Test("A width change writes the integer, stamps modified, clears modified-by, and touches nothing else") func writesTheIntegerAndStamps() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let before = try fixture.indexText(Ident.lane1) store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 3) let after = try fixture.indexText(Ident.lane1) #expect(after.contains("width: 3")) #expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution") #expect(!after.contains("modified: 2026-02-02T09:00:00Z"), "the stamp is fresh") // Everything the write does not own survives exactly, in order: the unknown key with its // inline comment, the reserved `labels`, the original `created`, and the body. #expect(untouchedLines(after) == untouchedLines(before)) let lane = try loadedLane(Ident.lane1, in: fixture) #expect(lane.width == .valid(3)) #expect(lane.modifiedBy.isMissing) let modified = try #require(lane.modified.value) #expect(abs(modified.timeIntervalSinceNow) < 60, "modified is stamped with the time of the write") #expect(store.banners.oneShots.isEmpty) } @Test("A width change replaces whatever was there — a malformed value included") func replacesAMalformedValue() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try fixture.item(Ident.lane1, laneText(order: "1024", title: "Todo", width: "wide")) let store = try BoardStore(rootURL: fixture.root) // `width: wide` is lenient on the read side — it renders as one unit with the bytes left // alone (01-storage-format.md § Frontmatter). An explicit change is the user overwriting // it, so the Writer puts a plain integer in its place. #expect(store.snapshot.lanes.first { $0.id.rawValue == Ident.lane1 }?.width == .malformed(raw: "wide")) store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 2) let after = try fixture.indexText(Ident.lane1) #expect(after.contains("width: 2")) #expect(!after.contains("wide")) #expect(try loadedLane(Ident.lane1, in: fixture).width == .valid(2)) } @Test("A count below one clamps to one rather than writing a value the read side would reject") func clampsBelowOne() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) // Lane two is at 3×, so a clamped 1 is a real change and actually reaches disk. store.setLaneWidth(ItemID(rawValue: Ident.lane2), units: 0) #expect(try loadedLane(Ident.lane2, in: fixture).width == .valid(1)) try fixture.item(Ident.lane2, laneText(order: "2048", title: "Doing", width: "3")) let reopened = try BoardStore(rootURL: fixture.root) reopened.setLaneWidth(ItemID(rawValue: Ident.lane2), units: -7) #expect(try loadedLane(Ident.lane2, in: fixture).width == .valid(1)) } @Test("A lane that is not in the snapshot is a no-op") func unknownLaneIsANoOp() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let before = try fixture.indexData(Ident.lane1) // The lane vanished under the gesture (or never existed): the reload that removed it is the // authority, and inventing a file here would be the app disagreeing with disk. store.setLaneWidth(ItemID(rawValue: Ident.indexless), units: 3) #expect(!fixture.exists(Ident.indexless)) #expect(try fixture.indexData(Ident.lane1) == before) #expect(store.banners.oneShots.isEmpty) } @Test("Setting the width a lane already displays writes nothing at all") func unchangedWidthIsANoOp() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let atThree = try fixture.indexData(Ident.lane2) let atOne = try fixture.indexData(Ident.lane1) // A drag that ends where it started, and a stepper pressed against its floor: neither may // stamp `modified` or (on a git board) mint a commit. store.setLaneWidth(ItemID(rawValue: Ident.lane2), units: 3) #expect(try fixture.indexData(Ident.lane2) == atThree) // Lane one has no `width` key at all, so it *displays* one unit — setting one unit is the // same no-op, and must not materialize the key. store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 1) #expect(try fixture.indexData(Ident.lane1) == atOne) #expect(try fixture.entryNames(Ident.lane1) == ["index.md"], "no temp-file residue either") } @Test("A readable-but-uneditable lane refuses the write, banners it, and keeps its bytes") func uneditableLaneBannersAndChangesNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let before = try fixture.indexData(Ident.lane3) store.setLaneWidth(ItemID(rawValue: Ident.lane3), units: 2) // The refusal is the settled readable-but-uneditable rule: the file loads and renders, but // a surgical edit of it cannot be expressed, so nothing is written. #expect(try fixture.indexData(Ident.lane3) == before) #expect(try fixture.indexText(Ident.lane3) == Item.uneditable) // `performWrite` posts before it rethrows, and `setLaneWidth` swallows the rethrow — the // banner is the only thing that says the gesture did not happen, so it must be there. #expect(store.banners.oneShots.count == 1) let posted = try #require(store.banners.oneShots.first) #expect(posted.error.operation == .resize(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 resize 'Odd' — ")) #expect(store.bannerRows.contains { $0.id == "one-shot:\(posted.id.uuidString)" }) } @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) store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 4) // The lock row is already standing; a refusal per gesture would bury it under echoes of // itself (02-architecture.md § Write-failure surfacing). #expect(try fixture.indexData(Ident.lane1) == before) #expect(store.banners.oneShots.isEmpty) #expect(store.bannerRows.contains { $0.id == "read-only-lock" }) } }