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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user