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
+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])
}
}