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:
@@ -364,7 +364,7 @@ struct BoardStoreTests {
|
||||
#expect(!ran)
|
||||
|
||||
// Reading stays live throughout — keeping the last-good snapshot is the point of the lock.
|
||||
store.select([ItemID(rawValue: Ident.card2)], liveness: .live)
|
||||
store.select([ItemID(rawValue: Ident.card2)], in: .board)
|
||||
#expect(store.selection.ids.count == 1)
|
||||
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Repaired"))
|
||||
@@ -421,50 +421,48 @@ struct BoardStoreTests {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live)
|
||||
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board)
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||
await store.awaitQuiescence()
|
||||
|
||||
#expect(store.selection.ids == [ItemID(rawValue: Ident.card2)])
|
||||
#expect(store.selection.liveness == .live)
|
||||
#expect(store.selection.container == .board)
|
||||
}
|
||||
|
||||
@Test("A liveness flip is a vanish: a selected card tombstoned externally leaves the selection")
|
||||
func selectionEjectsALivenessFlip() async throws {
|
||||
@Test("A container crossing is a vanish: a selected card trashed externally leaves the selection")
|
||||
func selectionEjectsAContainerCrossing() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live)
|
||||
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board)
|
||||
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First"))
|
||||
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||
await store.awaitQuiescence()
|
||||
|
||||
// Still in the snapshot — the trash renders it — but no longer on the selection's side of
|
||||
// the boundary, so the homogeneous-by-liveness invariant survives a foreign edit.
|
||||
let flipped = lane(Ident.lane1, in: store.snapshot)?.cards.first { $0.id.rawValue == Ident.card1 }
|
||||
#expect(flipped?.isDeleted == true)
|
||||
// Still in the snapshot — the trash renders it — but no longer in the selection's container,
|
||||
// so 04's one-container invariant survives a foreign edit (02-architecture.md's reload rule,
|
||||
// resettled 2026-07-28: "re-resolution matches UUID *and* container side").
|
||||
#expect(store.snapshot.trash.map(\.id.rawValue) == [Ident.card1])
|
||||
#expect(store.selection.ids == [ItemID(rawValue: Ident.card2)])
|
||||
}
|
||||
|
||||
@Test("Tombstoning a lane ejects its cards from a live selection — liveness is effective")
|
||||
func selectionEjectsCardsUnderATombstonedLane() async throws {
|
||||
@Test("Deleting a lane externally takes its cards out of the selection with it")
|
||||
func selectionEjectsCardsUnderARemovedLane() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live)
|
||||
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board)
|
||||
|
||||
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Lane one"))
|
||||
// A lane delete is physical now (03-board-ui.md § Trash), so the cards genuinely go with it
|
||||
// — no ancestor walk needed, and none left to do: presence is the whole test.
|
||||
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
|
||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||
await store.awaitQuiescence()
|
||||
|
||||
// The cards' own flags never changed, but their lane's did — and liveness is
|
||||
// ancestor-walked (02, settled): the cards render nowhere once 03 collapses the lane to
|
||||
// a single trash entry, and nothing invisible may stay selected.
|
||||
let survivor = lane(Ident.lane1, in: store.snapshot)?.cards.first { $0.id.rawValue == Ident.card1 }
|
||||
#expect(survivor?.isDeleted == false, "the card's own flag is untouched")
|
||||
#expect(store.snapshot.lanes.first { $0.id.rawValue == Ident.lane1 } == nil)
|
||||
#expect(store.selection.ids.isEmpty)
|
||||
}
|
||||
|
||||
@@ -473,7 +471,7 @@ struct BoardStoreTests {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live)
|
||||
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board)
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
||||
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
|
||||
@@ -481,9 +479,9 @@ struct BoardStoreTests {
|
||||
await store.awaitQuiescence()
|
||||
|
||||
#expect(store.selection.ids.isEmpty)
|
||||
#expect(store.selection.liveness == .live, "the side survives even when the membership does not")
|
||||
#expect(store.selection.container == .board, "the container survives even when the membership does not")
|
||||
|
||||
store.select([ItemID(rawValue: Ident.lane1)], liveness: .live)
|
||||
store.select([ItemID(rawValue: Ident.lane1)], in: .board)
|
||||
store.clearSelection()
|
||||
#expect(store.selection == .empty)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user