import CoreGraphics import Foundation import Testing @testable import Kanban /// `BoardStore.setLaneCollapsed` — the one commit point every collapse gesture shares (03-board-ui.md /// § Lane ▸ Collapsed lanes): the header chevron, the context menu's Collapse/Expand Lane row, and a /// click on the collapsed strip's body. /// /// `LaneWidthWriteTests`' suite, one key over, and deliberately its twin: collapse is document state /// exactly as width is, so the claims worth making are the same claims — the value that lands, the /// stamps that follow it, the remove-at-default rule, and everything else surviving byte-for-byte. Real /// stores over real temp boards, read back as **raw bytes** rather than through the app's own read path. /// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`. // MARK: - Fixtures /// A lane index carrying an explicit `collapsed:` beside a `width:` — half of what is under test is /// what happens to values that are already there, and the other half is that `width` is not one of them. private func laneText(order: String, title: String, width: String?, collapsed: String?) -> String { let widthLine = width.map { "width: \($0)\n" } ?? "" let collapsedLine = collapsed.map { "collapsed: \($0)\n" } ?? "" return """ --- schema: 1 title: \(title) order: \(order) \(widthLine)\(collapsedLine)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, one already folded 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, laneText(order: "1024", title: "Todo", width: "2", collapsed: nil)) try fixture.item(Ident.lane2, laneText(order: "2048", title: "Doing", width: "3", collapsed: "true")) 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 fold byte-for-byte, in order. `width:` is deliberately **not** in the exclusion list: the /// whole point is that a collapse leaves it alone. 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("collapsed:") && !$0.hasPrefix("kind:") } } 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 collapse") struct LaneCollapseWriteTests { @Test("A collapse writes `collapsed: true`, stamps modified, clears modified-by, and touches nothing else") func collapseWritesTheKeyAndStamps() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let before = try fixture.indexText(Ident.lane1) store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: true) let after = try fixture.indexText(Ident.lane1) #expect(after.contains("collapsed: true")) #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 — **`width: 2` included**, which // is the whole of "expanding restores the lane the user had". #expect(untouchedLines(after) == untouchedLines(before)) #expect(after.contains("width: 2")) let lane = try loadedLane(Ident.lane1, in: fixture) #expect(lane.collapsed == .valid(true)) #expect(lane.width == .valid(2), "the fold does not read or rewrite the width key") #expect(LaneLayoutMath.isCollapsed(lane)) #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("Expanding removes the key rather than writing `false` — the remove-at-default family") func expandRemovesTheKey() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.setLaneCollapsed(ItemID(rawValue: Ident.lane2), collapsed: false) let after = try fixture.indexText(Ident.lane2) #expect(!after.contains("collapsed"), "a default lane's frontmatter stays clean") #expect(!after.contains("false")) let lane = try loadedLane(Ident.lane2, in: fixture) #expect(lane.collapsed.isMissing) #expect(!LaneLayoutMath.isCollapsed(lane)) // And the preserved width is what the lane comes back as. #expect(lane.width == .valid(3)) #expect(after.contains("width: 3")) } @Test("A fold survives a width change, and a width change survives a fold") func theTwoKeysAreIndependent() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) // The stepper still edits the preserved key while the lane is folded — 03's "the width stepper // and ⌥⌘→/⌥⌘← still edit the preserved key, which is what the lane will be when it unfolds". store.setLaneWidth(ItemID(rawValue: Ident.lane2), units: 5) let afterResize = try loadedLane(Ident.lane2, in: fixture) #expect(afterResize.width == .valid(5)) #expect(afterResize.collapsed == .valid(true), "resizing a folded lane does not unfold it") let reopened = try BoardStore(rootURL: fixture.root) reopened.setLaneCollapsed(ItemID(rawValue: Ident.lane2), collapsed: false) #expect(try loadedLane(Ident.lane2, in: fixture).width == .valid(5)) } @Test("Setting the state a lane already reads writes nothing at all") func unchangedStateIsANoOp() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let folded = try fixture.indexData(Ident.lane2) let expanded = try fixture.indexData(Ident.lane1) // Neither may stamp `modified`: a second Collapse on a folded lane, and an Expand on a lane // that has no key to remove. store.setLaneCollapsed(ItemID(rawValue: Ident.lane2), collapsed: true) #expect(try fixture.indexData(Ident.lane2) == folded) store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: false) #expect(try fixture.indexData(Ident.lane1) == expanded) #expect(try fixture.entryNames(Ident.lane1) == ["index.md"], "no temp-file residue either") } @Test("A hand-written `collapsed: false` reads as expanded and is preserved until the app edits it") func handWrittenFalseIsPreserved() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try fixture.item(Ident.lane1, laneText(order: "1024", title: "Todo", width: nil, collapsed: "false")) let store = try BoardStore(rootURL: fixture.root) let before = try fixture.indexData(Ident.lane1) #expect(!LaneLayoutMath.isCollapsed(try loadedLane(Ident.lane1, in: fixture))) // Reads as expanded, asked to expand: the unchanged-state guard fires before the // remove-at-default rule can, so the legal hand edit stays byte-for-byte — `width: 1`'s rule. store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: false) #expect(try fixture.indexData(Ident.lane1) == before) // A real change replaces it with the one shape the app writes. store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: true) let after = try fixture.indexText(Ident.lane1) #expect(after.contains("collapsed: true")) #expect(!after.contains("collapsed: false")) } @Test("A value with no boolean reading is replaced on collapse and removed on expand") func aMalformedValueIsOverwritten() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try fixture.item(Ident.lane1, laneText(order: "1024", title: "Todo", width: nil, collapsed: "maybe")) let store = try BoardStore(rootURL: fixture.root) // Lenient on the read side — renders expanded with the bytes left alone — so a collapse is a // real change and overwrites it. #expect(store.snapshot.lanes.first { $0.id.rawValue == Ident.lane1 }?.collapsed == .malformed(raw: "maybe")) store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: true) let after = try fixture.indexText(Ident.lane1) #expect(after.contains("collapsed: true")) #expect(!after.contains("maybe")) #expect(try loadedLane(Ident.lane1, in: fixture).collapsed == .valid(true)) } @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.setLaneCollapsed(ItemID(rawValue: Ident.indexless), collapsed: true) #expect(!fixture.exists(Ident.indexless)) #expect(try fixture.indexData(Ident.lane1) == before) #expect(store.banners.oneShots.isEmpty) } @Test("Collapsing a lane whose card is selected selects the lane") func collapsingReSelectsTheLane() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.board() try fixture.lane(Ident.lane1, order: "1024", title: "Todo") try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "First") try fixture.lane(Ident.lane2, order: "2048", title: "Doing") let store = try BoardStore(rootURL: fixture.root) let lane = ItemID(rawValue: Ident.lane1) let card = ItemID(rawValue: Ident.card1) store.select([card], in: .board) store.setLaneCollapsed(lane, collapsed: true) // The card stops being rendered, so a selection naming it would leave the arrows with no frame // to step from — the lane the user just folded is what they are holding instead. #expect(store.selection.ids == [lane]) #expect(store.selection.container == .board) // A selection elsewhere is untouched, and so is one of the lane itself. let other = ItemID(rawValue: Ident.lane2) store.select([other], in: .board) store.setLaneCollapsed(lane, collapsed: false) #expect(store.selection.ids == [other], "expanding never touches the selection") } @Test("Undo puts the fold back, and each direction names itself") func undoAndRedoRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let history = NativeHistoryProvider() store.history = history let lane = ItemID(rawValue: Ident.lane1) store.setLaneCollapsed(lane, collapsed: true) #expect(try loadedLane(Ident.lane1, in: fixture).collapsed == .valid(true)) #expect(history.undoActionName == "Collapse Lane") history.undo() #expect(try loadedLane(Ident.lane1, in: fixture).collapsed.isMissing, "the inverse removes the key rather than writing `false`") #expect(try loadedLane(Ident.lane1, in: fixture).width == .valid(2), "and it never touched the width") history.redo() #expect(try loadedLane(Ident.lane1, in: fixture).collapsed == .valid(true)) // The other direction names itself: an expand's row must not read "Collapse". let reopened = try BoardStore(rootURL: fixture.root) let reopenedHistory = NativeHistoryProvider() reopened.history = reopenedHistory reopened.setLaneCollapsed(ItemID(rawValue: Ident.lane2), collapsed: false) #expect(reopenedHistory.undoActionName == "Expand Lane") reopenedHistory.undo() #expect(try loadedLane(Ident.lane2, in: fixture).collapsed == .valid(true)) } @Test("A readable-but-uneditable lane refuses the write, banners it in the gesture's own word, 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.setLaneCollapsed(ItemID(rawValue: Ident.lane3), collapsed: true) #expect(try fixture.indexData(Ident.lane3) == before) #expect(try fixture.indexText(Ident.lane3) == Item.uneditable) // `performWrite` posts before it rethrows and the store swallows the rethrow — the banner is the // only thing that says the gesture did not happen, and it must say *collapse* rather than // resize: the user pressed Collapse Lane. #expect(store.banners.oneShots.count == 1) let posted = try #require(store.banners.oneShots.first) #expect(posted.error.operation == .collapse(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 collapse 'Odd' — ")) } /// **A drop on the strip appends at the lane's end** (03-board-ui.md § Lane ▸ Collapsed lanes: /// "dropping a drag onto the collapsed strip appends the payload at the lane's END, like an /// end-of-lane drop"). /// /// It is asserted with **no window at all**, which is the point rather than a convenience: a folded /// lane draws no cards, so there is no row geometry a position could be resolved against and nothing /// about the pointer can change the answer — the branch is taken before the cursor is ever read. @Test("A card drag over a folded lane proposes the end of its card list, cursor notwithstanding") func dropOnTheStripAppendsAtTheEnd() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.board() try fixture.lane(Ident.lane1, order: "1024", title: "Todo") try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "First") try fixture.card(Ident.card2, in: Ident.lane1, order: "2048", title: "Second") try fixture.card(Ident.card3, in: Ident.lane1, order: "3072", title: "Third") // The destination: folded, holding two cards of its own. try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: Doing\norder: 2048\ncollapsed: true\n---\n\n") try fixture.card(Ident.card4, in: Ident.lane2, order: "1024", title: "Fourth") try fixture.card(Ident.indexless, in: Ident.lane2, order: "2048", title: "Fifth") let store = try BoardStore(rootURL: fixture.root) let session = DragSession() let registry = LaneDropRegistry() let drops = BoardDropContext( store: store, session: session, registry: registry, gap: 12, window: { nil }, stripFrame: { .zero }, standard: { 260 } ) let dragged = ItemID(rawValue: Ident.card1) session.beginCards( [dragged], folders: [store.rootURL .appendingPathComponent(Ident.lane1, isDirectory: true) .appendingPathComponent(Ident.card1, isDirectory: true)], heights: [44], container: .board, source: store ) drops.retargetCards(inLane: ItemID(rawValue: Ident.lane2)) let proposal = try #require(session.proposal) #expect(proposal.container == .lane(ItemID(rawValue: Ident.lane2))) #expect(proposal.index == 2, "the end of the folded lane's two cards") // Into the lane the run came *from*, the index is counted in the resting layout — the dragged // card lifted out — which is the space every proposal in this app is counted in. drops.retargetCards(inLane: ItemID(rawValue: Ident.lane1)) #expect(session.proposal?.index == 2, "three cards minus the one in flight") // Expanded, the lane goes back to needing geometry: with no window and no registered grid there // is nothing to resolve, and the standing proposal simply holds (the hysteresis contract). try fixture.lane(Ident.lane2, order: "2048", title: "Doing") let reopened = try BoardStore(rootURL: fixture.root) let reopenedDrops = BoardDropContext( store: reopened, session: session, registry: registry, gap: 12, window: { nil }, stripFrame: { .zero }, standard: { 260 } ) session.propose(nil) reopenedDrops.retargetCards(inLane: ItemID(rawValue: Ident.lane2)) #expect(session.proposal == nil, "an expanded lane answers from its grid, and there is none here") } @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.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: true) // The lock row is already standing; a refusal per gesture would bury it under echoes of itself. #expect(try fixture.indexData(Ident.lane1) == before) #expect(store.banners.oneShots.isEmpty) #expect(store.bannerRows.contains { $0.id == "read-only-lock" }) } }