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