import Foundation import Testing @testable import Kanban /// `BoardStore`'s drop commits — the writes a released drag performs (04-interactions.md ▸ Drag and /// drop, DRAG-REORDER.md § The drop commits). /// /// Like every other write suite here these drive a **real store over a real temp board** and then /// read back through the loader or the raw bytes, never through a snapshot the store handed out: /// the interesting claims are about the files — which rank landed, which folder travelled, which /// UUID was reminted, and which sibling was left alone. `WriterFixture`, `Ident` and `Item` come /// from `WriterTestSupport.swift`. /// /// The geometry that produces the `index` these methods take is `DropSlotMathTests`'; here the /// index is simply given, which is the whole point of the split. // MARK: - Fixtures /// Three cards in the first lane, one in the second — enough room for a run of two to insert /// between siblings without either end being the answer. @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.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third")) try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth")) return fixture } /// Identities the destination board has never seen — the source board's own, for every cross-board /// case that is *not* about the import boundary. `Ident` is shared with the writer suites and /// deliberately small; a second board needs its own namespace to be a second board at all. private enum Foreign { static let lane = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" static let card = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" static let second = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" static let trashed = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" } /// The board every cross-board test drags *out of*: one lane holding two cards, plus a card sitting /// in the source board's own `.trash/` for the cross-board restore cases. /// /// `colliding` puts the lane and its first card under identities the **destination** already holds, /// which is the import boundary's whole question; the second card keeps its foreign identity either /// way, so a colliding arrival can prove the degradation is per folder. @MainActor private func makeSourceBoard(colliding: Bool = false) throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) let laneName = colliding ? Ident.lane1 : Foreign.lane let cardName = colliding ? Ident.card1 : Foreign.card try fixture.item(laneName, Item.rich(order: "1024", title: "Imported")) try fixture.item("\(laneName)/\(cardName)", Item.rich(order: "1024", title: "Travelling")) try fixture.item("\(laneName)/\(Foreign.second)", Item.rich(order: "2048", title: "Second")) try fixture.item(".trash/\(Foreign.trashed)", Item.rich(order: "1024", title: "Trashed")) 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 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 board as the loader sees it — never the store's snapshot, which a drop deliberately does not /// touch (the one-way flow: the write lands, the watcher reloads). private func loaded(_ fixture: WriterFixture) throws -> BoardModel { try BoardLoader.load(boardRoot: fixture.root).model } /// A lane's rendered card titles, in display order. private func titles(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String] { guard let lane = try loaded(fixture).lanes.first(where: { $0.id == laneID }) else { return [] } return lane.cards.compactMap(\.title.value) } /// A lane's rendered card folder names, in display order — identity, where titles would not /// distinguish an original from its copy. private func ids(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String] { guard let lane = try loaded(fixture).lanes.first(where: { $0.id == laneID }) else { return [] } return lane.cards.map(\.id.rawValue) } private func order(_ fixture: WriterFixture, _ relativePath: String) throws -> FieldValue { try FrontmatterDocument.parse(fixture.indexText(relativePath)).order } /// A file's mtime — "this sibling was not rewritten", stated the way `WriteFidelityTests` states it. private func stat(_ fixture: WriterFixture, _ relativePath: String) throws -> Date { let indexURL = fixture.url(relativePath).appendingPathComponent("index.md") let attributes = try FileManager.default.attributesOfItem(atPath: indexURL.path) guard let modified = attributes[.modificationDate] as? Date else { Issue.record("no modification date for \(relativePath)") return .distantPast } return modified } // MARK: - Within-board card moves @MainActor @Suite("BoardStore ▸ moveCards") struct MoveCardsTests { @Test("A same-lane drop rewrites only the card that moved") func sameLaneReorder() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let first = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") let second = try stat(fixture, "\(Ident.lane1)/\(Ident.card2)") // Third to the head: the index is counted with the dragged card already removed, so 0 is // "before what is left", which is First. store.moveCards([card3], toLane: lane1, at: 0) #expect(try titles(lane1, in: fixture) == ["Third", "First", "Second"]) // A head insert over the remaining ranks [1024, 2048]. #expect(try order(fixture, "\(Ident.lane1)/\(Ident.card3)") == .valid(0)) // Ranks are inserted, never permuted, so the siblings' files were never opened. #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == first) #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)") == second) #expect(store.banners.oneShots.isEmpty) } @Test("A cross-lane drop inserts the run contiguously, in flatten order") func crossLaneContiguousInsert() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) // A Set, deliberately unordered: the run lands in *flatten* order — lane `order` first, // then card `order` — so Second (lane one) precedes Fourth (lane two) whatever the Set did. store.moveCards([card4, card2], toLane: lane1, at: 1) #expect(try titles(lane1, in: fixture) == ["First", "Second", "Fourth", "Third"]) #expect(try titles(lane2, in: fixture) == [], "the arrival's folder really left lane two") #expect(!fixture.exists("\(Ident.lane2)/\(Ident.card4)")) #expect(fixture.exists("\(Ident.lane1)/\(Ident.card4)")) // A within-board move never remints: the UUIDs travelled unchanged. #expect(try ids(lane1, in: fixture) == [Ident.card1, Ident.card2, Ident.card4, Ident.card3]) #expect(store.banners.oneShots.isEmpty) } @Test("A drop that lands where everything already is writes nothing") func ownSlotIsANoOp() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let stamps = [ try stat(fixture, "\(Ident.lane1)/\(Ident.card1)"), try stat(fixture, "\(Ident.lane1)/\(Ident.card2)"), try stat(fixture, "\(Ident.lane1)/\(Ident.card3)"), ] // Second's own resting slot: with it removed the lane reads [First, Third], and 1 puts it // straight back between them. store.moveCards([card2], toLane: lane1, at: 1) #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == stamps[0]) #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)") == stamps[1], "a drag that ends where it started must not stamp modified or mint a commit") #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card3)") == stamps[2]) #expect(store.banners.oneShots.isEmpty) } @Test("A destination that is gone, or a set that names nothing, writes nothing") func noOps() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let stamp = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") store.moveCards([card1], toLane: ItemID(rawValue: Ident.indexless), at: 0) // no such lane store.moveCards([], toLane: lane2, at: 0) // nothing dragged store.moveCards([ItemID(rawValue: Ident.indexless)], toLane: lane2, at: 0) // nothing there #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == stamp) #expect(try titles(lane2, in: fixture) == ["Fourth"]) #expect(store.banners.oneShots.isEmpty) } @Test("An out-of-range index clamps rather than trapping") func indexClamps() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) // A proposal computed against a snapshot one reload old must not trap. store.moveCards([card4], toLane: lane1, at: 99) #expect(try titles(lane1, in: fixture) == ["First", "Second", "Third", "Fourth"]) } @Test("Duplicate ranks trigger a renumber, then the run places against the fresh ladder") func renumberFallback() throws { let fixture = try makeBoard() defer { fixture.tearDown() } // Two cards sharing a rank: no `Double` fits between them, which is the renumber trigger. try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "1024", title: "Second")) let store = try BoardStore(rootURL: fixture.root) store.moveCards([card3], toLane: lane1, at: 1) #expect(try titles(lane1, in: fixture) == ["First", "Third", "Second"]) // The lane was compacted to the 1024 ladder first, so the interior midpoint exists again. #expect(try order(fixture, "\(Ident.lane1)/\(Ident.card1)") == .valid(1024)) #expect(try order(fixture, "\(Ident.lane1)/\(Ident.card3)") == .valid(1536)) #expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(2048)) #expect(store.banners.oneShots.isEmpty) } @Test("A read-only board refuses the drop") func readOnlyRefuses() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.enterVanishedRootLock() store.moveCards([card3], toLane: lane1, at: 0) #expect(try titles(lane1, in: fixture) == ["First", "Second", "Third"]) } } // MARK: - Within-board ⌥-copies /// **A copy's index is counted among the lane's full rendered cards, originals included** — unlike /// `moveCards`', which is counted among the cards its run vacates (DRAG-REORDER.md § Resting-layout /// zones, ruled 2026-08-01). That is not an arbitrary split: a copy leaves its originals in the /// source board's resting layout for the whole drag (`DragSession.hiddenMembers`), so the zones that /// produced the number counted them too, and each writer consumes the number in the space its own /// gesture was showing. `DragRestingLayoutTests` pins the layout half; these pin the write half. @MainActor @Suite("BoardStore ▸ copyCards") struct CopyCardsTests { @Test("A copy lands fresh-GUID duplicates at the drop and leaves the originals alone") func freshGUIDsAndUntouchedOriginals() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let stamp = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") // The lane the user is looking at reads [First, Second, Third] — the ⌥-copy's originals // stay in it — so 2 is "before Third". store.copyCards([card1], toLane: lane1, at: 2) #expect(try titles(lane1, in: fixture) == ["First", "Second", "First", "Third"]) let landed = try ids(lane1, in: fixture) #expect(landed.count == 4) #expect(landed[2] != Ident.card1, "a copy mints a fresh UUID at every level") #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == stamp, "the original is untouched") #expect(store.banners.oneShots.isEmpty) } /// **The drop lands exactly where the shadow showed, below the dragged run included.** This is /// the case the index space is actually about: the shadow sits *after* the original, so the /// slot number the geometry produced counts the original on its way there, and a writer that /// re-read the number against the originals-removed layout would push the copy one slot too far /// — off the end of a three-card lane, in this fixture. @Test("A copy below the dragged run lands at the shadow's slot, not past it") func landsBelowTheRun() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) // Dragging First, ⌥ held, with the shadow between Second and Third: the drawn lane is // [First, Second, ▮, Third], so the proposal is slot 2. store.copyCards([card1], toLane: lane1, at: 2) #expect(try titles(lane1, in: fixture) == ["First", "Second", "First", "Third"]) let landed = try ids(lane1, in: fixture)[2] #expect(try order(fixture, "\(Ident.lane1)/\(landed)") == .valid(2560), "between Second and Third, where the shadow was") } /// The end slot, in the same space: one past the last card of the layout on screen appends. @Test("The terminal slot appends, counted among the originals like every other slot") func theTerminalSlotAppends() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.copyCards([card1], toLane: lane1, at: 3) #expect(try titles(lane1, in: fixture) == ["First", "Second", "Third", "First"]) let landed = try ids(lane1, in: fixture)[3] #expect(try order(fixture, "\(Ident.lane1)/\(landed)") == .valid(4096)) } @Test("A copy keeps created — a copy is a fork") func createdIsKept() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.copyCards([card1], toLane: lane2, at: 0) let landedIDs = try ids(lane2, in: fixture) let landed = try #require(landedIDs.first { $0 != Ident.card4 }) let text = try fixture.indexText("\(Ident.lane2)/\(landed)") #expect(text.contains("created: 2026-01-01T09:00:00Z")) #expect(!text.contains("modified-by"), "a copy is an app write, so the foreign stamp is cleared") } @Test("A copy's ranks are placed among the originals, which are still there") func ranksAvoidTheOriginals() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) // Index 1 is "between First and Second" in the lane as drawn, and the original First is // genuinely still sitting at 1024 — so the rank has to land strictly inside that gap. There // is no apparently-vacated slot to choose in, which is the whole point of counting the // index among the originals rather than around them. store.copyCards([card1], toLane: lane1, at: 1) #expect(try titles(lane1, in: fixture) == ["First", "First", "Second", "Third"]) let landed = try ids(lane1, in: fixture)[1] #expect(try order(fixture, "\(Ident.lane1)/\(landed)") == .valid(1536)) #expect(try order(fixture, "\(Ident.lane1)/\(Ident.card1)") == .valid(1024), "the original keeps its rank — nothing was written to it") } /// Slot 0 in the copy's space is genuinely *before* the original, which the old /// originals-removed space could not name at all: its 0 meant "before the first survivor", /// i.e. after the original. @Test("Slot 0 lands the copy in front of its own original") func slotZeroLandsInFront() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.copyCards([card1], toLane: lane1, at: 0) #expect(try titles(lane1, in: fixture) == ["First", "First", "Second", "Third"]) let landed = try ids(lane1, in: fixture)[0] #expect(landed != Ident.card1, "the copy is the one in front") let rank = try #require(order(fixture, "\(Ident.lane1)/\(landed)").value) #expect(rank < 1024, "a whole gap below the original, which keeps its own 1024") #expect(try order(fixture, "\(Ident.lane1)/\(Ident.card1)") == .valid(1024)) } @Test("A multi-copy lands the run contiguously, in flatten order") func multiCopyIsContiguous() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.copyCards([card3, card1], toLane: lane2, at: 1) #expect(try titles(lane2, in: fixture) == ["Fourth", "First", "Third"]) #expect(try titles(lane1, in: fixture) == ["First", "Second", "Third"], "originals stay") } @Test("Nothing droppable copies nothing") func noOps() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.copyCards([card1], toLane: ItemID(rawValue: Ident.lane3), at: 0) store.copyCards([], toLane: lane2, at: 0) #expect(try titles(lane2, in: fixture) == ["Fourth"]) #expect(!fixture.exists(Ident.lane3)) #expect(store.banners.oneShots.isEmpty) } } // MARK: - Cross-board card arrivals @MainActor @Suite("BoardStore ▸ receiveCards") struct ReceiveCardsTests { @Test("A cross-board copy lands a fresh-GUID duplicate and leaves the source alone") func crossBoardCopy() throws { let destination = try makeBoard() defer { destination.tearDown() } let source = try makeSourceBoard() defer { source.tearDown() } let store = try BoardStore(rootURL: destination.root) store.receiveCards([source.url("\(Foreign.lane)/\(Foreign.card)")], operation: .copy, toLane: lane2, at: 0) #expect(try titles(lane2, in: destination) == ["Travelling", "Fourth"]) let landedIDs = try ids(lane2, in: destination) #expect(landedIDs.first != Foreign.card, "copies mint fresh UUIDs, always") #expect(source.exists("\(Foreign.lane)/\(Foreign.card)"), "the original stays") #expect(store.banners.oneShots.isEmpty) } @Test("A cross-board move carries the identity and empties the source folder") func crossBoardMove() throws { let destination = try makeBoard() defer { destination.tearDown() } let source = try makeSourceBoard() defer { source.tearDown() } let store = try BoardStore(rootURL: destination.root) store.receiveCards([source.url("\(Foreign.lane)/\(Foreign.card)")], operation: .move, toLane: lane2, at: 1) #expect(try ids(lane2, in: destination) == [Ident.card4, Foreign.card], "identity travels") #expect(!source.exists("\(Foreign.lane)/\(Foreign.card)")) #expect(store.banners.oneShots.isEmpty) } @Test("A moved folder whose UUID the destination already holds arrives reminted") func importBoundaryRemints() throws { let destination = try makeBoard() defer { destination.tearDown() } // The source's card carries `card1`, which lives in the destination's first lane already. let source = try makeSourceBoard(colliding: true) defer { source.tearDown() } let store = try BoardStore(rootURL: destination.root) store.receiveCards([source.url("\(Ident.lane1)/\(Ident.card1)")], operation: .move, toLane: lane2, at: 1) let landed = try ids(lane2, in: destination) #expect(landed.count == 2) #expect(landed[1] != Ident.card1, "a colliding UUID is repaired at the import boundary") #expect(destination.exists("\(Ident.lane1)/\(Ident.card1)"), "the resident keeps its identity") #expect(try titles(lane2, in: destination) == ["Fourth", "Travelling"]) #expect(!source.exists("\(Ident.lane1)/\(Ident.card1)")) } @Test("A cross-board run lands contiguously at the drop, in the order given") func runLandsContiguously() throws { let destination = try makeBoard() defer { destination.tearDown() } let source = try makeSourceBoard() defer { source.tearDown() } try source.item("\(Foreign.lane)/\(Foreign.second)", Item.rich(order: "3072", title: "Second traveller")) let store = try BoardStore(rootURL: destination.root) store.receiveCards([ source.url("\(Foreign.lane)/\(Foreign.card)"), source.url("\(Foreign.lane)/\(Foreign.second)"), ], operation: .copy, toLane: lane1, at: 1) #expect(try titles(lane1, in: destination) == ["First", "Travelling", "Second traveller", "Second", "Third"]) } @Test("Nothing droppable receives nothing") func noOps() throws { let destination = try makeBoard() defer { destination.tearDown() } let source = try makeSourceBoard() defer { source.tearDown() } let store = try BoardStore(rootURL: destination.root) store.receiveCards([source.url("\(Foreign.lane)/\(Foreign.card)")], operation: .copy, toLane: ItemID(rawValue: Ident.lane3), at: 0) store.receiveCards([], operation: .copy, toLane: lane2, at: 0) #expect(!destination.exists(Ident.lane3)) #expect(try titles(lane2, in: destination) == ["Fourth"]) #expect(source.exists("\(Foreign.lane)/\(Foreign.card)")) #expect(store.banners.oneShots.isEmpty) } } // MARK: - Within-board lane moves /// A three-lane board, so a run of two can land at either end or between the survivors. @MainActor private func makeThreeLaneBoard() 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, Item.rich(order: "2048", title: "Doing")) try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done")) return fixture } private func laneTitles(_ fixture: WriterFixture) throws -> [String] { try loaded(fixture).lanes.filter { !$0.isDeleted }.compactMap(\.title.value) } @MainActor @Suite("BoardStore ▸ moveLanes") struct MoveLanesTests { @Test("A run of lanes lands contiguously at the drop, in board order") func runLandsContiguously() throws { let fixture = try makeThreeLaneBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let untouched = try stat(fixture, Ident.lane2) // Todo and Done together, dropped at 0 — "before what is left", which is Doing. The two // arrive adjacent and in board order even though they were not adjacent to begin with. store.moveLanes([lane1, lane3], toIndex: 0) #expect(try laneTitles(fixture) == ["Todo", "Done", "Doing"]) // Ranks are inserted, never permuted: the lane that did not move was never opened. #expect(try stat(fixture, Ident.lane2) == untouched) #expect(try order(fixture, Ident.lane2) == .valid(2048)) #expect(store.banners.oneShots.isEmpty) } @Test("A run dropped past the survivors appends in order") func runAppends() throws { let fixture = try makeThreeLaneBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.moveLanes([lane1, lane2], toIndex: 1) #expect(try laneTitles(fixture) == ["Done", "Todo", "Doing"]) } @Test("A single lane behaves exactly as the singular command does") func singleLane() throws { let fixture = try makeThreeLaneBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.moveLanes([lane3], toIndex: 0) #expect(try laneTitles(fixture) == ["Done", "Todo", "Doing"]) } @Test("A drag that lands where everything already is writes nothing") func noOpDropWritesNothing() throws { let fixture = try makeThreeLaneBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let before = try [Ident.lane1, Ident.lane2, Ident.lane3].map { try stat(fixture, $0) } store.moveLanes([lane1], toIndex: 0) store.moveLanes([lane1, lane2], toIndex: 0) store.moveLanes([], toIndex: 1) store.moveLanes([ItemID(rawValue: Ident.indexless)], toIndex: 0) #expect(try [Ident.lane1, Ident.lane2, Ident.lane3].map { try stat(fixture, $0) } == before) #expect(store.banners.oneShots.isEmpty) } @Test("An out-of-range index clamps rather than trapping") func indexClamps() throws { // A store apiece: a write does not touch the snapshot (the one-way flow), so two drops // against one store would both be computed against the pre-drop board. let high = try makeThreeLaneBoard() defer { high.tearDown() } try BoardStore(rootURL: high.root).moveLanes([lane1], toIndex: 99) #expect(try laneTitles(high) == ["Doing", "Done", "Todo"]) let low = try makeThreeLaneBoard() defer { low.tearDown() } try BoardStore(rootURL: low.root).moveLanes([lane3], toIndex: -5) #expect(try laneTitles(low) == ["Done", "Todo", "Doing"]) } } // MARK: - Cross-board lane arrivals @MainActor @Suite("BoardStore ▸ receiveLanes") struct ReceiveLanesTests { /// 04-interactions.md ▸ Drag and drop, resettled 2026-07-28: "A lane carries exactly its cards — /// the trash is board-level (`.trash/`), so there is nothing lane-nested to strip or carry: copy /// and ⌘-drag move alike transfer the lane's folder as it is; the old tombstone-stripping rule is /// retired with the tombstone model." @Test("A lane copy transfers the whole lane, minting fresh identities at every level") func laneCopyRemintsThroughout() throws { let destination = try makeBoard() defer { destination.tearDown() } let source = try makeSourceBoard() defer { source.tearDown() } let store = try BoardStore(rootURL: destination.root) store.receiveLanes([source.url(Foreign.lane)], operation: .copy, at: 0) let model = try loaded(destination) #expect(model.lanes.map(\.title.value) == ["Imported", "Todo", "Doing"]) let arrived = try #require(model.lanes.first) #expect(arrived.id.rawValue != Foreign.lane, "a copy mints fresh UUIDs at every level") #expect(arrived.cards.map(\.title.value) == ["Travelling", "Second"], "nothing to strip") #expect(arrived.cards.allSatisfy { $0.id.rawValue != Foreign.card }, "a copied lane's cards are new cards") #expect(model.trash.isEmpty, "the source's trash is board-level and never travels with a lane") #expect(store.banners.oneShots.isEmpty) } @Test("A lane move carries its cards whole, identity and all") func laneMoveCarriesItsCards() throws { let destination = try makeBoard() defer { destination.tearDown() } let source = try makeSourceBoard() defer { source.tearDown() } let store = try BoardStore(rootURL: destination.root) store.receiveLanes([source.url(Foreign.lane)], operation: .move, at: 2) let model = try loaded(destination) #expect(model.lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, Foreign.lane], "identity travels, and the drop position is honoured") let arrived = try #require(model.lanes.last) #expect(arrived.cards.map(\.id.rawValue) == [Foreign.card, Foreign.second], "a lane carries exactly its cards: nothing lane-nested to strip or carry") #expect(model.trash.isEmpty, "and nothing arrived in the destination's own trash") #expect(!source.exists(Foreign.lane)) #expect(store.banners.oneShots.isEmpty) } @Test("A colliding lane move remints only the folders that collide") func laneMoveRemintsPerFolder() throws { let destination = try makeBoard() defer { destination.tearDown() } // The source lane is `lane1` holding `card1` — both already live in the destination — plus // one tombstoned card whose identity is foreign. let source = try makeSourceBoard(colliding: true) defer { source.tearDown() } let store = try BoardStore(rootURL: destination.root) store.receiveLanes([source.url(Ident.lane1)], operation: .move, at: 2) let model = try loaded(destination) #expect(model.lanes.count == 3) let arrived = try #require(model.lanes.last) #expect(arrived.id.rawValue != Ident.lane1, "the colliding root was repaired") #expect(arrived.title.value == "Imported") let arrivedCards = arrived.cards.map(\.id.rawValue) #expect(arrivedCards.count == 2) #expect(arrivedCards[0] != Ident.card1, "the colliding card was repaired too") #expect(arrivedCards[1] == Foreign.second, "and nothing else was — degradation is per folder") #expect(try titles(lane1, in: destination) == ["First", "Second", "Third"], "the residents kept their identities and their ranks") } @Test("Nothing to receive writes nothing") func noOps() throws { let destination = try makeBoard() defer { destination.tearDown() } let store = try BoardStore(rootURL: destination.root) store.receiveLanes([], operation: .copy, at: 0) #expect(try loaded(destination).lanes.count == 2) #expect(store.banners.oneShots.isEmpty) } } // MARK: - Restore is an ordinary move out /// A lane holding two cards, plus one card in the board's `.trash/` — so a restore has somewhere to /// land that is neither the head nor the tail. /// /// **There is no tombstone anywhere in it.** "Restoring is an ordinary move out: drag a trash card /// into any lane at any position … there is no restore-specific machinery and no Put Back" /// (03-board-ui.md § Trash, resettled 2026-07-28), so the fixture is an ordinary board plus a /// reserved folder. @MainActor private func makeTrashBoard() 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.card3)", Item.rich(order: "3072", title: "Third")) try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth")) try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Trashed")) return fixture } /// Restoring out of the trash — **through `moveCards` and `copyCards`, with no method of its own**. /// /// That is the claim these tests exist to pin. The tombstone model needed `restoreByDrag` and /// `receiveRestoredCards` because a restore had to clear a key as well as place a rank; a /// materialized trash makes it "an ordinary move" (03-board-ui.md § Trash), so the ordinary drop /// commits take a trash-side source and the retired pair is gone. @MainActor @Suite("BoardStore ▸ restore by move-out") struct RestoreByMoveOutTests { @Test("The drop position sets the restored card's order") func dropPositionSetsTheOrder() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) // Index 0 among the lane's two cards: ahead of both. store.moveCards([card2], toLane: lane1, at: 0) #expect(try titles(lane1, in: fixture) == ["Trashed", "First", "Third"]) #expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(0)) #expect(!fixture.exists(".trash/\(Ident.card2)"), "the folder physically left the trash") #expect(try loaded(fixture).trash.isEmpty) #expect(store.banners.oneShots.isEmpty) } @Test("An out-of-range index clamps to the lane's bottom") func indexClamps() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.moveCards([card2], toLane: lane1, at: 99) #expect(try titles(lane1, in: fixture) == ["First", "Third", "Trashed"]) #expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(4096)) } @Test("The identity travels, exactly as it does for any move") func identityTravels() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.moveCards([card2], toLane: lane2, at: 0) #expect(try ids(lane2, in: fixture) == [Ident.card2, Ident.card4]) #expect(!fixture.exists(".trash/\(Ident.card2)")) // Nothing about the restored card says it was ever deleted: there is no key to remove, // because there is no key. #expect(!(try fixture.indexText("\(Ident.lane2)/\(Ident.card2)").contains("deleted:"))) } @Test("A multi-card restore lands the run contiguously, in one bracket") func multiRestore() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } 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.lane2, Item.rich(order: "2048", title: "Doing")) try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth")) // The trash's own order is its display order, and since 2026-07-31 that is `modified` // descending (01-storage-format.md § Deletion) — `card3` above `card2`, newest-first. try fixture.item( ".trash/\(Ident.card2)", "---\nschema: 1\ntitle: TrashedA\norder: 2048\nmodified: 2026-05-01T09:00:00Z\n---\n") try fixture.item( ".trash/\(Ident.card3)", "---\nschema: 1\ntitle: TrashedB\norder: 1024\nmodified: 2026-05-03T09:00:00Z\n---\n") let store = try BoardStore(rootURL: fixture.root) store.moveCards([card3, card2], toLane: lane2, at: 0) #expect(try titles(lane2, in: fixture) == ["TrashedB", "TrashedA", "Fourth"], "the trash's own order is the landing order") #expect(try loaded(fixture).trash.isEmpty) #expect(store.banners.oneShots.isEmpty) } @Test("⌥ copies out and leaves the original in the trash") func copyOutLeavesTheOriginal() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.copyCards([card2], toLane: lane2, at: 0) #expect(try titles(lane2, in: fixture) == ["Trashed", "Fourth"]) let landed = try #require(try ids(lane2, in: fixture).first) #expect(landed != Ident.card2, "a copy out of the trash is still a copy") #expect(fixture.exists(".trash/\(Ident.card2)"), "the original stays in the trash") #expect(try loaded(fixture).trash.count == 1) } @Test("A card that is not in the trash and an empty list both write nothing") func skipsWhatIsNotThere() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let untouched = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") store.moveCards([ItemID(rawValue: Ident.indexless)], toLane: lane2, at: 0) store.moveCards([], toLane: lane2, at: 0) #expect(try titles(lane2, in: fixture) == ["Fourth"]) #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == untouched) #expect(store.banners.oneShots.isEmpty) } } /// The cross-board half — **`receiveCards`, with no restore variant** (04-interactions.md ▸ The /// trash: "Dropped on *another* board it follows the copy default — a live copy lands there, the /// original stays in the source trash; ⌘-drag forces the true cross-board restore-move"). @MainActor @Suite("BoardStore ▸ cross-board restore") struct CrossBoardRestoreTests { @Test("A cross-board copy out of the trash lands a fresh identity and leaves the original") func copyLeavesTheOriginal() throws { let destination = try makeBoard() defer { destination.tearDown() } let source = try makeSourceBoard() defer { source.tearDown() } let store = try BoardStore(rootURL: destination.root) store.receiveCards([source.url(".trash/\(Foreign.trashed)")], operation: .copy, toLane: lane2, at: 0) #expect(try titles(lane2, in: destination) == ["Trashed", "Fourth"]) let landed = try #require(try ids(lane2, in: destination).first) #expect(landed != Foreign.trashed, "a copy out of the trash is still a copy") let text = try destination.indexText("\(Ident.lane2)/\(landed)") #expect(text.contains("created: 2026-01-01T09:00:00Z"), "a copy is a fork") #expect(source.exists(".trash/\(Foreign.trashed)"), "the original stays in the source trash") #expect(store.banners.oneShots.isEmpty) } @Test("A ⌘-drag move out of another board's trash carries the identity and empties it") func moveCarriesTheIdentity() throws { let destination = try makeBoard() defer { destination.tearDown() } let source = try makeSourceBoard() defer { source.tearDown() } let store = try BoardStore(rootURL: destination.root) store.receiveCards([source.url(".trash/\(Foreign.trashed)")], operation: .move, toLane: lane2, at: 1) #expect(try ids(lane2, in: destination) == [Ident.card4, Foreign.trashed], "identity travels") #expect(!source.exists(".trash/\(Foreign.trashed)"), "the card left the source trash") #expect(try loaded(source).trash.isEmpty) #expect(try loaded(destination).trash.isEmpty) #expect(store.banners.oneShots.isEmpty) } } // MARK: - Restoring a trashed lane /// A board whose trash holds a **lane row** — the opaque unit, subtree intact (03-board-ui.md § /// Trash) — beside an ordinary trashed card, so the restore's rank arithmetic has a strip to land /// on and the container has more than one kind in it. @MainActor private func makeTrashedLaneBoard() 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.lane2, Item.rich(order: "2048", title: "Doing")) try fixture.item( ".trash/\(Ident.lane3)", "---\nschema: 1\ntitle: Done\norder: 512\nkind: lane\nproject: lanework\n---\nDone body.\n" ) try fixture.item(".trash/\(Ident.lane3)/\(Ident.card2)", Item.rich(order: "1024", title: "Freight")) try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Trashed")) return fixture } /// **Drag-to-restore at the lane level** (04-interactions.md ▸ The trash: "dropping … a trashed lane /// row onto its own board's strip — is an ordinary move to the drop position"), which is /// `BoardStore.restoreLanes` — an arrival's rank arithmetic with a within-board move's identity /// posture and an undo step, since 13-native-undo.md's inverse inventory names "restore-by-move → /// move back in". @MainActor @Suite("BoardStore ▸ restoring a trashed lane") struct RestoreLaneTests { @Test("The drop slot sets the restored lane's order, and its cards ride along") func dropSlotSetsTheOrder() throws { let fixture = try makeTrashedLaneBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) // Slot 1: between the board's two lanes. store.restoreLanes([lane3], toIndex: 1) let model = try loaded(fixture) #expect(model.lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane3, Ident.lane2]) #expect(model.trashedLanes.isEmpty) #expect(!fixture.exists(".trash/\(Ident.lane3)"), "the folder physically left the trash") #expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)"), "the freight came back inside it") #expect(model.trash.map(\.id.rawValue) == [Ident.card3], "the column's cards are untouched") #expect(store.banners.oneShots.isEmpty) } @Test("The identity travels — a within-board restore is a move, never an import") func identityTravels() throws { let fixture = try makeTrashedLaneBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.restoreLanes([lane3], toIndex: 99) // The lane keeps its UUID and so does its card: the import boundary's remint is for // *arrivals from another board*, and a row coming out of this board's own trash is not one. #expect(try loaded(fixture).lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, Ident.lane3]) #expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)")) #expect(try fixture.indexText(Ident.lane3).contains("project: lanework"), "unknown keys ride along") } @Test("Its undo is the ordinary move back in, at the `order` the row was carrying") func undoMovesItBackIn() throws { let fixture = try makeTrashedLaneBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let history = NativeHistoryProvider() store.history = history store.restoreLanes([lane3], toIndex: 0) #expect(history.undoActionName == "Move Lane") history.undo() #expect(fixture.exists(".trash/\(Ident.lane3)/\(Ident.card2)"), "back in, subtree intact") #expect(!fixture.exists(Ident.lane3)) #expect(try order(fixture, ".trash/\(Ident.lane3)") == .valid(512), "at the rank it was holding") history.redo() #expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)")) #expect(try loaded(fixture).trashedLanes.isEmpty) } @Test("A row that is not in the trash, and an empty set, write nothing") func skipsWhatIsNotThere() throws { let fixture = try makeTrashedLaneBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let untouched = try stat(fixture, Ident.lane1) store.restoreLanes([lane1], toIndex: 0) store.restoreLanes([], toIndex: 0) #expect(try loaded(fixture).lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2]) #expect(try stat(fixture, Ident.lane1) == untouched) #expect(store.banners.oneShots.isEmpty) } /// The keyboard restore's write (04-interactions.md ▸ The trash: "⌘X in the trash, ⌘V … a trashed /// lane pastes after the anchor lane"), whose armed-cut path lands in `receiveLanes` with a /// source folder inside this board's own `.trash/`. /// /// **The identity has to survive that**, which is what the trash-aware source-root derivation /// buys: `.trash/` counts in the destination's identity scan, so a restore mistaken for an import /// would find the row's own UUID resident and remint the very lane it was restoring. @Test("A within-board paste out of the trash keeps the lane's identity") func pasteRestoreKeepsTheIdentity() throws { let fixture = try makeTrashedLaneBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.receiveLanes( [.folder(fixture.url(".trash/\(Ident.lane3)"))], operation: .move, at: 2, normalizingLooseFiles: true ) #expect(try loaded(fixture).lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, Ident.lane3]) #expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)")) #expect(try loaded(fixture).trashedLanes.isEmpty) } } /// The cross-board half — the ordinary arrival, exactly as for a live lane: "Dropped on *another* /// board it follows the copy default … ⌘-drag forces the true cross-board restore-move" /// (04-interactions.md ▸ The trash). @MainActor @Suite("BoardStore ▸ cross-board lane restore") struct CrossBoardLaneRestoreTests { @Test("A cross-board copy out of the trash mints fresh identities and leaves the original") func copyLeavesTheOriginal() throws { let destination = try makeBoard() defer { destination.tearDown() } let source = try makeTrashedLaneBoard() defer { source.tearDown() } let store = try BoardStore(rootURL: destination.root) store.receiveLanes([source.url(".trash/\(Ident.lane3)")], operation: .copy, at: 99) let model = try loaded(destination) let landed = try #require(model.lanes.last) #expect(landed.title.value == "Done") #expect(landed.id.rawValue != Ident.lane3, "a copy out of the trash is still a copy") #expect(landed.cards.map(\.title.value) == ["Freight"]) #expect(source.exists(".trash/\(Ident.lane3)"), "the original stays in the source trash") } @Test("A ⌘-drag move carries the lane's identity and empties the source trash") func moveCarriesTheIdentity() throws { let destination = try makeBoard() defer { destination.tearDown() } let source = try makeTrashedLaneBoard() defer { source.tearDown() } let store = try BoardStore(rootURL: destination.root) store.receiveLanes([source.url(".trash/\(Ident.lane3)")], operation: .move, at: 0) let landed = try #require(loaded(destination).lanes.first) #expect(landed.id.rawValue == Ident.lane3, "the lane's identity travels") // Its card carried `Ident.card2`, which this destination already holds — so the import // boundary remints that folder and only that folder, per-folder at the finest grain. #expect(landed.cards.map(\.title.value) == ["Freight"]) #expect(landed.cards.map(\.id.rawValue) != [Ident.card2]) #expect(!source.exists(".trash/\(Ident.lane3)")) #expect(try loaded(source).trashedLanes.isEmpty) } }