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
+24
View File
@@ -82,6 +82,30 @@ struct CardWindowFateTests {
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card1, in: snapshot) == .dismisses)
}
/// The lane's own delete is a *move* now (03 § Trash, re-ruled 2026-07-29), and the dismissal
/// still falls out of the same absence rather than needing a rule: a trashed lane is an opaque
/// unit, so its cards are not in the snapshot at all not under a lane, not among the trash's
/// cards and the walk simply does not find them.
@Test("Trashing a card's lane dismisses its window too — the freight leaves the snapshot with it")
func aLaneTrashedDismissesItsCards() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try BoardWriter.deleteLaneToTrash(
at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024
)
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
#expect(snapshot.trashedLanes.map(\.id.rawValue) == [Ident.lane1])
#expect(!snapshot.trash.contains { $0.id.rawValue == Ident.card1 })
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card1, in: snapshot) == .dismisses)
// And the same absence is what ejects it from every item-referencing set selection, the
// pending cut, drag membership (02-architecture.md § Live-reload resilience).
#expect(!ItemContainer.board.ids(in: snapshot).contains(ItemID(rawValue: Ident.card1)))
#expect(!ItemContainer.trash.ids(in: snapshot).contains(ItemID(rawValue: Ident.card1)))
#expect(ItemContainer.trash.ids(in: snapshot).contains(ItemID(rawValue: Ident.lane1)),
"the lane row itself is in the trash's universe")
}
@Test("A card that is not in this board's snapshot dismisses — the cross-board move")
func anAbsentCardDismisses() throws {
let fixture = try makeBoard()
+3 -1
View File
@@ -269,7 +269,9 @@ struct FixtureTombstonesTests {
let cardUnderDeadLane = "50000000-0000-4000-8000-000000000005"
let result = try loadFixture("Valid/tombstones.kanban")
#expect(result.warnings.isEmpty)
// The lane's key is the tolerate tier's since 2026-07-29 ignored, logged, and the lane
// loads live (01 § Deletion, lane clause).
#expect(result.warnings == [.laneLevelDeletedIgnored(path: laneDead)])
#expect(result.model.lanes.map(\.id.rawValue) == [laneLive, laneDead])
let live = try #require(result.model.lanes.first { $0.id.rawValue == laneLive })
+4 -4
View File
@@ -292,7 +292,7 @@ struct IntegrityDefectTests {
LooseCardFiles(laneID: lane, cardID: card, title: nil, fileNames: ["a.txt"])
).healClass == .looseCardFiles)
#expect(IntegrityRules.Defect.legacyTombstone(
LegacyTombstone(kind: .lane, laneID: lane, cardID: nil, title: nil)
LegacyTombstone(laneID: lane, cardID: card, title: nil)
).healClass == .legacyTombstone)
#expect(IntegrityRules.Defect.claimedNameSquatted(
ClaimedNameSquatter(name: ".trash", found: .file, expected: .directory)
@@ -318,10 +318,10 @@ struct IntegrityDefectTests {
@Test("Signatures identify the work, not its display")
func signaturesIdentifyTheWork() {
let one = IntegrityRules.Defect.legacyTombstone(
LegacyTombstone(kind: .card, laneID: lane, cardID: card, title: "Before")
LegacyTombstone(laneID: lane, cardID: card, title: "Before")
)
let two = IntegrityRules.Defect.legacyTombstone(
LegacyTombstone(kind: .card, laneID: lane, cardID: card, title: "After")
LegacyTombstone(laneID: lane, cardID: card, title: "After")
)
#expect(one.signatures == two.signatures)
}
@@ -334,7 +334,7 @@ struct IntegrityDefectTests {
LooseCardFiles(laneID: lane, cardID: card, title: nil, fileNames: ["x"])
)
let tombstone = IntegrityRules.Defect.legacyTombstone(
LegacyTombstone(kind: .card, laneID: lane, cardID: card, title: nil)
LegacyTombstone(laneID: lane, cardID: card, title: nil)
)
let squatter = IntegrityRules.Defect.claimedNameSquatted(
ClaimedNameSquatter(name: ".trash", found: .symlink, expected: .directory)
+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
+284 -161
View File
@@ -140,10 +140,12 @@ struct TrashContainerLoadTests {
#expect(result.warnings.count == 2)
}
/// "A lane-shaped nesting inside `.trash` is a stray": the walk stops at a card in the trash
/// exactly as it does under a lane, so a UUID-shaped folder *inside* a trash card is content,
/// never a level invisible, preserved, and not warned about (a card's subfolders never are).
@Test("A lane-shaped nesting inside .trash renders as one card, its children invisible")
/// The walk stops at a trash entry exactly as it stops at a card under a lane, so a UUID-shaped
/// folder *inside* one is never a level. What that folder makes of its parent changed with the
/// lanes-in-trash ruling: a kindless entry holding identity-shaped children with their own
/// `index.md` reads as a **lane** by shape (01 § Deletion), one opaque row whose children are
/// counted rather than surfaced invisible, preserved, and not warned about.
@Test("A lane-shaped nesting inside .trash reads as one opaque lane row, its children counted")
func laneShapedNestingInsideTrash() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
@@ -157,7 +159,9 @@ struct TrashContainerLoadTests {
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trash.map(\.id.rawValue) == [outer])
#expect(result.model.trashedLanes.map(\.id.rawValue) == [outer])
#expect(result.model.trashedLanes.first?.heldCards == 1)
#expect(result.model.trash.isEmpty)
#expect(result.warnings.isEmpty)
// Nowhere in the snapshot, and still on disk.
#expect(!result.model.lanes.contains { $0.cards.contains { $0.id.rawValue == nested } })
@@ -277,33 +281,43 @@ struct LegacyTombstoneDetectionTests {
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.legacyTombstones == [
LegacyTombstone(kind: .card, laneID: ItemID(rawValue: lane), cardID: ItemID(rawValue: card), title: "Gone")
LegacyTombstone(laneID: ItemID(rawValue: lane), cardID: ItemID(rawValue: card), title: "Gone")
])
// Still rendered by the retiring path read-only detection has moved nothing yet.
#expect(result.model.lanes[0].cards.map(\.isDeleted) == [true])
#expect(result.model.trash.isEmpty)
}
@Test("A tombstoned lane is reported with no card, and keeps loading flagged")
func tombstonedLaneIsReported() throws {
/// "A lane carrying `deleted:` simply loads **live** with the key ignored no migration
/// machinery, no key-strip write, no notice" (01 § Deletion, lane clause re-ruled 2026-07-29):
/// the tolerate tier's whole verdict, and the bytes are never touched.
@Test("A tombstoned lane loads live, is warned rather than migrated, and keeps its bytes")
func tombstonedLaneIsTolerated() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let lane = uuidName()
let text = "schema: 1\norder: 1024\ntitle: Old lane\ndeleted: 2026-01-01T00:00:00Z\n"
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\ntitle: Old lane\ndeleted: 2026-01-01T00:00:00Z\n")
try fixture.index(lane, text)
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.legacyTombstones == [
LegacyTombstone(kind: .lane, laneID: ItemID(rawValue: lane), cardID: nil, title: "Old lane")
])
#expect(result.model.lanes[0].isDeleted)
#expect(result.legacyTombstones.isEmpty, "the lane half of the migration is retired")
#expect(result.warnings == [.laneLevelDeletedIgnored(path: lane)])
#expect(result.model.lanes.map(\.title.value) == ["Old lane"], "it loads live")
#expect(result.model.lanes[0].isDeleted, "the key is still on disk — it just decides nothing")
#expect(
try String(contentsOf: fixture.root.appendingPathComponent("\(lane)/index.md"), encoding: .utf8)
== "---\n" + text + "---\n",
"preserved verbatim, like any unhandled key"
)
}
/// Presence, not validity the same rule the flag has always read by, so a broken timestamp
/// still migrates rather than being left behind as the one key the pivot forgot.
@Test("A malformed deleted value is still migration input")
/// still migrates rather than being left behind as the one key the pivot forgot. The lane beside
/// it takes the tolerate posture whatever its value's condition.
@Test("A malformed deleted value is still migration input on a card, still inert on a lane")
func malformedDeletedIsStillReported() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
@@ -316,8 +330,8 @@ struct LegacyTombstoneDetectionTests {
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.legacyTombstones.map(\.kind) == [.card, .lane])
#expect(result.legacyTombstones.map(\.cardID) == [ItemID(rawValue: card), nil])
#expect(result.legacyTombstones.map(\.cardID) == [ItemID(rawValue: card)])
#expect(result.warnings == [.laneLevelDeletedIgnored(path: lane)])
}
/// "A `deleted:` key at board level remains meaningless ignored and logged, preserved
@@ -553,10 +567,11 @@ struct TombstoneMigrationTests {
#expect(result.model.trash.map(\.id.rawValue) == [Ident.card1])
}
/// "A lane carrying `deleted:` returns **live** with the key removed" resurrection is the
/// safe direction, and the folder does not move, so it returns to its own position.
@Test("A tombstoned lane keeps its place and returns live")
func laneMigratesInPlace() throws {
/// The lane half of this migration is **retired** (01 § Deletion, re-ruled 2026-07-29): there is
/// no `migrateTombstonedLane` to call, and the loader hands the store no lane work to do the
/// lane simply loads live with the key inert (`LegacyTombstoneDetectionTests`).
@Test("A tombstoned lane is no longer migration input at all")
func laneIsNotMigrationInput() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
@@ -572,74 +587,124 @@ struct TombstoneMigrationTests {
Lane notes.
""")
let cardBefore = Item.rich(order: "1024", title: "Kept")
try fixture.item("\(Ident.lane1)/\(Ident.card1)", cardBefore)
try BoardWriter.migrateTombstonedLane(at: fixture.url(Ident.lane1))
let text = try fixture.indexText(Ident.lane1)
#expect(!text.contains("deleted:"))
#expect(text.contains("width: 2"))
#expect(text.contains("Lane notes."))
#expect(try FrontmatterDocument.parse(text).order == .valid(2048))
// Its cards are none of the lane migration's business.
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == cardBefore)
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.legacyTombstones.isEmpty)
#expect(result.model.lanes.map(\.isDeleted) == [false])
}
@Test("The lane migration refuses a card, whose migration is a move")
func laneMigrationRefusesACard() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card"))
let error = writeFailure {
try BoardWriter.migrateTombstonedLane(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
}
#expect(error?.operation == .migrateTombstone(title: nil))
#expect(result.model.lanes.map(\.title.value) == ["Old lane"])
}
}
// MARK: - Writer: lane delete is physical
// MARK: - Writer: a lane delete is the same move
@Suite("BoardWriter ▸ removeLane")
struct RemoveLaneTests {
@Suite("BoardWriter ▸ deleteLaneToTrash")
struct DeleteLaneToTrashTests {
@Test("The lane folder and everything under it go")
func laneIsRemovedWhole() throws {
/// "Deleting a lane moves its folder subtree intact into `.trash/`, exactly as a card moves"
/// (03 § Trash, re-ruled 2026-07-29). Byte fidelity of the freight is the assertion: nothing
/// under the lane is read or rewritten, so a restore is an ordinary move back.
@Test("The lane folder moves whole, its subtree byte-identical")
func laneMovesWithItsSubtree() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF])
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card"))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([1]))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Other"))
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Doing"))
let cardBefore = Item.rich(order: "1024", title: "Kept")
try fixture.item("\(Ident.lane1)/\(Ident.card1)", cardBefore)
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.uneditable)
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", png)
try fixture.file("\(Ident.lane1)/notes/scratch.md", Data("scratch".utf8))
try BoardWriter.removeLane(at: fixture.url(Ident.lane1))
let id = try BoardWriter.deleteLaneToTrash(
at: fixture.url(Ident.lane1),
inBoard: fixture.root,
order: -1024
)
#expect(id == ItemID(rawValue: Ident.lane1), "a delete moves a folder, it does not rename one")
#expect(!fixture.exists(Ident.lane1))
#expect(fixture.exists(Ident.lane2))
#expect(try fixture.indexText(".trash/\(Ident.lane1)/\(Ident.card1)") == cardBefore)
#expect(try fixture.indexText(".trash/\(Ident.lane1)/\(Ident.card2)") == Item.uneditable,
"even a card whose own index.md refuses writes — nothing below the root is touched")
#expect(try fixture.data(".trash/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == png)
#expect(try fixture.data(".trash/\(Ident.lane1)/notes/scratch.md") == Data("scratch".utf8),
"strays ride along like everything else")
}
@Test("A lane that is already gone is success")
func absentLaneIsSuccess() throws {
/// The container-changing move stamps both provenance keys (01 § Frontmatter `modified`'s
/// scope, refined 2026-07-30) the same rule a card's trash move obeys.
@Test("The rank is rewritten, modified stamped and modified-by cleared")
func theRankRewriteStamps() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try BoardWriter.removeLane(at: fixture.url(Ident.lane1))
try fixture.item(Ident.lane1, """
---
schema: 1
title: Doing
order: 2048
modified: 2020-01-01T00:00:00Z
modified-by: claude
width: 3
---
Lane notes.
""")
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024)
let document = try FrontmatterDocument.parse(fixture.indexText(".trash/\(Ident.lane1)"))
#expect(document.order == .valid(-1024))
#expect(document.modifiedBy.isMissing)
#expect(document.modified.value.map { $0 > Date(timeIntervalSince1970: 1_600_000_000) } == true)
let text = try fixture.indexText(".trash/\(Ident.lane1)")
#expect(text.contains("width: 3"), "everything else round-trips")
#expect(text.contains("Lane notes."))
}
/// The guard has to tell a lane from a card *and* from a trash card, whose parent is `.trash/`
/// rather than a UUID otherwise a permanent delete would be reachable through this door.
@Test("A card, a trash card, a board root and a stray are all refused")
func onlyLanesAreRemoved() throws {
/// "`kind: lane` backfilled on touch when absent the trash move's rank mint included"
/// (01 § Deletion). The **empty** lane is the case that needs it: in a flat container it is
/// shape-identical to a card, so a derived kind would answer wrongly and the row would come back
/// as a card.
@Test("The move stamps kind: lane, even on an empty lane where shape would say card")
func kindIsStampedNotGuessed() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Empty\norder: 1024\n---\n")
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024)
#expect(try fixture.indexText(".trash/\(Ident.lane1)").contains("kind: lane"))
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.trashKinds[ItemID(rawValue: Ident.lane1)] == .lane)
#expect(result.model.trashedLanes.map(\.id.rawValue) == [Ident.lane1])
#expect(result.model.trash.isEmpty)
}
/// A present `kind` is never rewritten and never corroborated the value names the kind
/// (01 § Frontmatter, the `kind` row).
@Test("An existing kind is left exactly as it was")
func existingKindIsLeftAlone() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, "---\nschema: 1\norder: 1024\nkind: lane\n---\n")
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024)
let text = try fixture.indexText(".trash/\(Ident.lane1)")
#expect(text.components(separatedBy: "kind: lane").count == 2, "written once, not twice")
}
/// The guard is the mirror of `deleteCardToTrash`'s: a lane is `<root>/<lane>`, so a card, a
/// board root, a stray, and an entry already in `.trash/` are all refused.
@Test("A card, a trash entry, a board root and a stray are all refused")
func onlyLanesTakeThisDoor() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
@@ -650,107 +715,27 @@ struct RemoveLaneTests {
try fixture.item("notes", Item.rich(order: "1024", title: "Stray"))
for target in ["\(Ident.lane1)/\(Ident.card1)", ".trash/\(Ident.card2)", "", "notes"] {
#expect(writeFailure { try BoardWriter.removeLane(at: fixture.url(target)) } != nil)
#expect(
writeFailure {
try BoardWriter.deleteLaneToTrash(
at: fixture.url(target),
inBoard: fixture.root,
order: -1024
)
} != nil
)
}
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
#expect(fixture.exists(".trash/\(Ident.card2)"))
}
}
// MARK: - Writer: capture and replay
@Suite("BoardWriter ▸ subtree capture and replay")
struct SubtreeCaptureTests {
/// A lane delete's undo in one round trip: capture, remove, recreate, capture again. Equality
/// of the two captures is the byte-fidelity assertion names, bytes, nesting and order.
@Test("Capture → remove → recreate → capture is identical, nested cards and attachments included")
func roundTripIsByteIdentical() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF, 0x0D, 0x0A])
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "One"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.uneditable)
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", png)
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/sub/deep.bin", Data([7, 8, 9]))
// The verbatim promise covers what the loader would never render, too.
try fixture.file("\(Ident.lane1)/\(Ident.card1)/.DS_Store", Data([0, 1]))
try fixture.file("\(Ident.lane1)/notes/scratch.md", Data("scratch".utf8))
try FileManager.default.createDirectory(
at: fixture.url("\(Ident.lane1)/\(Ident.indexless)"),
withIntermediateDirectories: true
)
let lane = fixture.url(Ident.lane1)
let captured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: "Lane"))
try BoardWriter.removeLane(at: lane)
#expect(!fixture.exists(Ident.lane1))
try BoardWriter.recreateSubtree(at: lane, from: captured, operation: .delete(title: "Lane"))
let recaptured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: "Lane"))
#expect(recaptured == captured)
// and spot-checked against disk, so the equality is not two identical bugs.
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)") == Item.uneditable)
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == png)
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/.DS_Store") == Data([0, 1]))
#expect(try fixture.data("\(Ident.lane1)/notes/scratch.md") == Data("scratch".utf8))
#expect(try fixture.entryNames(Ident.lane1).contains(Ident.indexless))
}
@Test("A symlink is captured as a link and recreated as one, never followed")
func symlinksAreNotFollowed() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
try FileManager.default.createSymbolicLink(
atPath: fixture.url(Ident.lane1).appendingPathComponent("link").path,
withDestinationPath: "../nowhere"
)
let lane = fixture.url(Ident.lane1)
let captured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: nil))
#expect(captured.entries.contains(.symlink(name: "link", destination: "../nowhere")))
try BoardWriter.removeLane(at: lane)
try BoardWriter.recreateSubtree(at: lane, from: captured, operation: .delete(title: nil))
let destination = try FileManager.default.destinationOfSymbolicLink(
atPath: lane.appendingPathComponent("link").path
)
#expect(destination == "../nowhere")
}
@Test("Recreating over something that exists refuses rather than merging")
func recreateRefusesToClobber() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
let lane = fixture.url(Ident.lane1)
let captured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: nil))
let error = writeFailure {
try BoardWriter.recreateSubtree(at: lane, from: captured, operation: .delete(title: nil))
}
#expect(error?.reason == .io(message: "something already exists here"))
// The refusal left the folder that was there exactly as it was.
#expect(try fixture.indexText(Ident.lane1).contains("title: Lane"))
}
}
// MARK: - Writer: permanent removal
@Suite("BoardWriter ▸ purging the trash")
struct TrashContainerPurgeTests {
@Test("A trash card purges; a live card is unreachable through this door")
@Test("A trash entry purges; a live card is unreachable through this door")
func purgeIsScopedToTheTrash() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
@@ -760,11 +745,11 @@ struct TrashContainerPurgeTests {
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Live"))
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Trashed"))
try BoardWriter.purgeTrashCard(at: fixture.url(".trash/\(Ident.card2)"), inBoard: fixture.root)
try BoardWriter.purgeTrashEntry(at: fixture.url(".trash/\(Ident.card2)"), inBoard: fixture.root)
#expect(!fixture.exists(".trash/\(Ident.card2)"))
let error = writeFailure {
try BoardWriter.purgeTrashCard(
try BoardWriter.purgeTrashEntry(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root
)
@@ -773,7 +758,7 @@ struct TrashContainerPurgeTests {
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
// Already gone is success, as everywhere else on the purge path.
try BoardWriter.purgeTrashCard(at: fixture.url(".trash/\(Ident.card2)"), inBoard: fixture.root)
try BoardWriter.purgeTrashEntry(at: fixture.url(".trash/\(Ident.card2)"), inBoard: fixture.root)
}
@Test("Empty Trash takes every card and leaves a hand-editor's strays standing")
@@ -801,6 +786,44 @@ struct TrashContainerPurgeTests {
try fixture.item("", Item.board)
#expect(try BoardWriter.emptyTrash(inBoard: fixture.root).isEmpty)
}
/// "Permanent deletion walks lane subtrees" (03 § Trash): the trashed lane's freight goes with
/// it, through the same recursive removal a card takes.
@Test("Purging a trashed lane takes its whole subtree")
func purgingALaneTakesItsFreight() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(".trash/\(Ident.lane1)", "---\nschema: 1\norder: 1024\nkind: lane\n---\n")
try fixture.item(".trash/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Freight"))
try fixture.file(".trash/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([1]))
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "2048", title: "Beside it"))
try BoardWriter.purgeTrashEntry(at: fixture.url(".trash/\(Ident.lane1)"), inBoard: fixture.root)
#expect(!fixture.exists(".trash/\(Ident.lane1)"))
#expect(fixture.exists(".trash/\(Ident.card2)"), "its neighbour is untouched")
}
@Test("Empty Trash walks lane subtrees too")
func emptyTrashWalksLanes() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(".trash/\(Ident.lane1)", "---\nschema: 1\norder: 1024\nkind: lane\n---\n")
try fixture.item(".trash/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Freight"))
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "2048", title: "Card"))
try fixture.file(".trash/readme.txt", Data("mine".utf8))
let purged = try BoardWriter.emptyTrash(inBoard: fixture.root)
#expect(Set(purged) == [ItemID(rawValue: Ident.lane1), ItemID(rawValue: Ident.card2)])
#expect(!fixture.exists(".trash/\(Ident.lane1)"))
#expect(!fixture.exists(".trash/\(Ident.card2)"))
#expect(try fixture.data(".trash/readme.txt") == Data("mine".utf8), "strays still stand")
}
}
// MARK: - Board-wide uniqueness spans the trash
@@ -887,6 +910,9 @@ struct TrashKindDiscriminatorTests {
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.trashKinds[ItemID(rawValue: entry)] == .lane)
#expect(result.model.trashedLanes.map(\.id.rawValue) == [entry])
#expect(result.model.trashedLanes.first?.heldCards == 0, "an honored lane can be empty")
#expect(result.model.trash.isEmpty, "and it is not a card anywhere")
}
/// No key, or a value outside the schema's three, falls through to shape UUID-shaped children
@@ -938,7 +964,104 @@ struct TrashKindDiscriminatorTests {
) { $0.set(FrontmatterKeys.order, to: .double(4096)) }
let after = try BoardLoader.load(boardRoot: fixture.root)
#expect(after.model.trash.first?.document.kind == .valid("lane"))
#expect(after.model.trashedLanes.first?.document.kind == .valid("lane"))
#expect(after.trashKinds[ItemID(rawValue: entry)] == .lane)
}
/// **The opaque unit** (03 § Trash): a trashed lane is one row with a title, a rank and a count.
/// Its cards are not in the snapshot at all not as trash cards, not as anybody's lane's cards
/// and the count is what the loader would have rendered as cards, so the row's number and a
/// restore's outcome agree.
@Test("A trashed lane surfaces as one opaque row: title, rank, held-card count")
func trashedLaneIsOpaque() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let lane = uuidName()
let held = [uuidName(), uuidName(), uuidName()]
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(lane)", "schema: 1\norder: 1024\ntitle: Doing\nkind: lane\n")
for (index, card) in held.enumerated() {
try fixture.index(".trash/\(lane)/\(card)", "schema: 1\norder: \((index + 1) * 1024)\n")
}
// Not a card, so not counted: a stray folder and an identity-shaped one with no index.md.
try fixture.index(".trash/\(lane)/attachments", "schema: 1\norder: 1024\n")
try fixture.folder(".trash/\(lane)/\(uuidName())")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trashedLanes.count == 1)
let row = try #require(result.model.trashedLanes.first)
#expect(row.id == ItemID(rawValue: lane))
#expect(row.title.value == "Doing")
#expect(row.order == 1024)
#expect(row.heldCards == 3)
#expect(result.model.trash.isEmpty, "the freight is not surfaced as trash cards")
#expect(result.model.lanes.isEmpty)
#expect(result.warnings.isEmpty, "nothing inside an opaque entry is walked, so nothing strays")
}
/// The ruling's own **honest limit**, pinned so it stays a decision rather than a surprise: "a
/// kind-less trashed lane emptied of its children before any touch becomes indistinguishable
/// from a card" (01 § Deletion). The app never produces one `deleteLaneToTrash` stamps the
/// key so this is only reachable by a hand-editor.
@Test("A kindless empty lane folder reads as a card — the on-touch-only limit")
func kindlessEmptyLaneReadsAsACard() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let entry = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(entry)", "schema: 1\norder: 1024\ntitle: Was a lane\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.trashKinds[ItemID(rawValue: entry)] == .card)
#expect(result.model.trash.map(\.id.rawValue) == [entry])
#expect(result.model.trashedLanes.isEmpty)
}
/// Both kinds are validated by the one rulebook: `schema` and `order` are required of a lane
/// exactly as of a card (`IntegrityRules.requiresOrder`), so a malformed entry fails fast
/// whichever kind the discriminator would have called it.
@Test("A trashed lane missing order fails the load, like any entry")
func trashedLaneFailsFastOnOrder() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let lane = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(lane)", "schema: 1\nkind: lane\n")
try fixture.index(".trash/\(lane)/\(uuidName())", "schema: 1\norder: 1024\n")
#expect(throws: BoardLoadError.self) {
try BoardLoader.load(boardRoot: fixture.root)
}
}
/// The column is one list interleaved by rank (03 § Trash), which the snapshot expresses as two
/// arrays carrying the ranks that interleave them so a consumer merging by `order` gets the
/// column, and neither array is "after" the other.
@Test("Both kinds carry the ranks that interleave them")
func kindsInterleaveByRank() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let newestLane = uuidName()
let middleCard = uuidName()
let oldestLane = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(oldestLane)", "schema: 1\norder: 3072\nkind: lane\n")
try fixture.index(".trash/\(middleCard)", "schema: 1\norder: 2048\n")
try fixture.index(".trash/\(newestLane)", "schema: 1\norder: 1024\nkind: lane\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trashedLanes.map(\.id.rawValue) == [newestLane, oldestLane])
#expect(result.model.trash.map(\.id.rawValue) == [middleCard])
let column = (result.model.trash.map { (order: $0.order, id: $0.id) }
+ result.model.trashedLanes.map { (order: $0.order, id: $0.id) })
.sorted { $0.order < $1.order }
.map(\.id.rawValue)
#expect(column == [newestLane, middleCard, oldestLane])
}
}
+78 -44
View File
@@ -267,28 +267,73 @@ struct DeleteCardTests {
}
}
// MARK: - Delete: a lane is physical
// MARK: - Delete: a lane takes the same move
@MainActor
@Suite("BoardStore ▸ delete a lane")
struct DeleteLaneTests {
@Test("Deleting a lane removes the folder and its contents — nothing is trashed")
func laneDeleteIsPhysical() throws {
/// 03-board-ui.md § Trash, re-ruled 2026-07-29: "deleting a lane moves its folder subtree
/// intact into `.trash/`, exactly as a card moves". The freight is inside the folder that
/// moved, so it is not filed separately and not surfaced.
@Test("Deleting a lane moves the folder into .trash/, subtree intact and opaque")
func laneDeleteIsAMoveIntoTheTrash() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.delete([lane3])
// 03-board-ui.md § Trash: "Cards only. Lanes are never trashed deleting a lane deletes it,
// folder and contents, physically."
#expect(!fixture.exists(Ident.lane3))
#expect(!fixture.exists(".trash/\(Ident.card4)"), "its cards go with it, not into the trash")
#expect(try loaded(fixture).trash.map(\.id) == [newer, trashed], "the trash is untouched")
#expect(fixture.exists(".trash/\(Ident.lane3)"))
#expect(fixture.exists(".trash/\(Ident.lane3)/\(Ident.card4)"), "its cards ride along inside it")
#expect(!fixture.exists(".trash/\(Ident.card4)"), "and are never filed as trash cards")
let model = try loaded(fixture)
#expect(model.trash.map(\.id) == [newer, trashed], "the trash's cards are unchanged")
#expect(model.trashedLanes.map(\.id.rawValue) == [Ident.lane3])
#expect(model.trashedLanes.first?.heldCards == 1)
#expect(store.banners.oneShots.isEmpty)
}
/// "Every arrival lands at the trash's topmost position regardless of kind" (03 § Trash): the
/// ladder the rank is minted against is the whole container, cards and lane rows alike.
@Test("The lane lands at the top of the trash, above every existing entry")
func laneLandsOnTop() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let topBefore = try #require(loaded(fixture).trash.map(\.order).min())
store.delete([lane3])
let landed = try #require(loaded(fixture).trashedLanes.first?.order)
#expect(landed < topBefore)
// And the next card delete mints above *that* the lane row is in the ladder the store
// mints against, which is the whole container rather than one array of it. The reload is
// what puts the new row in the snapshot; without it the store is still holding the
// pre-delete picture, as it is for two of any deletes in a row.
await reload(store)
store.delete([card1])
let card = try #require(loaded(fixture).trash.first { $0.id == card1 }?.order)
#expect(card < landed)
}
/// No dialog: "the move is recoverable, so nothing needs confirming" (03 § Trash).
@Test("A lane delete raises no confirmation and needs none")
func laneDeleteIsNotConfirmed() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
store.select([lane3], in: .board)
confirmations.requestDelete(in: store)
#expect(confirmations.pending == nil)
#expect(fixture.exists(".trash/\(Ident.lane3)"), "it went straight through")
}
@Test("The selection moves to the successor lane")
func laneSuccessor() throws {
let fixture = try makeBoard()
@@ -344,7 +389,7 @@ struct StagedDeleteTests {
// The column's order is [newer, trashed].
store.select([newer], in: .trash)
store.deleteTrashCards([newer])
store.deleteTrashEntries([newer])
#expect(store.selection.ids == [trashed])
#expect(store.selection.container == .trash)
@@ -357,7 +402,7 @@ struct StagedDeleteTests {
let store = try BoardStore(rootURL: fixture.root)
store.select([newer, trashed], in: .trash)
store.deleteTrashCards([newer, trashed])
store.deleteTrashEntries([newer, trashed])
#expect(try loaded(fixture).trash.isEmpty)
#expect(store.selection.isEmpty)
@@ -369,7 +414,7 @@ struct StagedDeleteTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.deleteTrashCards([card1])
store.deleteTrashEntries([card1])
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
#expect(store.banners.oneShots.isEmpty)
@@ -432,8 +477,8 @@ struct PurgeTests {
#expect(store.purgeIsUnrecoverable)
store.select([trashed], in: .trash)
store.deleteTrashCards([trashed])
store.deleteTrashCards([newer])
store.deleteTrashEntries([trashed])
store.deleteTrashEntries([newer])
store.emptyTrash()
// 13-native-undo.md Rules: "Permanently delete (the trash's Delete, Empty Trash)
@@ -472,10 +517,11 @@ struct StoreTombstoneMigrationTests {
return fixture
}
@Test("Cards relocate into .trash/ with the key removed; lanes return live in place")
func migrationMovesCardsAndResurrectsLanes() throws {
@Test("Cards relocate into .trash/ with the key removed; a lane's key is never written to")
func migrationMovesCardsAndLeavesLanesAlone() throws {
let fixture = try makeLegacyBoard()
defer { fixture.tearDown() }
let laneBefore = try fixture.indexText(Ident.lane2)
let store = try BoardStore(rootURL: fixture.root)
store.migrateLegacyTombstones()
@@ -488,12 +534,12 @@ struct StoreTombstoneMigrationTests {
#expect(!(try fixture.indexText(".trash/\(Ident.card2)").contains("deleted:")))
#expect(!(try fixture.indexText(".trash/\(Ident.card3)").contains("deleted:")))
// The lane stayed exactly where it was, key stripped "resurrection is the safe direction,
// nothing is destroyed by migration".
#expect(fixture.exists(Ident.lane2))
#expect(!(try fixture.indexText(Ident.lane2).contains("deleted:")))
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"), "its cards come back with it")
#expect(try document(fixture, Ident.lane2).order.value == 2048, "at its own position")
// "A lane carrying `deleted:` simply loads live with the key ignored no migration
// machinery, no key-strip write" (01 § Deletion, re-ruled 2026-07-29): its bytes are
// untouched, `modified` included, and it renders as an ordinary lane.
#expect(try fixture.indexText(Ident.lane2) == laneBefore, "not one byte written")
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
#expect(try loaded(fixture).lanes.map(\.id.rawValue).contains(Ident.lane2))
}
@Test("Cards migrate oldest-first, so the newest deletion ends up on top")
@@ -554,7 +600,7 @@ struct StoreTombstoneMigrationTests {
#expect(store.banners.losses.isEmpty, "and nothing to announce")
}
@Test("The notice is one folded warning-tone row naming both halves")
@Test("The notice is one warning-tone row, and names only what was moved")
func theNotice() throws {
let fixture = try makeLegacyBoard()
defer { fixture.tearDown() }
@@ -563,8 +609,10 @@ struct StoreTombstoneMigrationTests {
store.migrateLegacyTombstones()
#expect(store.banners.losses.count == 1, "one migration, one row")
// No lane clause: nothing happened to the lane, so a row claiming otherwise would announce
// an act the app did not perform.
#expect(store.banners.losses.first?.message
== "Moved 2 cards to the trash and restored 'Retired' — they carried old deleted markers")
== "Moved 2 cards to the trash — they carried old deleted markers")
}
@Test("A board with nothing legacy migrates nothing and says nothing")
@@ -628,7 +676,7 @@ struct StoreTombstoneMigrationTests {
// read, so the channel empties and stays empty.
#expect(store.legacyTombstones.isEmpty)
#expect(store.snapshot.trash.count == 2)
#expect(store.snapshot.lanes.count == 2, "the resurrected lane is an ordinary lane again")
#expect(store.snapshot.lanes.count == 2, "the key-carrying lane was always an ordinary lane")
}
@Test("It is armed by the reload seam, exactly like the loose-file relocation")
@@ -659,31 +707,17 @@ struct MigrationNoticeTests {
@Test("One card names it; several fold to a count")
func cardsFold() {
#expect(BannerCenter.migratedTombstonesMessage(cards: ["Fix login"], lanes: [])
#expect(BannerCenter.migratedTombstonesMessage(cards: ["Fix login"])
== "Moved 'Fix login' to the trash — it carried an old deleted marker")
#expect(BannerCenter.migratedTombstonesMessage(cards: ["A", "B", "C"], lanes: [])
#expect(BannerCenter.migratedTombstonesMessage(cards: ["A", "B", "C"])
== "Moved 3 cards to the trash — they carried old deleted markers")
}
@Test("A lane reads as a restoration, which is what it is")
func lanesRead() {
#expect(BannerCenter.migratedTombstonesMessage(cards: [], lanes: ["Doing"])
== "Restored 'Doing' — it carried an old deleted marker")
#expect(BannerCenter.migratedTombstonesMessage(cards: [], lanes: ["A", "B"])
== "Restored 2 lanes — they carried old deleted markers")
}
@Test("Both halves fold into one sentence — one migration, one row")
func bothFold() {
#expect(BannerCenter.migratedTombstonesMessage(cards: ["A", "B", "C"], lanes: ["D", "E"])
== "Moved 3 cards to the trash and restored 2 lanes — they carried old deleted markers")
}
@Test("An untitled item reads as a rendering, and nothing migrated is not news")
func edges() {
#expect(BannerCenter.migratedTombstonesMessage(cards: [nil], lanes: [])
#expect(BannerCenter.migratedTombstonesMessage(cards: [nil])
== "Moved an untitled item to the trash — it carried an old deleted marker")
#expect(BannerCenter.migratedTombstonesMessage(cards: [], lanes: []) == nil)
#expect(BannerCenter.migratedTombstonesMessage(cards: []) == nil)
}
}
@@ -712,7 +746,7 @@ struct TrashConfirmationsTests {
store.select([trashed], in: .trash)
confirmations.requestDelete(in: store)
let pending = try #require(confirmations.pending)
#expect(pending.action == .deleteTrashCards([trashed]))
#expect(pending.action == .deleteTrashEntries([trashed]))
#expect(fixture.exists(".trash/\(Ident.indexless)"), "nothing has happened yet")
confirmations.confirm(in: store)
@@ -844,7 +878,7 @@ struct TrashMenuValidationTests {
confirmations.requestDelete(in: store)
// The trash staging: permanent, and behind the alert.
let pending = try #require(confirmations.pending)
#expect(pending.action == .deleteTrashCards([trashed]))
#expect(pending.action == .deleteTrashEntries([trashed]))
}
/// A context menu names its target by where it was invoked, so the trash row's Delete must purge
@@ -862,7 +896,7 @@ struct TrashMenuValidationTests {
let pending = try #require(confirmations.pending)
#expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?")
#expect(pending.action == .deleteTrashCards([trashed]))
#expect(pending.action == .deleteTrashEntries([trashed]))
confirmations.confirm(in: store)
#expect(!fixture.exists(".trash/\(Ident.indexless)"))
+55 -12
View File
@@ -664,29 +664,63 @@ struct TrashUndoTests {
#expect(history.canUndo == false)
}
@Test("A lane delete is physical, and its undo recreates the folder byte for byte")
func laneDeleteRecreatesTheSubtree() throws {
/// 13 Interaction with the trash: "a lane [returns] to its strip position (subtree intact it
/// never left the folder)". The inverse is the ordinary move back, so the capture/recreate
/// machinery is retired: the lane's bytes never left the disk to need replaying.
@Test("A lane delete is a move into the trash, and its undo is the move back to its strip rank")
func laneDeleteRoundTrip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let laneText = try fixture.indexText(Ident.lane2)
let laneBefore = try fixture.indexText(Ident.lane2)
let cardText = try fixture.indexText("\(Ident.lane2)/\(Ident.indexless)")
let laneRank = try #require(try document(fixture, Ident.lane2).order.value)
store.delete([lane2])
#expect(!fixture.exists(Ident.lane2), "03 ▸ Trash: deleting a lane deletes it, physically")
#expect(try loadedTrash(fixture).map(\.id) == [trashed], "lanes are never trashed")
#expect(!fixture.exists(Ident.lane2))
#expect(fixture.exists(".trash/\(Ident.lane2)"))
#expect(try loadedTrash(fixture).map(\.id) == [trashed], "its cards are not trash cards")
#expect(try loadedTrashedLanes(fixture).map(\.id) == [lane2])
#expect(history.undoActionName == "Delete Lane")
let trashRank = try #require(try document(fixture, ".trash/\(Ident.lane2)").order.value)
let resident = try #require(try document(fixture, trashedPath).order.value)
#expect(trashRank < resident, "entry is at the top — a rank above the current topmost")
history.undo()
#expect(fixture.exists(Ident.lane2))
#expect(try fixture.indexText(Ident.lane2) == laneText, "the capture replays bytes, it does not edit")
#expect(!fixture.exists(".trash/\(Ident.lane2)"))
#expect(try document(fixture, Ident.lane2).order.value == laneRank, "at its own strip position")
#expect(untouchedLines(try fixture.indexText(Ident.lane2)) == untouchedLines(laneBefore),
"byte-identical but for the stamps every container-changing move owns")
#expect(try fixture.indexText("\(Ident.lane2)/\(Ident.indexless)") == cardText,
"and the whole subtree comes back with it — nested cards included")
"the subtree never moved relative to its lane, so not one nested byte changed")
history.redo()
#expect(!fixture.exists(Ident.lane2))
#expect(fixture.exists(".trash/\(Ident.lane2)"))
#expect(try document(fixture, ".trash/\(Ident.lane2)").order.value == trashRank,
"the redo replays the write's own captured rank")
}
@Test("A multi-lane delete is one step with a plural title")
func batchLaneDeleteIsOneStep() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.delete([lane1, lane2])
#expect(history.undoActionName == "Delete 2 Lanes")
let first = try #require(try document(fixture, ".trash/\(Ident.lane1)").order.value)
let second = try #require(try document(fixture, ".trash/\(Ident.lane2)").order.value)
#expect(second < first, "each arrival in the run mints a rank above the one before it")
history.undo()
#expect(fixture.exists(Ident.lane1))
#expect(fixture.exists(Ident.lane2))
#expect(try loadedTrashedLanes(fixture).isEmpty)
#expect(history.canUndo == false)
}
@Test("A restore-by-move-out registers as an ordinary Move, with the ordinary move inverse")
@@ -721,6 +755,11 @@ private func loadedTrash(_ fixture: WriterFixture) throws -> [Card] {
try BoardLoader.load(boardRoot: fixture.root).model.trash
}
@MainActor
private func loadedTrashedLanes(_ fixture: WriterFixture) throws -> [TrashedLane] {
try BoardLoader.load(boardRoot: fixture.root).model.trashedLanes
}
// MARK: - The Edit session
@MainActor
@@ -820,7 +859,7 @@ struct NotUndoableTests {
let armed = try #require(history.undoActionName)
store.select([trashed], in: .trash)
store.deleteTrashCards([trashed])
store.deleteTrashEntries([trashed])
#expect(fixture.exists(trashedPath) == false)
#expect(store.purgeIsUnrecoverable)
@@ -963,7 +1002,9 @@ private enum Foreign {
try fixture.move(path, toTrash: id)
}
/// A foreign delete of a **lane** physical, since lanes are never trashed.
/// A **Finder deletion** of a lane: the folder disappears entirely, which "is also a delete"
/// (01 § Deletion) such items never enter the trash, so this is the shape a foreign writer's
/// destructive removal has, distinct from the app's own delete (a move into `.trash/`).
static func removeLane(_ fixture: WriterFixture, _ path: String) throws {
try FileManager.default.removeItem(at: fixture.url(path))
}
@@ -1093,8 +1134,10 @@ struct StaleStepTests {
#expect(store.banners.signposts.count == 1)
}
/// The card's own path is the check: a lane delete is physical, so a card under it is simply not
/// there any more no ancestor walk, which is what materializing the trash bought.
/// The card's own path is the check: its lane's folder is gone, so the card is simply not there
/// any more no ancestor walk, which is what materializing the trash bought. (The same holds
/// when the app trashes the lane: the card's path moves under `.trash/` and nothing is left at
/// the one the step named.)
@Test("A card whose lane a foreign writer deleted is stale too")
func aCardUnderARemovedLaneIsStale() throws {
let fixture = try makeBoard()
+43
View File
@@ -179,6 +179,29 @@ struct WriteFidelityMinimalTouchTests {
}
#expect(BoardLoader.isUUIDShaped(createdCard))
// A **lane** delete is the same move, one level up (03 § Trash, re-ruled 2026-07-29): the
// lane's folder leaves the visible tree carrying its whole subtree, and the cards inside it
// are neither read nor rewritten their mtimes prove it, since they are still tracked here
// under their new path only after the restore puts the lane back.
try step(
"delete lane",
targeting: [],
departed: [Ident.lane2, "\(Ident.lane2)/\(Ident.card3)", "\(Ident.lane2)/\(Ident.card4)"]
) {
try BoardWriter.deleteLaneToTrash(
at: fixture.url(Ident.lane2), inBoard: fixture.root, order: 512
)
}
try step("restore lane", targeting: []) {
_ = try BoardWriter.moveItem(
at: BoardWriter.trashFolder(inBoard: fixture.root).appendingPathComponent(Ident.lane2),
toParent: fixture.root,
sourceBoardRoot: fixture.root,
destinationBoardRoot: fixture.root,
order: 2048
)
}
try step("import attachment", targeting: []) {
// Dot-prefixed so it never becomes a board-root stray the loader has to warn about
// the source lives outside the board tree in spirit, just not in path.
@@ -547,6 +570,26 @@ struct WriteFidelityStampingTests {
#expect(restored.modifiedBy == nil, "out of the trash is a container change too")
}
/// A lane's trash move is the same container change, and its **subtree is not** the nested
/// card is neither read nor rewritten, so its stamps are exactly what they were.
@Test("A lane's trash move stamps the lane and nothing beneath it")
func aLaneTrashMoveStampsOnlyTheLane() throws {
let fixture = try twoLaneBoard()
defer { fixture.tearDown() }
try BoardWriter.deleteLaneToTrash(
at: fixture.url(Ident.lane1), inBoard: fixture.root, order: 1024
)
let lane = try stamps(fixture, ".trash/\(Ident.lane1)")
#expect(lane.modified != Self.priorModified, "into the trash is a container change")
#expect(lane.modifiedBy == nil)
let card = try stamps(fixture, ".trash/\(Ident.lane1)/\(Ident.card1)")
#expect(card.modified == Self.priorModified, "the freight's own container did not change")
#expect(card.modifiedBy == "claude", "so even a foreign stamp survives, untouched")
}
/// A cross-board arrival changes the container as surely as a cross-lane move does, and the
/// import boundary's remint does not change that: the arrived file is stamped either way.
@Test("A cross-board arrival stamps")