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
This commit is contained in:
+112
-109
@@ -16,20 +16,6 @@ import Testing
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private func tombstoned(order: String, title: String) -> String {
|
||||
"""
|
||||
---
|
||||
schema: 1
|
||||
title: \(title)
|
||||
order: \(order)
|
||||
created: 2026-01-01T09:00:00Z
|
||||
deleted: 2026-03-03T09:00:00Z
|
||||
---
|
||||
\(title) body.
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -55,12 +41,12 @@ private enum Foreign {
|
||||
static let trashed = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"
|
||||
}
|
||||
|
||||
/// The board every cross-board test drags *out of*: one lane holding a live card and a tombstoned
|
||||
/// one, so the lane-copy rule has something to strip and the restore rules have a row to carry.
|
||||
/// 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 live card under identities the **destination** already holds,
|
||||
/// which is the import boundary's whole question; the tombstoned card keeps its foreign identity
|
||||
/// either way, so a colliding arrival can prove the degradation is per folder.
|
||||
/// `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()
|
||||
@@ -69,7 +55,8 @@ private func makeSourceBoard(colliding: Bool = false) throws -> WriterFixture {
|
||||
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.trashed)", tombstoned(order: "2048", title: "Trashed"))
|
||||
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
|
||||
}
|
||||
|
||||
@@ -90,14 +77,14 @@ private func loaded(_ fixture: WriterFixture) throws -> BoardModel {
|
||||
/// 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.filter { !$0.isDeleted }.compactMap(\.title.value)
|
||||
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.filter { !$0.isDeleted }.map(\.id.rawValue)
|
||||
return lane.cards.map(\.id.rawValue)
|
||||
}
|
||||
|
||||
private func order(_ fixture: WriterFixture, _ relativePath: String) throws -> FieldValue<Double> {
|
||||
@@ -183,18 +170,16 @@ struct MoveCardsTests {
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A destination that is gone, tombstoned, or empty of members writes nothing")
|
||||
@Test("A destination that is gone, or a set that names nothing, writes nothing")
|
||||
func noOps() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let stamp = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
|
||||
|
||||
store.moveCards([card1], toLane: lane3, at: 0) // tombstoned lane
|
||||
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 live
|
||||
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"])
|
||||
@@ -315,14 +300,13 @@ struct CopyCardsTests {
|
||||
func noOps() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.copyCards([card1], toLane: lane3, at: 0)
|
||||
store.copyCards([card1], toLane: ItemID(rawValue: Ident.lane3), at: 0)
|
||||
store.copyCards([], toLane: lane2, at: 0)
|
||||
|
||||
#expect(try titles(lane2, in: fixture) == ["Fourth"])
|
||||
#expect(try fixture.entryNames(Ident.lane3) == ["index.md"])
|
||||
#expect(!fixture.exists(Ident.lane3))
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -411,14 +395,13 @@ struct ReceiveCardsTests {
|
||||
defer { destination.tearDown() }
|
||||
let source = try makeSourceBoard()
|
||||
defer { source.tearDown() }
|
||||
try destination.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
|
||||
let store = try BoardStore(rootURL: destination.root)
|
||||
|
||||
store.receiveCards([source.url("\(Foreign.lane)/\(Foreign.card)")],
|
||||
operation: .copy, toLane: lane3, at: 0)
|
||||
operation: .copy, toLane: ItemID(rawValue: Ident.lane3), at: 0)
|
||||
store.receiveCards([], operation: .copy, toLane: lane2, at: 0)
|
||||
|
||||
#expect(try destination.entryNames(Ident.lane3) == ["index.md"])
|
||||
#expect(!destination.exists(Ident.lane3))
|
||||
#expect(try titles(lane2, in: destination) == ["Fourth"])
|
||||
#expect(source.exists("\(Foreign.lane)/\(Foreign.card)"))
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
@@ -524,8 +507,12 @@ struct MoveLanesTests {
|
||||
@Suite("BoardStore ▸ receiveLanes")
|
||||
struct ReceiveLanesTests {
|
||||
|
||||
@Test("A lane copy transfers the content and strips the tombstoned cards")
|
||||
func laneCopyStripsTombstones() throws {
|
||||
/// 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()
|
||||
@@ -538,19 +525,15 @@ struct ReceiveLanesTests {
|
||||
#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"],
|
||||
"trash isn't content — the tombstoned card did not come")
|
||||
#expect(arrived.cards.allSatisfy { !$0.isDeleted })
|
||||
#expect(arrived.cards[0].id.rawValue != Foreign.card, "a copied lane's cards are new cards")
|
||||
|
||||
// The tombstoned original stays recoverable in the source board.
|
||||
#expect(source.exists("\(Foreign.lane)/\(Foreign.trashed)"))
|
||||
#expect(try source.indexText("\(Foreign.lane)/\(Foreign.trashed)").contains("deleted:"))
|
||||
#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 tombstoned cards whole, into the destination's trash")
|
||||
func laneMoveCarriesTombstones() throws {
|
||||
@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()
|
||||
@@ -563,10 +546,9 @@ struct ReceiveLanesTests {
|
||||
#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.trashed])
|
||||
#expect(arrived.cards[1].isDeleted, "the tombstone came along as-is")
|
||||
#expect(TrashModel.entries(of: model).map(\.id) == [ItemID(rawValue: Foreign.trashed)],
|
||||
"and it renders in the destination's trash")
|
||||
#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)
|
||||
}
|
||||
@@ -591,7 +573,7 @@ struct ReceiveLanesTests {
|
||||
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.trashed, "and nothing else was — degradation is per folder")
|
||||
#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")
|
||||
}
|
||||
@@ -609,26 +591,37 @@ struct ReceiveLanesTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drag to restore, positionally
|
||||
// MARK: - Restore is an ordinary move out
|
||||
|
||||
/// A lane holding a live card, a tombstoned one, and another live one — so a restore has somewhere
|
||||
/// to land that is neither the head nor the tail.
|
||||
/// 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.card2)", tombstoned(order: "2048", title: "Trashed"))
|
||||
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 ▸ positional drag-to-restore")
|
||||
struct RestoreByDragPositionTests {
|
||||
@Suite("BoardStore ▸ restore by move-out")
|
||||
struct RestoreByMoveOutTests {
|
||||
|
||||
@Test("The drop position sets the restored card's order")
|
||||
func dropPositionSetsTheOrder() throws {
|
||||
@@ -636,76 +629,89 @@ struct RestoreByDragPositionTests {
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
// Index 0 among the lane's two live cards: ahead of both, not back at its recorded 2048.
|
||||
store.restoreByDrag(cardID: card2, intoLane: lane1, at: 0)
|
||||
// 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("\(Ident.lane1)/\(Ident.card2)"), "same lane never moves a folder")
|
||||
#expect(TrashModel.isEmpty(try loaded(fixture)))
|
||||
#expect(!fixture.exists(".trash/\(Ident.card2)"), "the folder physically left the trash")
|
||||
#expect(try loaded(fixture).trash.isEmpty)
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A cross-lane restore lands at the drop position, not at the bottom")
|
||||
func crossLanePositional() throws {
|
||||
let fixture = try makeTrashBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.restoreByDrag(cardID: card2, intoLane: lane2, at: 0)
|
||||
|
||||
#expect(try titles(lane2, in: fixture) == ["Trashed", "Fourth"])
|
||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
|
||||
#expect(try order(fixture, "\(Ident.lane2)/\(Ident.card2)") == .valid(0))
|
||||
let text = try fixture.indexText("\(Ident.lane2)/\(Ident.card2)")
|
||||
#expect(!text.contains("deleted:"))
|
||||
}
|
||||
|
||||
@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.restoreByDrag(cardID: card2, intoLane: lane1, at: 99)
|
||||
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("A multi-row restore lands the run contiguously, in drop order, in one bracket")
|
||||
@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.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "TrashedA"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstoned(order: "3072", title: "TrashedB"))
|
||||
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.restoreByDrag(cardIDs: [card3, card2], intoLane: lane2, at: 0)
|
||||
store.moveCards([card3, card2], toLane: lane2, at: 0)
|
||||
|
||||
#expect(try titles(lane2, in: fixture) == ["TrashedB", "TrashedA", "Fourth"],
|
||||
"the payload's order is the landing order")
|
||||
#expect(TrashModel.isEmpty(try loaded(fixture)))
|
||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
|
||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card3)"))
|
||||
"the trash's own order is the landing order")
|
||||
#expect(try loaded(fixture).trash.isEmpty)
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A row that is not a trash row is skipped, and an empty list writes nothing")
|
||||
func skipsWhatIsNotARow() throws {
|
||||
@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)")
|
||||
|
||||
// `card1` is live — it has no trash row to drag — and `indexless` names nothing.
|
||||
store.restoreByDrag(cardIDs: [card1, ItemID(rawValue: Ident.indexless)], intoLane: lane2, at: 0)
|
||||
store.restoreByDrag(cardIDs: [], intoLane: lane2, at: 0)
|
||||
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)
|
||||
@@ -713,52 +719,49 @@ struct RestoreByDragPositionTests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 ▸ receiveRestoredCards")
|
||||
struct ReceiveRestoredCardsTests {
|
||||
@Suite("BoardStore ▸ cross-board restore")
|
||||
struct CrossBoardRestoreTests {
|
||||
|
||||
@Test("A cross-board restore-copy lands live and leaves the source tombstone standing")
|
||||
func restoreCopyStripsDeletedAndKeepsTheOriginal() throws {
|
||||
@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.receiveRestoredCards([source.url("\(Foreign.lane)/\(Foreign.trashed)")],
|
||||
operation: .copy, toLane: lane2, at: 0)
|
||||
store.receiveCards([source.url(".trash/\(Foreign.trashed)")],
|
||||
operation: .copy, toLane: lane2, at: 0)
|
||||
|
||||
#expect(try titles(lane2, in: destination) == ["Trashed", "Fourth"])
|
||||
let landedIDs = try ids(lane2, in: destination)
|
||||
let landed = try #require(landedIDs.first)
|
||||
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("deleted:"), "`deleted:` is stripped on paste/duplicate/drop")
|
||||
#expect(text.contains("created: 2026-01-01T09:00:00Z"), "a copy is a fork")
|
||||
|
||||
// The tombstoned original stays recoverable in the source board's trash.
|
||||
#expect(source.exists("\(Foreign.lane)/\(Foreign.trashed)"))
|
||||
#expect(try source.indexText("\(Foreign.lane)/\(Foreign.trashed)").contains("deleted:"))
|
||||
#expect(source.exists(".trash/\(Foreign.trashed)"), "the original stays in the source trash")
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A cross-board restore-move clears the tombstone and the source loses the folder")
|
||||
func restoreMoveClearsTheTombstone() throws {
|
||||
@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.receiveRestoredCards([source.url("\(Foreign.lane)/\(Foreign.trashed)")],
|
||||
operation: .move, toLane: lane2, at: 1)
|
||||
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")
|
||||
let text = try destination.indexText("\(Ident.lane2)/\(Foreign.trashed)")
|
||||
#expect(!text.contains("deleted:"))
|
||||
#expect(!source.exists("\(Foreign.lane)/\(Foreign.trashed)"), "the tombstone left the source")
|
||||
#expect(TrashModel.isEmpty(try loaded(source)))
|
||||
#expect(TrashModel.isEmpty(try loaded(destination)))
|
||||
#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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user