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:
2026-07-28 17:47:56 -04:00
parent 16c10d61c3
commit 53bc71f7fb
53 changed files with 3459 additions and 3655 deletions
+22 -28
View File
@@ -3,14 +3,18 @@ import Testing
@testable import Kanban
/// A card window's whole lifecycle is one decision re-taken on every snapshot: does this key still
/// name a card? Four answers, three of which are "no" for different reasons, and the one that is
/// easiest to get wrong a live card under a tombstoned lane is invisible in the card's own data.
/// So the decision is a pure function and this is its suite; nothing here needs a window.
/// name a card **on the board**? The decision is a pure function and this is its suite; nothing here
/// needs a window.
///
/// **The walk got simpler with the materialized trash** (05-card-window.md Deletion & lifecycle,
/// resettled 2026-07-28): "entering the trash counts as deleted", and a trashed card's folder has
/// physically left its lane so "is it under one of this board's lanes" is the whole question, and
/// the tombstone era's ancestor walk is gone.
// MARK: - Fixtures
/// - lane 1 (live): one live card, one tombstoned card
/// - lane 2 (**tombstoned**): one live card, whose own flag is clear
/// - lane 1: one card
/// - the trash: one card, moved there by a delete
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
@@ -18,16 +22,7 @@ private func makeBoard() throws -> WriterFixture {
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
try fixture.item(
"\(Ident.lane1)/\(Ident.card2)",
"---\nschema: 1\norder: 2048\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
)
try fixture.item(
Ident.lane2,
"---\nschema: 1\norder: 2048\ntitle: Archive\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
)
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Buried alive"))
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Gone"))
return fixture
}
@@ -62,30 +57,29 @@ struct CardWindowFateTests {
#expect(laneTitle(fate) == "Todo")
}
@Test("A tombstoned card dismisses its window")
func aTombstonedCardDismisses() throws {
@Test("A card in the trash dismisses its window")
func aTrashedCardDismisses() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// on the board closes the card's open window "a tombstone counts as deleted"
// (05-card-window.md). The card is still in the snapshot; the trash renders it.
#expect(snapshot.lanes[0].cards.contains { $0.id.rawValue == Ident.card2 })
// on the board closes the card's open window "entering the trash counts as deleted"
// (05-card-window.md). The card is still in the snapshot; the trash column renders it.
#expect(snapshot.trash.contains { $0.id.rawValue == Ident.card2 })
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card2, in: snapshot) == .dismisses)
}
@Test("A live card under a tombstoned lane dismisses too — liveness is ancestor-walked")
func aLaneTombstoneDismissesItsCards() throws {
@Test("Deleting a card's lane dismisses its window, because the card is gone with it")
func aLaneDeleteDismissesItsCards() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// The card's own flag says nothing is wrong. Its lane's does, and 03-board-ui.md collapses a
// tombstoned lane to one restorable trash entry so the card renders nowhere, and a window
// onto something that renders nowhere is the case this walk exists for.
let buried = try #require(snapshot.lanes.first { $0.id.rawValue == Ident.lane2 }?.cards.first)
#expect(!buried.isDeleted)
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card3, in: snapshot) == .dismisses)
// "Deleting the card's *lane* deletes the card with it the window dismisses because the
// card is gone" (05). A lane delete is physical, so this needs no ancestor walk: the card
// is simply not in the snapshot.
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card1, in: snapshot) == .dismisses)
}
@Test("A card that is not in this board's snapshot dismisses — the cross-board move")