Unify the trashed universe on the trash rows

02 rules the trashed side has exactly one definition: the set with
trash rows — a card carrying its own deleted: under a tombstoned lane
is in neither universe, so an anchor or selection can never survive on
an item that renders nowhere. The membership rule now lives once, in
Liveness.walk, and ItemReferenceSet.idUniverse, TrashModel.entries,
paths, and emptyTrashTargets all derive from it — the old
lane-OR-card logic that admitted subsumed cards to the trashed side is
gone, and the two universes deliberately no longer partition the
board. Put Back, Delete Immediately, and Empty Trash outcomes are
unchanged: a tombstoned lane still moves and purges whole, its nested
tombstones with it.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 07:23:18 -04:00
parent 88364b20c0
commit 7be4eec9fd
5 changed files with 214 additions and 66 deletions
@@ -137,6 +137,56 @@ struct TransientBoardStateTests {
#expect(store.transient.pendingCut.ids == [card3], "card3's lane is untouched, so card3 stays cut")
}
@Test("A card with its own deleted: under a tombstoned lane is in neither universe")
func ownFlaggedCardUnderATombstonedLaneIsInNeitherUniverse() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Everything that can point at an item, all aimed at card3 and on the *trashed* side, as
// if the user had clicked its trash row a moment before its lane went too.
store.transient.select([card3], liveness: .trashed, anchor: card3, head: card3)
store.transient.dragMembers = ItemReferenceSet(ids: [card3], liveness: .trashed)
store.transient.pendingCut = ItemReferenceSet(ids: [card3], liveness: .trashed)
store.transient.beginRename(of: card3, currentTitle: "Third")
// Both flags at once: the card carries its own `deleted:` *and* an agent tombstones its lane.
try fixture.item("\(Ident.lane2)/\(Ident.card3)", tombstoned(order: "1024", title: "Third"))
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
await reload(store)
let lane = try #require(store.snapshot.lanes.first { $0.id == lane2 })
#expect(lane.isDeleted)
#expect(lane.cards.first { $0.id == card3 }?.isDeleted == true, "the card's own flag is on disk")
// The lane's single entry subsumes it (03-board-ui.md's absolute ancestor walk), so it has
// no row and "the trashed side has exactly one definition: the set with trash rows"
// (02-architecture.md, settled). No row, no membership, on either side.
#expect(!TrashModel.entries(of: store.snapshot).map(\.id).contains(card3))
#expect(!ItemReferenceSet.idUniverse(of: store.snapshot, on: .trashed).contains(card3))
#expect(!ItemReferenceSet.idUniverse(of: store.snapshot, on: .live).contains(card3))
// So every set holding it is ejected from the trashed side here, and from the live side
// for the same reason, which the value function says directly since a set has one side.
#expect(store.transient.selection.ids.isEmpty)
#expect(store.transient.dragMembers.ids.isEmpty)
#expect(store.transient.pendingCut.ids.isEmpty)
#expect(ItemReferenceSet(ids: [card3], liveness: .live).resolved(against: store.snapshot).isEmpty)
// And no cursor or editor survives on it: an anchor that ranges from somewhere the board
// draws nowhere would be a range the user cannot see the origin of.
#expect(store.transient.selectionAnchor == nil)
#expect(store.transient.selectionHead == nil)
#expect(store.transient.renameEditor == nil)
// Menu validation agrees, which is the point of the sets and the commands reading one rule:
// neither twin offers to act on it.
let stale = ItemReferenceSet(ids: [card3], liveness: .trashed)
#expect(!TrashModel.canActOnTrash(selection: stale, in: store.snapshot))
#expect(!TrashModel.canDelete(selection: ItemReferenceSet(ids: [card3], liveness: .live),
in: store.snapshot))
}
@Test("Every set resolves to empty against a board whose lanes all vanished")
func everythingResolvesToNothingOnAnEmptyBoard() async throws {
let fixture = try makeBoard()
+48 -6
View File
@@ -213,7 +213,7 @@ struct TrashModelContentsTests {
#expect(!TrashModel.isEmpty(try load(dirty)))
}
@Test("Paths resolve on the effective liveness side, in display order")
@Test("Paths resolve on the row set's liveness side, in display order")
func pathsResolveByEffectiveLiveness() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
@@ -224,22 +224,64 @@ struct TrashModelContentsTests {
]
// Live: the live lane and its live card. `card3` is live by its own flag but its lane is
// tombstoned, so the ancestor walk puts it on the other side.
// tombstoned, so the ancestor walk takes it off this side.
let liveSide = TrashModel.paths(of: everything, on: .live, in: model)
#expect(liveSide.map { ($0.laneID.rawValue, $0.cardID?.rawValue) }.map { "\($0.0)/\($0.1 ?? "-")" }
== ["\(Ident.lane1)/-", "\(Ident.lane1)/\(Ident.card2)"])
// Trashed: the tombstoned card, the tombstoned lane, and everything the lane hides in
// display order, lanes left to right and each lane before its cards.
// Trashed: the tombstoned card and the tombstoned lane the trash's two rows, in display
// order, lanes left to right and each lane before its cards. Nothing under `lane2` is here:
// its cards have no rows, and the lane's *folder* is what takes them (Put Back returns them
// with it; Delete Immediately purges them with it).
let trashedSide = TrashModel.paths(of: everything, on: .trashed, in: model)
#expect(trashedSide.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "-")" }
== ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane2)/-",
"\(Ident.lane2)/\(Ident.card3)", "\(Ident.lane2)/\(More.cardD)"])
== ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane2)/-"])
#expect(TrashModel.paths(of: [], on: .live, in: model).isEmpty)
#expect(TrashModel.paths(of: [ItemID(rawValue: Ident.indexless)], on: .trashed, in: model).isEmpty)
}
@Test("The trashed universe is exactly the trash's rows — one function, not two")
func trashedUniverseIsTheRowSet() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
// The guarantee 02-architecture.md settles: "universe and rows are one function, never a
// broader set with a pointer-side subset". Anything a set may reference on the trashed side
// is something the quasi-lane draws.
let rows = Set(TrashModel.entries(of: model).map(\.id))
let trashed = ItemReferenceSet.idUniverse(of: model, on: .trashed)
#expect(trashed == rows)
// And the two universes therefore do **not** partition the board. `cardD` carries its own
// `deleted:` under a tombstoned lane and `card3` rides along unflagged under the same one;
// both render nowhere, so neither is on either side.
let liveUniverse = ItemReferenceSet.idUniverse(of: model, on: .live)
for hidden in [ItemID(rawValue: More.cardD), ItemID(rawValue: Ident.card3)] {
#expect(!rows.contains(hidden))
#expect(!trashed.contains(hidden))
#expect(!liveUniverse.contains(hidden))
#expect(ItemReferenceSet(ids: [hidden], liveness: .trashed).resolved(against: model).isEmpty)
#expect(ItemReferenceSet(ids: [hidden], liveness: .live).resolved(against: model).isEmpty)
}
// Paths are the same walk in folder form, so they name the same things one per row.
#expect(TrashModel.paths(of: rows, on: .trashed, in: model).count == rows.count)
#expect(TrashModel.emptyTrashTargets(in: model).count == rows.count)
// And `isEmpty`'s short-circuit is that walk's non-emptiness, on both a dirty board and a
// clean one.
#expect(TrashModel.isEmpty(model) == rows.isEmpty)
let clean = try WriterFixture()
defer { clean.tearDown() }
try clean.item("", Item.board)
try clean.item(Ident.lane1, live(order: "1024", title: "Todo"))
let cleanModel = try load(clean)
#expect(TrashModel.isEmpty(cleanModel) == TrashModel.entries(of: cleanModel).isEmpty)
#expect(ItemReferenceSet.idUniverse(of: cleanModel, on: .trashed).isEmpty)
}
@Test("Empty Trash targets every tombstone, a tombstoned lane contributing only its own folder")
func emptyTrashTargets() throws {
let fixture = try makeBoard()