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:
@@ -24,22 +24,12 @@ private func makeMixedBoard() 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: "First"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
||||
try fixture.item(
|
||||
"\(Ident.lane1)/\(Ident.card3)",
|
||||
"---\nschema: 1\norder: 3072\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.card4)", Item.rich(order: "1024", title: "Buried"))
|
||||
try fixture.item(
|
||||
"\(Ident.lane2)/\(Ident.indexless)",
|
||||
"---\nschema: 1\norder: 2048\ntitle: Also buried\n---\nbody\n"
|
||||
)
|
||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Archive"))
|
||||
|
||||
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Doing"))
|
||||
// The trash: cards in a sibling container, never lanes (03-board-ui.md § Trash).
|
||||
try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Gone"))
|
||||
try fixture.item(".trash/\(Ident.card4)", Item.rich(order: "2048", title: "Also gone"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
@@ -76,54 +66,35 @@ struct AppModelTests {
|
||||
|
||||
// MARK: Live-only counts
|
||||
|
||||
@Test("The recents counts are live items only, at both levels")
|
||||
func liveCountsIgnoreTombstonesAndWhatHidesBeneathThem() throws {
|
||||
@Test("The recents counts are working items only — the trash is an errand, not inventory")
|
||||
func liveCountsExcludeTheTrash() throws {
|
||||
let fixture = try makeMixedBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
|
||||
// The snapshot itself keeps everything — tombstones are what the trash renders — so this is a
|
||||
// genuine filter, not a property of the load.
|
||||
#expect(snapshot.lanes.count == 3)
|
||||
#expect(snapshot.lanes.flatMap(\.cards).count == 5)
|
||||
// The snapshot itself keeps everything — the trash is a sibling container — so this is a
|
||||
// genuine exclusion, not a property of the load.
|
||||
#expect(snapshot.lanes.count == 2)
|
||||
#expect(snapshot.trash.count == 2)
|
||||
|
||||
let counts = AppModel.liveCounts(of: snapshot)
|
||||
#expect(counts.lanes == 2, "the tombstoned lane is not part of the board's working size")
|
||||
#expect(counts.cards == 2, "one tombstoned card, and two more hidden beneath a tombstoned lane")
|
||||
#expect(counts.lanes == 2)
|
||||
// 02 § Per-board app state, re-grounded 2026-07-28: "cards in `.trash/` don't count; the row
|
||||
// advertises the board's working size". The walk reads `snapshot.lanes` and the trash is
|
||||
// `snapshot.trash`, so the exclusion is by construction and none could be forgotten.
|
||||
#expect(counts.cards == 2)
|
||||
}
|
||||
|
||||
@Test("A board with nothing live counts zero rather than declining to answer")
|
||||
@Test("A board with nothing on it counts zero rather than declining to answer")
|
||||
func liveCountsOfAnEmptyBoard() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(
|
||||
Ident.lane1,
|
||||
"---\nschema: 1\norder: 1024\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
||||
)
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Buried"))
|
||||
try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "1024", title: "Gone"))
|
||||
|
||||
let counts = AppModel.liveCounts(of: try BoardLoader.load(boardRoot: fixture.root).model)
|
||||
#expect(counts.lanes == 0)
|
||||
#expect(counts.cards == 0)
|
||||
}
|
||||
|
||||
@Test("A malformed deleted: still counts as deleted")
|
||||
func liveCountsFollowPresenceNotValidity() 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)",
|
||||
"---\nschema: 1\norder: 1024\ntitle: Gone\ndeleted: yesterday\n---\nbody\n"
|
||||
)
|
||||
|
||||
// The presence of the key is what encodes deletion intent (`Card.isDeleted`), so an
|
||||
// unparseable timestamp hides the card here exactly as it hides it on the board.
|
||||
let counts = AppModel.liveCounts(of: try BoardLoader.load(boardRoot: fixture.root).model)
|
||||
#expect(counts.lanes == 1)
|
||||
#expect(counts.cards == 0)
|
||||
#expect(counts.cards == 0, "a board whose only content is trash advertises no working size")
|
||||
}
|
||||
|
||||
// MARK: Display name
|
||||
@@ -241,7 +212,7 @@ struct AppModelTests {
|
||||
#expect(model.storeRegistry.liveStore(for: fixture.root) == nil, "the last reference went with the session")
|
||||
|
||||
let record = try #require(model.boardRegistry.record(id: recordID))
|
||||
#expect(record.laneCount == 2, "the counts the welcome row will show are the live ones")
|
||||
#expect(record.laneCount == 2, "the counts the welcome row will show are the working ones")
|
||||
#expect(record.cardCount == 2)
|
||||
#expect(record.isOpenNow == false)
|
||||
#expect(model.boardRegistry.restorables().isEmpty)
|
||||
|
||||
Reference in New Issue
Block a user