Files
lanework/KanbanTests/DragWriteTests.swift
T
rzen 53bc71f7fb Materialize the trash — store, undo, and the container universe
Phase 2 swaps every consumer: Liveness and its ancestor walk are gone,
replaced by ItemContainer — a UUID set plus the container side it
lives on, presence the whole test, one selection boundary instead of
the old liveness law. Deletion stages by place: board cards move to
the trash at a store-minted head rank, trash-side delete is permanent
behind its confirmation, Delete Immediately skips the trash from
anywhere, lane delete captures the subtree and removes the folder.
Restore has no method at all — moveCards resolves members in either
container, so drag-out and cut-paste are the ordinary moves 13 calls
them, registering ordinary Move steps. The delete inverse moves the
card back to its captured lane and rank; redo replays the captured
trash rank, a value the gesture actually wrote; lane undo recreates
the subtree byte-faithfully in session. Purges register nothing —
where 13's trash section contradicts its own Rules on that, Rules
wins, filed for ruling. Staleness collapsed to present-or-absent: a
container is a path, so a foreign restore fails the delete step's
expectation structurally. Legacy tombstones migrate on the loose-file
tail hook, cards oldest-first so minting above top reproduces the
retired newest-first column, lanes returning live, one folded loss
row naming both directions. Put Back, restoreByDrag,
receiveRestoredCards, TrashEntry, and the kind machinery are deleted;
the trash column renders the container correctly with its full face
rework left to phase 3.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 17:47:56 -04:00

768 lines
35 KiB
Swift

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<Double> {
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
@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)")
// With First lifted the lane reads [Second, Third]; 1 is "before Third".
store.copyCards([card1], toLane: lane1, at: 1)
#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)
}
@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 0 in the resting layout means "before Second" — the layout the originals are lifted
// out of. The rank has to sit between First and Second, not at First's apparently vacated
// 1024, because First reappears the instant the write lands.
store.copyCards([card1], toLane: lane1, at: 0)
#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))
}
@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 — `card3` above `card2`, newest-first.
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "2048", title: "TrashedA"))
try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "TrashedB"))
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)
}
}