Lanes delete into the trash — storage, loader, writer, and undo

Phase 1 of the lanes-in-trash card (2026-07-29 ruling, docs led the
code): lane delete is a move into .trash/ with the subtree intact,
arriving at top trash rank — no destructive delete remains outside
the trash.

TrashedLane opaque unit (id/schema/title/order/heldCards) beside
trash cards — deliberately not a Lane, so no card-shaped surface can
believe an empty subtree. Loader's trash walk trusts the kind VALUE
(lane → opaque unit w/ held-card count counted at the loader's own
unit; card → ordinary card; absent/unrecognized → UUID-children
shape, empty-kindless falls to card per 01's honest limit). Writer:
moveIntoTrash generalized with kind passed never derived (an empty
lane would re-derive as card), deleteLaneToTrash mints against the
whole-container rank ladder. Retired: migrateTombstonedLane (lane
deleted: now ignored — loads live, bytes inert, tolerate-tier
warning), removeLane, captureSubtree/recreateSubtree and the
subtree-snapshot machinery. Undo inverse = move back to captured
strip position, redo replays at captured trash rank. Purge walks
lane subtrees; TrashModel.Freight phrases confirms with lane freight
("…and its 5 cards"). ItemPath gains .trashLane; resolve interleaves
the trash by rank; SearchFilter matches lane rows by title only.
Trashed-lane card windows dismiss and pending cuts void via the
ordinary vanish rule — no new plumbing.

Phase 2 (rendering, selection grammar, drag, a11y, agent guide)
follows. Both schemes 1858 tests / 318 suites green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-30 16:16:57 -04:00
parent 785ef5fe14
commit 8014bde7c6
21 changed files with 1357 additions and 910 deletions
+119
View File
@@ -292,6 +292,125 @@ struct TrashPhrasingTests {
}
}
// MARK: - Lane freight
/// "Confirms name the freight honestly a trashed lane's alert counts its cards" (03 § Trash,
/// re-ruled 2026-07-29). The count is `TrashedLane.heldCards`, which the loader took at load
/// precisely because the subtree it counts is deliberately not in the snapshot.
@MainActor
@Suite("TrashModel ▸ lane freight in the confirmations")
struct TrashFreightTests {
/// A board whose trash holds one card and one lane carrying `held` cards.
private func makeFreightBoard(held: Int) throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(More.laneA, card(order: "1024", title: "Todo"))
try fixture.item(".trash/\(More.cardD)", card(order: "1024", title: "A card"))
try fixture.item(".trash/\(Ident.lane2)", "---\nschema: 1\ntitle: Doing\norder: 512\nkind: lane\n---\n")
for index in 0 ..< held {
try fixture.item(
".trash/\(Ident.lane2)/\(UUID().uuidString.lowercased())",
card(order: "\((index + 1) * 1024)", title: "Freight \(index)")
)
}
return fixture
}
/// 03's own phrasing, verbatim: "Permanently delete lane 'Doing' and its 5 cards".
@Test("A sole trashed lane names itself and its freight")
func soleLaneNamesItsFreight() throws {
let fixture = try makeFreightBoard(held: 5)
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let prompt = try #require(TrashModel.purgePrompt(
for: [ItemID(rawValue: Ident.lane2)], snapshot: snapshot, unrecoverable: true
))
#expect(prompt.title == "Permanently delete lane \u{201C}Doing\u{201D} and its 5 cards?")
#expect(prompt.message == "This can\u{2019}t be undone.")
}
/// An empty lane has no freight clause to add "2 lanes containing 0 cards" would say less
/// than naming the lane.
@Test("An empty trashed lane names only itself")
func emptyLaneNamesOnlyItself() throws {
let fixture = try makeFreightBoard(held: 0)
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let prompt = try #require(TrashModel.purgePrompt(
for: [ItemID(rawValue: Ident.lane2)], snapshot: snapshot, unrecoverable: true
))
#expect(prompt.title == "Permanently delete lane \u{201C}Doing\u{201D}?")
}
@Test("A sole held card folds to the singular")
func oneHeldCardFoldsSingular() throws {
let fixture = try makeFreightBoard(held: 1)
defer { fixture.tearDown() }
let prompt = try #require(TrashModel.purgePrompt(
for: [ItemID(rawValue: Ident.lane2)], snapshot: try load(fixture), unrecoverable: true
))
#expect(prompt.title == "Permanently delete lane \u{201C}Doing\u{201D} and its 1 card?")
}
/// 03's other example phrasing: " 41 cards and 2 lanes containing 9 more cards".
@Test("Empty Trash counts both kinds and the lanes' freight")
func emptyTrashCountsFreight() throws {
let fixture = try makeFreightBoard(held: 5)
defer { fixture.tearDown() }
let prompt = try #require(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true))
#expect(prompt.title == "Permanently delete 1 card and 1 lane containing 5 more cards?")
}
/// The subject builder as a pure value, so the phrasing rules are pinned without a board:
/// **"more" only where cards precede it**, and no clause for freight that is not there.
@Test("The aggregate subject folds every shape")
func theSubjectFolds() {
func subject(cards: Int, lanes: Int, held: Int) -> String {
TrashModel.subject(for: TrashModel.Freight(cards: cards, lanes: lanes, heldCards: held))
}
#expect(subject(cards: 41, lanes: 0, held: 0) == "41 cards")
#expect(subject(cards: 1, lanes: 0, held: 0) == "1 card")
#expect(subject(cards: 41, lanes: 2, held: 9) == "41 cards and 2 lanes containing 9 more cards")
#expect(subject(cards: 0, lanes: 2, held: 9) == "2 lanes containing 9 cards",
"nothing precedes it, so nothing is 'more'")
#expect(subject(cards: 0, lanes: 1, held: 1) == "1 lane containing 1 card")
#expect(subject(cards: 3, lanes: 1, held: 0) == "3 cards and 1 lane")
}
/// A trashed lane is deletable from the trash like any entry the menu validation is by
/// container, and the kind never enters it.
@Test("Delete is enabled on a trash lane selection, and resolves to a purgeable path")
func laneRowsValidate() throws {
let fixture = try makeFreightBoard(held: 2)
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let selection = ItemReferenceSet(ids: [ItemID(rawValue: Ident.lane2)], container: .trash)
#expect(TrashModel.canDelete(selection: selection, in: snapshot))
#expect(ItemPath.resolve(selection.ids, in: .trash, snapshot: snapshot)
== [.trashLane(ItemID(rawValue: Ident.lane2))])
}
/// The column is one list: `resolve` hands back the trash's paths interleaved by rank, because
/// a batch's order is the column's order (03 § Trash).
@Test("Resolution interleaves the container's two kinds by rank")
func resolutionInterleaves() throws {
let fixture = try makeFreightBoard(held: 0)
defer { fixture.tearDown() }
let snapshot = try load(fixture)
// The lane sits at 512 and the card at 1024, so the lane row comes first.
#expect(ItemPath.resolve(
[ItemID(rawValue: Ident.lane2), cardD], in: .trash, snapshot: snapshot
) == [.trashLane(ItemID(rawValue: Ident.lane2)), .trashCard(cardD)])
}
}
// MARK: - Menu validation
@MainActor