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:
+284
-161
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user