Materialize the trash — store, undo, and the container universe

Phase 2 swaps every consumer: Liveness and its ancestor walk are gone,
replaced by ItemContainer — a UUID set plus the container side it
lives on, presence the whole test, one selection boundary instead of
the old liveness law. Deletion stages by place: board cards move to
the trash at a store-minted head rank, trash-side delete is permanent
behind its confirmation, Delete Immediately skips the trash from
anywhere, lane delete captures the subtree and removes the folder.
Restore has no method at all — moveCards resolves members in either
container, so drag-out and cut-paste are the ordinary moves 13 calls
them, registering ordinary Move steps. The delete inverse moves the
card back to its captured lane and rank; redo replays the captured
trash rank, a value the gesture actually wrote; lane undo recreates
the subtree byte-faithfully in session. Purges register nothing —
where 13's trash section contradicts its own Rules on that, Rules
wins, filed for ruling. Staleness collapsed to present-or-absent: a
container is a path, so a foreign restore fails the delete step's
expectation structurally. Legacy tombstones migrate on the loose-file
tail hook, cards oldest-first so minting above top reproduces the
retired newest-first column, lanes returning live, one folded loss
row naming both directions. Put Back, restoreByDrag,
receiveRestoredCards, TrashEntry, and the kind machinery are deleted;
the trash column renders the container correctly with its full face
rework left to phase 3.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 17:47:56 -04:00
parent 16c10d61c3
commit 53bc71f7fb
53 changed files with 3459 additions and 3655 deletions
+19 -48
View File
@@ -24,22 +24,12 @@ private func makeMixedBoard() throws -> WriterFixture {
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item(
"\(Ident.lane1)/\(Ident.card3)",
"---\nschema: 1\norder: 3072\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
)
try fixture.item(
Ident.lane2,
"---\nschema: 1\norder: 2048\ntitle: Archive\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
)
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Buried"))
try fixture.item(
"\(Ident.lane2)/\(Ident.indexless)",
"---\nschema: 1\norder: 2048\ntitle: Also buried\n---\nbody\n"
)
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Archive"))
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Doing"))
// The trash: cards in a sibling container, never lanes (03-board-ui.md § Trash).
try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Gone"))
try fixture.item(".trash/\(Ident.card4)", Item.rich(order: "2048", title: "Also gone"))
return fixture
}
@@ -76,54 +66,35 @@ struct AppModelTests {
// MARK: Live-only counts
@Test("The recents counts are live items only, at both levels")
func liveCountsIgnoreTombstonesAndWhatHidesBeneathThem() throws {
@Test("The recents counts are working items only — the trash is an errand, not inventory")
func liveCountsExcludeTheTrash() throws {
let fixture = try makeMixedBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// The snapshot itself keeps everything tombstones are what the trash renders so this is a
// genuine filter, not a property of the load.
#expect(snapshot.lanes.count == 3)
#expect(snapshot.lanes.flatMap(\.cards).count == 5)
// The snapshot itself keeps everything the trash is a sibling container so this is a
// genuine exclusion, not a property of the load.
#expect(snapshot.lanes.count == 2)
#expect(snapshot.trash.count == 2)
let counts = AppModel.liveCounts(of: snapshot)
#expect(counts.lanes == 2, "the tombstoned lane is not part of the board's working size")
#expect(counts.cards == 2, "one tombstoned card, and two more hidden beneath a tombstoned lane")
#expect(counts.lanes == 2)
// 02 § Per-board app state, re-grounded 2026-07-28: "cards in `.trash/` don't count; the row
// advertises the board's working size". The walk reads `snapshot.lanes` and the trash is
// `snapshot.trash`, so the exclusion is by construction and none could be forgotten.
#expect(counts.cards == 2)
}
@Test("A board with nothing live counts zero rather than declining to answer")
@Test("A board with nothing on it counts zero rather than declining to answer")
func liveCountsOfAnEmptyBoard() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(
Ident.lane1,
"---\nschema: 1\norder: 1024\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
)
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Buried"))
try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "1024", title: "Gone"))
let counts = AppModel.liveCounts(of: try BoardLoader.load(boardRoot: fixture.root).model)
#expect(counts.lanes == 0)
#expect(counts.cards == 0)
}
@Test("A malformed deleted: still counts as deleted")
func liveCountsFollowPresenceNotValidity() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\norder: 1024\ntitle: Gone\ndeleted: yesterday\n---\nbody\n"
)
// The presence of the key is what encodes deletion intent (`Card.isDeleted`), so an
// unparseable timestamp hides the card here exactly as it hides it on the board.
let counts = AppModel.liveCounts(of: try BoardLoader.load(boardRoot: fixture.root).model)
#expect(counts.lanes == 1)
#expect(counts.cards == 0)
#expect(counts.cards == 0, "a board whose only content is trash advertises no working size")
}
// MARK: Display name
@@ -241,7 +212,7 @@ struct AppModelTests {
#expect(model.storeRegistry.liveStore(for: fixture.root) == nil, "the last reference went with the session")
let record = try #require(model.boardRegistry.record(id: recordID))
#expect(record.laneCount == 2, "the counts the welcome row will show are the live ones")
#expect(record.laneCount == 2, "the counts the welcome row will show are the working ones")
#expect(record.cardCount == 2)
#expect(record.isOpenNow == false)
#expect(model.boardRegistry.restorables().isEmpty)
+21 -23
View File
@@ -364,7 +364,7 @@ struct BoardStoreTests {
#expect(!ran)
// Reading stays live throughout keeping the last-good snapshot is the point of the lock.
store.select([ItemID(rawValue: Ident.card2)], liveness: .live)
store.select([ItemID(rawValue: Ident.card2)], in: .board)
#expect(store.selection.ids.count == 1)
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Repaired"))
@@ -421,50 +421,48 @@ struct BoardStoreTests {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live)
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board)
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
#expect(store.selection.ids == [ItemID(rawValue: Ident.card2)])
#expect(store.selection.liveness == .live)
#expect(store.selection.container == .board)
}
@Test("A liveness flip is a vanish: a selected card tombstoned externally leaves the selection")
func selectionEjectsALivenessFlip() async throws {
@Test("A container crossing is a vanish: a selected card trashed externally leaves the selection")
func selectionEjectsAContainerCrossing() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live)
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board)
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First"))
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
// Still in the snapshot the trash renders it but no longer on the selection's side of
// the boundary, so the homogeneous-by-liveness invariant survives a foreign edit.
let flipped = lane(Ident.lane1, in: store.snapshot)?.cards.first { $0.id.rawValue == Ident.card1 }
#expect(flipped?.isDeleted == true)
// Still in the snapshot the trash renders it but no longer in the selection's container,
// so 04's one-container invariant survives a foreign edit (02-architecture.md's reload rule,
// resettled 2026-07-28: "re-resolution matches UUID *and* container side").
#expect(store.snapshot.trash.map(\.id.rawValue) == [Ident.card1])
#expect(store.selection.ids == [ItemID(rawValue: Ident.card2)])
}
@Test("Tombstoning a lane ejects its cards from a live selection — liveness is effective")
func selectionEjectsCardsUnderATombstonedLane() async throws {
@Test("Deleting a lane externally takes its cards out of the selection with it")
func selectionEjectsCardsUnderARemovedLane() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live)
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board)
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Lane one"))
// A lane delete is physical now (03-board-ui.md § Trash), so the cards genuinely go with it
// no ancestor walk needed, and none left to do: presence is the whole test.
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
// The cards' own flags never changed, but their lane's did and liveness is
// ancestor-walked (02, settled): the cards render nowhere once 03 collapses the lane to
// a single trash entry, and nothing invisible may stay selected.
let survivor = lane(Ident.lane1, in: store.snapshot)?.cards.first { $0.id.rawValue == Ident.card1 }
#expect(survivor?.isDeleted == false, "the card's own flag is untouched")
#expect(store.snapshot.lanes.first { $0.id.rawValue == Ident.lane1 } == nil)
#expect(store.selection.ids.isEmpty)
}
@@ -473,7 +471,7 @@ struct BoardStoreTests {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live)
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board)
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
@@ -481,9 +479,9 @@ struct BoardStoreTests {
await store.awaitQuiescence()
#expect(store.selection.ids.isEmpty)
#expect(store.selection.liveness == .live, "the side survives even when the membership does not")
#expect(store.selection.container == .board, "the container survives even when the membership does not")
store.select([ItemID(rawValue: Ident.lane1)], liveness: .live)
store.select([ItemID(rawValue: Ident.lane1)], in: .board)
store.clearSelection()
#expect(store.selection == .empty)
}
+1 -2
View File
@@ -191,13 +191,12 @@ struct RemoveAttachmentStoreTests {
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x02]))
let store = try BoardStore(rootURL: fixture.root)
store.removeAttachment(named: "shot.png", fromCard: card2) // tombstoned
store.removeAttachment(named: "shot.png", fromCard: ItemID(rawValue: Ident.lane2)) // a lane
store.removeAttachment(named: "shot.png", fromCard: ItemID(rawValue: Ident.indexless)) // no such card
store.removeAttachment(named: "shot.png", fromCard: lane1) // a lane
store.removeAttachment(named: "", fromCard: card1) // no name
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"])
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card2)/attachments") == ["shot.png"])
#expect(store.banners.oneShots.isEmpty, "a vanished target is a silent no-op, not a failure")
}
+22 -28
View File
@@ -3,14 +3,18 @@ import Testing
@testable import Kanban
/// A card window's whole lifecycle is one decision re-taken on every snapshot: does this key still
/// name a card? Four answers, three of which are "no" for different reasons, and the one that is
/// easiest to get wrong a live card under a tombstoned lane is invisible in the card's own data.
/// So the decision is a pure function and this is its suite; nothing here needs a window.
/// name a card **on the board**? The decision is a pure function and this is its suite; nothing here
/// needs a window.
///
/// **The walk got simpler with the materialized trash** (05-card-window.md Deletion & lifecycle,
/// resettled 2026-07-28): "entering the trash counts as deleted", and a trashed card's folder has
/// physically left its lane so "is it under one of this board's lanes" is the whole question, and
/// the tombstone era's ancestor walk is gone.
// MARK: - Fixtures
/// - lane 1 (live): one live card, one tombstoned card
/// - lane 2 (**tombstoned**): one live card, whose own flag is clear
/// - lane 1: one card
/// - the trash: one card, moved there by a delete
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
@@ -18,16 +22,7 @@ private func makeBoard() throws -> WriterFixture {
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
try fixture.item(
"\(Ident.lane1)/\(Ident.card2)",
"---\nschema: 1\norder: 2048\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
)
try fixture.item(
Ident.lane2,
"---\nschema: 1\norder: 2048\ntitle: Archive\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
)
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Buried alive"))
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Gone"))
return fixture
}
@@ -62,30 +57,29 @@ struct CardWindowFateTests {
#expect(laneTitle(fate) == "Todo")
}
@Test("A tombstoned card dismisses its window")
func aTombstonedCardDismisses() throws {
@Test("A card in the trash dismisses its window")
func aTrashedCardDismisses() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// on the board closes the card's open window "a tombstone counts as deleted"
// (05-card-window.md). The card is still in the snapshot; the trash renders it.
#expect(snapshot.lanes[0].cards.contains { $0.id.rawValue == Ident.card2 })
// on the board closes the card's open window "entering the trash counts as deleted"
// (05-card-window.md). The card is still in the snapshot; the trash column renders it.
#expect(snapshot.trash.contains { $0.id.rawValue == Ident.card2 })
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card2, in: snapshot) == .dismisses)
}
@Test("A live card under a tombstoned lane dismisses too — liveness is ancestor-walked")
func aLaneTombstoneDismissesItsCards() throws {
@Test("Deleting a card's lane dismisses its window, because the card is gone with it")
func aLaneDeleteDismissesItsCards() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// The card's own flag says nothing is wrong. Its lane's does, and 03-board-ui.md collapses a
// tombstoned lane to one restorable trash entry so the card renders nowhere, and a window
// onto something that renders nowhere is the case this walk exists for.
let buried = try #require(snapshot.lanes.first { $0.id.rawValue == Ident.lane2 }?.cards.first)
#expect(!buried.isDeleted)
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card3, in: snapshot) == .dismisses)
// "Deleting the card's *lane* deletes the card with it the window dismisses because the
// card is gone" (05). A lane delete is physical, so this needs no ancestor walk: the card
// is simply not in the snapshot.
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card1, in: snapshot) == .dismisses)
}
@Test("A card that is not in this board's snapshot dismisses — the cross-board move")
+57 -54
View File
@@ -52,21 +52,21 @@ let clipboardCard2 = ItemID(rawValue: Ident.card2)
let clipboardCard3 = ItemID(rawValue: Ident.card3)
let clipboardCard4 = ItemID(rawValue: Ident.card4)
func tombstonedItem(order: String, title: String) -> String {
/// An ordinary card body, for the trash's resident.
func trashResidentItem(order: String, title: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
created: 2026-01-01T09:00:00Z
deleted: 2026-03-03T09:00:00Z
---
\(title) body.
"""
}
/// Two lanes: `lane1` holds three live cards and one tombstoned one, `lane2` holds a single card.
/// Two lanes: `lane1` holds two cards, `lane2` holds a single card, and one card sits in the trash.
/// `card1` carries two attachments, which is what makes "the snapshot travels whole" and "the
/// fallback lost exactly two files" both assertable.
@MainActor
@@ -78,9 +78,9 @@ func makeClipboardBoard() throws -> WriterFixture {
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("png bytes".utf8))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt", Data("notes".utf8))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstonedItem(order: "3072", title: "Trashed"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
try fixture.item(".trash/\(Ident.card3)", trashResidentItem(order: "1024", title: "Trashed"))
return fixture
}
@@ -140,7 +140,7 @@ struct ClipboardManifestTests {
copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/Board.kanban", isDirectory: true),
kind: .card,
side: .live,
container: .board,
entries: [entry(Ident.card1)]
)
let data = try #require(manifest.encoded())
@@ -153,7 +153,7 @@ struct ClipboardManifestTests {
copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
kind: .card,
side: .live,
container: .board,
entries: [entry(Ident.card1)]
)
manifest.version = ClipboardManifest.currentVersion + 1
@@ -167,7 +167,7 @@ struct ClipboardManifestTests {
copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
kind: .card,
side: .live,
container: .board,
entries: []
)
let data = try #require(manifest.encoded())
@@ -200,7 +200,7 @@ struct ClipboardManifestTests {
copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
kind: .card,
side: .live,
container: .board,
entries: [titled, untitled]
)
#expect(manifest.plainText == "First\nUntitled")
@@ -218,7 +218,7 @@ struct ClipboardCopyTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
@@ -238,12 +238,12 @@ struct ClipboardCopyTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1, clipboardCard2], liveness: .live)
harness.store.select([clipboardCard1, clipboardCard2], in: .board)
harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
#expect(manifest.kind == .card)
#expect(manifest.side == .live)
#expect(manifest.container == .board)
#expect(manifest.rootURL.path == harness.fixture.root.path)
// Flatten order lane `order`, then card `order`.
#expect(manifest.entries.map(\.id) == [Ident.card1, Ident.card2])
@@ -258,7 +258,7 @@ struct ClipboardCopyTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
@@ -266,12 +266,12 @@ struct ClipboardCopyTests {
#expect(manifest.entries[0].index == onDisk)
}
@Test("A lane copy embeds its live cards and leaves the tombstoned one out")
func laneEntryEmbedsLiveCards() async throws {
@Test("A lane copy embeds exactly its cards — the trash is board-level, so none is nested")
func laneEntryEmbedsItsCards() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardLane1], liveness: .live)
harness.store.select([clipboardLane1], in: .board)
harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
@@ -281,17 +281,17 @@ struct ClipboardCopyTests {
#expect(manifest.entries[0].lostAttachmentCount == 2)
}
@Test("A trashed selection copies out, side recorded")
func trashedSide() async throws {
@Test("A trash selection copies out, container recorded")
func trashContainerRecorded() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardCard3], liveness: .trashed)
harness.store.select([clipboardCard3], in: .trash)
harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
#expect(manifest.side == .trashed)
#expect(manifest.container == .trash)
#expect(manifest.entries.map(\.id) == [Ident.card3])
}
@@ -310,12 +310,12 @@ struct ClipboardCopyTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let first = try #require(harness.clipboard.payload?.copyID)
harness.store.select([clipboardCard2], liveness: .live)
harness.store.select([clipboardCard2], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let second = try #require(harness.clipboard.payload?.copyID)
@@ -365,7 +365,7 @@ struct ClipboardSweepTests {
withIntermediateDirectories: true
)
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
@@ -378,7 +378,7 @@ struct ClipboardSweepTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
#expect(try harness.stagedCopyIDs().count == 1)
@@ -402,7 +402,7 @@ struct ClipboardTakeoverTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
#expect(harness.clipboard.payload != nil)
@@ -417,7 +417,7 @@ struct ClipboardTakeoverTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
let before = harness.pasteboard.changeCount
harness.clipboard.refresh()
@@ -432,7 +432,7 @@ struct ClipboardTakeoverTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.cut(from: harness.store)
#expect(harness.store.transient.pendingCut.ids == [clipboardCard1])
@@ -453,11 +453,11 @@ struct ClipboardCutTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1, clipboardCard4], liveness: .live)
harness.store.select([clipboardCard1, clipboardCard4], in: .board)
harness.clipboard.cut(from: harness.store)
#expect(harness.store.transient.pendingCut.ids == [clipboardCard1, clipboardCard4])
#expect(harness.store.transient.pendingCut.liveness == .live)
#expect(harness.store.transient.pendingCut.container == .board)
}
@Test("A second copy voids the pending cut — its pasteboard entry has been overwritten")
@@ -465,21 +465,21 @@ struct ClipboardCutTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.cut(from: harness.store)
#expect(!harness.store.transient.pendingCut.isEmpty)
harness.store.select([clipboardCard2], liveness: .live)
harness.store.select([clipboardCard2], in: .board)
harness.clipboard.copy(from: harness.store)
#expect(harness.store.transient.pendingCut.isEmpty)
}
@Test("Deletion voids per item: a tombstoned cut member leaves the pending set on reload")
@Test("Deletion voids per item: a deleted cut member leaves the pending set on reload")
func deletionVoidsPerItem() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1, clipboardCard2], liveness: .live)
harness.store.select([clipboardCard1, clipboardCard2], in: .board)
harness.clipboard.cut(from: harness.store)
harness.store.delete([clipboardCard1])
@@ -502,19 +502,22 @@ struct ClipboardAvailabilityTests {
defer { harness.tearDown() }
#expect(harness.clipboard.canCopy(from: harness.store) == false)
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
#expect(harness.clipboard.canCopy(from: harness.store))
}
@Test("Copy works on a trashed selection; cut does not")
func trashIsCopyOutOnly() throws {
/// 04-interactions.md The trash, resettled 2026-07-28: "X **works** (it was disabled under
/// the tombstone model): cut in the trash, paste into a lane is the keyboard-native restore, an
/// ordinary folder move."
@Test("Both copy and cut work on a trash selection — cut is the keyboard restore")
func trashTakesCopyAndCut() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardCard3], liveness: .trashed)
harness.store.select([clipboardCard3], in: .trash)
#expect(harness.clipboard.canCopy(from: harness.store))
#expect(harness.clipboard.canCut(from: harness.store) == false)
#expect(harness.clipboard.canCut(from: harness.store))
}
@Test("The read-only lock blocks cut but never copy")
@@ -522,7 +525,7 @@ struct ClipboardAvailabilityTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.store.enterVanishedRootLock()
#expect(harness.clipboard.canCopy(from: harness.store))
#expect(harness.clipboard.canCut(from: harness.store) == false)
@@ -534,7 +537,7 @@ struct ClipboardAvailabilityTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
harness.store.transient.beginRename(of: clipboardCard1, currentTitle: "First")
@@ -549,7 +552,7 @@ struct ClipboardAvailabilityTests {
defer { harness.tearDown() }
#expect(harness.clipboard.canPaste(into: harness.store) == false)
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
#expect(harness.clipboard.canPaste(into: harness.store))
}
@@ -573,11 +576,11 @@ struct ClipboardAvailabilityTests {
let sourceStore = try BoardStore(rootURL: source.root)
let emptyStore = try BoardStore(rootURL: empty.root)
sourceStore.select([clipboardCard1], liveness: .live)
sourceStore.select([clipboardCard1], in: .board)
clipboard.copy(from: sourceStore)
#expect(clipboard.canPaste(into: emptyStore) == false)
sourceStore.select([clipboardLane1], liveness: .live)
sourceStore.select([clipboardLane1], in: .board)
clipboard.copy(from: sourceStore)
#expect(clipboard.canPaste(into: emptyStore))
}
@@ -600,7 +603,7 @@ struct PasteTargetTests {
let model = try snapshot(fixture)
let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardCard1], liveness: .live),
selection: ItemReferenceSet(ids: [clipboardCard1], container: .board),
lastActiveLaneID: nil,
snapshot: model
)
@@ -614,11 +617,11 @@ struct PasteTargetTests {
let model = try snapshot(fixture)
let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardLane1], liveness: .live),
selection: ItemReferenceSet(ids: [clipboardLane1], container: .board),
lastActiveLaneID: nil,
snapshot: model
)
// Two rendered cards the tombstoned third is not in the layout.
// Two rendered cards.
#expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 2))
}
@@ -629,7 +632,7 @@ struct PasteTargetTests {
let model = try snapshot(fixture)
let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardCard4, clipboardCard1], liveness: .live),
selection: ItemReferenceSet(ids: [clipboardCard4, clipboardCard1], container: .board),
lastActiveLaneID: nil,
snapshot: model
)
@@ -637,18 +640,18 @@ struct PasteTargetTests {
#expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1))
}
@Test("A tombstoned selection never anchors: it behaves as nothing selected")
func tombstonedSelectionNeverAnchors() throws {
@Test("A trash selection never anchors: it behaves as nothing selected")
func trashSelectionNeverAnchors() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let model = try snapshot(fixture)
let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardCard3], liveness: .trashed),
selection: ItemReferenceSet(ids: [clipboardCard3], container: .trash),
lastActiveLaneID: clipboardLane2,
snapshot: model
)
// The last-active lane, appended never `card3`'s live disk-lane.
// The last-active lane, appended the trash is never the destination.
#expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1))
}
@@ -679,7 +682,7 @@ struct PasteTargetTests {
let model = try snapshot(fixture)
#expect(PasteTarget.lanes(
selection: ItemReferenceSet(ids: [clipboardLane1], liveness: .live),
selection: ItemReferenceSet(ids: [clipboardLane1], container: .board),
snapshot: model
) == 1)
}
@@ -691,12 +694,12 @@ struct PasteTargetTests {
let model = try snapshot(fixture)
#expect(PasteTarget.lanes(
selection: ItemReferenceSet(ids: [clipboardCard1], liveness: .live),
selection: ItemReferenceSet(ids: [clipboardCard1], container: .board),
snapshot: model
) == 1)
}
@Test("Nothing (or something tombstoned) selected lands a lane at the board's right end")
@Test("Nothing (or a trash selection) lands a lane at the board's right end")
func rightEnd() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
@@ -704,7 +707,7 @@ struct PasteTargetTests {
#expect(PasteTarget.lanes(selection: .empty, snapshot: model) == 2)
#expect(PasteTarget.lanes(
selection: ItemReferenceSet(ids: [clipboardCard3], liveness: .trashed),
selection: ItemReferenceSet(ids: [clipboardCard3], container: .trash),
snapshot: model
) == 2)
}
+32 -32
View File
@@ -15,11 +15,11 @@ import Testing
@Suite("DragPayload")
struct DragPayloadTests {
private static func payload(kind: DragKind = .cards, side: Liveness = .live) -> DragPayload {
private static func payload(kind: DragKind = .cards, container: ItemContainer = .board) -> DragPayload {
DragPayload(
boardRoot: URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true),
kind: kind,
side: side,
container: container,
items: [
DragPayload.Item(id: "aaa", folder: "/Boards/Work.kanban/lane/aaa", title: "First"),
DragPayload.Item(id: "bbb", folder: "/Boards/Work.kanban/lane/bbb", title: nil)
@@ -30,8 +30,8 @@ struct DragPayloadTests {
@Test("A payload round-trips through its JSON representation unchanged")
func roundTrip() throws {
for kind in [DragKind.cards, .lanes] {
for side in [Liveness.live, .trashed] {
let original = Self.payload(kind: kind, side: side)
for container in [ItemContainer.board, .trash] {
let original = Self.payload(kind: kind, container: container)
let data = try #require(original.encoded())
#expect(DragPayload(data: data) == original)
}
@@ -62,10 +62,10 @@ struct DragPayloadTests {
#expect(Self.payload().plainText == "First\nUntitled")
}
@Test("The side survives the round trip, because it is what makes a trash drag a trash drag")
func sideSurvives() throws {
let data = try #require(Self.payload(side: .trashed).encoded())
#expect(DragPayload(data: data)?.side.liveness == .trashed)
@Test("The container survives the round trip, because it is what makes a trash drag a trash drag")
func containerSurvives() throws {
let data = try #require(Self.payload(container: .trash).encoded())
#expect(DragPayload(data: data)?.container == .trash)
}
}
@@ -95,25 +95,25 @@ struct DragLocalityTests {
/// The Finder volume model: within a board a drag rearranges, between boards it transfers.
@Test("Locality picks the default — within is a move, across is a copy")
func theDefault() {
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: none) == .move)
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: none) == .copy)
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: none) == .copy)
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: none) == .move)
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: none) == .copy)
#expect(DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: false, modifiers: none) == .copy)
}
@Test("⌥ forces copy and ⌘ forces move, each a no-op where it is already the default")
func modifiersOverride() {
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: option) == .copy)
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: command) == .move)
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: option) == .copy)
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: command) == .move)
// The no-ops.
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: command) == .move)
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: option) == .copy)
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: command) == .move)
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: option) == .copy)
}
@Test("⌘ wins over ⌥ when both are held")
func commandWinsOverOption() {
// Finder's own reduction, and the same precedence `ClickModifier.current` applies to clicks.
#expect(DragLocality.operation(
kind: .cards, side: .live, isWithinBoard: false, modifiers: [.option, .command]) == .move)
kind: .cards, container: .board, isWithinBoard: false, modifiers: [.option, .command]) == .move)
}
/// The first carve-out: "Lane drags never copy *within their board*. is simply ignored there:
@@ -122,13 +122,13 @@ struct DragLocalityTests {
func laneDragsNeverCopyWithinTheirBoard() {
for modifiers in [none, option, command, [.option, .command] as NSEvent.ModifierFlags] {
#expect(
DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: true, modifiers: modifiers) == .move,
DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: true, modifiers: modifiers) == .move,
"a within-board lane drag is a reorder whatever is held"
)
}
// Across boards the lane obeys the ordinary grammar again.
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: option) == .copy)
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: command) == .move)
#expect(DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: false, modifiers: option) == .copy)
#expect(DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: false, modifiers: command) == .move)
}
/// The second: a trash row's drag is copy-out grammar (04-interactions.md The trash). Within its
@@ -137,11 +137,11 @@ struct DragLocalityTests {
/// live copy either way.
@Test("A trash row drags as a restore at home and as a copy-out abroad")
func trashDragDefaults() {
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: true, modifiers: none) == .move)
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: false, modifiers: none) == .copy)
#expect(DragLocality.operation(kind: .cards, container: .trash, isWithinBoard: true, modifiers: none) == .move)
#expect(DragLocality.operation(kind: .cards, container: .trash, isWithinBoard: false, modifiers: none) == .copy)
#expect(DragLocality.operation(
kind: .cards, side: .trashed, isWithinBoard: false, modifiers: command) == .move)
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: true, modifiers: option) == .copy)
kind: .cards, container: .trash, isWithinBoard: false, modifiers: command) == .move)
#expect(DragLocality.operation(kind: .cards, container: .trash, isWithinBoard: true, modifiers: option) == .copy)
}
}
@@ -158,7 +158,7 @@ struct TrashDropTests {
/// The accepted session, with one clause at a time knocked out by the cases.
private func accepts(
kind: DragKind? = .cards,
side: Liveness = .live,
container: ItemContainer = .board,
isWithinBoard: Bool = true,
operation: TransferOperation = .move,
isTrashShown: Bool = true,
@@ -166,7 +166,7 @@ struct TrashDropTests {
) -> Bool {
TrashDrop.accepts(
kind: kind,
side: side,
container: container,
isWithinBoard: isWithinBoard,
operation: operation,
isTrashShown: isTrashShown,
@@ -196,12 +196,12 @@ struct TrashDropTests {
#expect(!accepts(kind: nil))
}
/// A trash row's drag is restore/copy-out grammar; dropped back where it came from it writes
/// nothing, so it never proposes.
@Test("A trash row dropped back on the trash is refused")
func theTrashedSideIsRefused() {
#expect(!accepts(side: .trashed))
#expect(!accepts(side: .trashed, isWithinBoard: false))
/// A trash card's drag is the restore; dropped back where it came from it writes nothing, so it
/// never proposes.
@Test("A trash card dropped back on the trash is refused")
func theTrashContainerIsRefused() {
#expect(!accepts(container: .trash))
#expect(!accepts(container: .trash, isWithinBoard: false))
}
/// "No move or paste ever targets the trash": a foreign card delivered into this board's trash
@@ -340,7 +340,7 @@ struct DropSettleTests {
.appendingPathComponent($0.id.rawValue, isDirectory: true)
},
heights: members.map { _ in 44 },
side: .live,
container: .board,
source: store
)
}
+112 -109
View File
@@ -16,20 +16,6 @@ import Testing
// MARK: - Fixtures
private func tombstoned(order: String, title: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
created: 2026-01-01T09:00:00Z
deleted: 2026-03-03T09:00:00Z
---
\(title) body.
"""
}
/// Three cards in the first lane, one in the second enough room for a run of two to insert
/// between siblings without either end being the answer.
@MainActor
@@ -55,12 +41,12 @@ private enum Foreign {
static let trashed = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"
}
/// The board every cross-board test drags *out of*: one lane holding a live card and a tombstoned
/// one, so the lane-copy rule has something to strip and the restore rules have a row to carry.
/// The board every cross-board test drags *out of*: one lane holding two cards, plus a card sitting
/// in the source board's own `.trash/` for the cross-board restore cases.
///
/// `colliding` puts the lane and its live card under identities the **destination** already holds,
/// which is the import boundary's whole question; the tombstoned card keeps its foreign identity
/// either way, so a colliding arrival can prove the degradation is per folder.
/// `colliding` puts the lane and its first card under identities the **destination** already holds,
/// which is the import boundary's whole question; the second card keeps its foreign identity either
/// way, so a colliding arrival can prove the degradation is per folder.
@MainActor
private func makeSourceBoard(colliding: Bool = false) throws -> WriterFixture {
let fixture = try WriterFixture()
@@ -69,7 +55,8 @@ private func makeSourceBoard(colliding: Bool = false) throws -> WriterFixture {
let cardName = colliding ? Ident.card1 : Foreign.card
try fixture.item(laneName, Item.rich(order: "1024", title: "Imported"))
try fixture.item("\(laneName)/\(cardName)", Item.rich(order: "1024", title: "Travelling"))
try fixture.item("\(laneName)/\(Foreign.trashed)", tombstoned(order: "2048", title: "Trashed"))
try fixture.item("\(laneName)/\(Foreign.second)", Item.rich(order: "2048", title: "Second"))
try fixture.item(".trash/\(Foreign.trashed)", Item.rich(order: "1024", title: "Trashed"))
return fixture
}
@@ -90,14 +77,14 @@ private func loaded(_ fixture: WriterFixture) throws -> BoardModel {
/// A lane's rendered card titles, in display order.
private func titles(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String] {
guard let lane = try loaded(fixture).lanes.first(where: { $0.id == laneID }) else { return [] }
return lane.cards.filter { !$0.isDeleted }.compactMap(\.title.value)
return lane.cards.compactMap(\.title.value)
}
/// A lane's rendered card folder names, in display order identity, where titles would not
/// distinguish an original from its copy.
private func ids(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String] {
guard let lane = try loaded(fixture).lanes.first(where: { $0.id == laneID }) else { return [] }
return lane.cards.filter { !$0.isDeleted }.map(\.id.rawValue)
return lane.cards.map(\.id.rawValue)
}
private func order(_ fixture: WriterFixture, _ relativePath: String) throws -> FieldValue<Double> {
@@ -183,18 +170,16 @@ struct MoveCardsTests {
#expect(store.banners.oneShots.isEmpty)
}
@Test("A destination that is gone, tombstoned, or empty of members writes nothing")
@Test("A destination that is gone, or a set that names nothing, writes nothing")
func noOps() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
let store = try BoardStore(rootURL: fixture.root)
let stamp = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
store.moveCards([card1], toLane: lane3, at: 0) // tombstoned lane
store.moveCards([card1], toLane: ItemID(rawValue: Ident.indexless), at: 0) // no such lane
store.moveCards([], toLane: lane2, at: 0) // nothing dragged
store.moveCards([ItemID(rawValue: Ident.indexless)], toLane: lane2, at: 0) // nothing live
store.moveCards([ItemID(rawValue: Ident.indexless)], toLane: lane2, at: 0) // nothing there
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == stamp)
#expect(try titles(lane2, in: fixture) == ["Fourth"])
@@ -315,14 +300,13 @@ struct CopyCardsTests {
func noOps() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
let store = try BoardStore(rootURL: fixture.root)
store.copyCards([card1], toLane: lane3, at: 0)
store.copyCards([card1], toLane: ItemID(rawValue: Ident.lane3), at: 0)
store.copyCards([], toLane: lane2, at: 0)
#expect(try titles(lane2, in: fixture) == ["Fourth"])
#expect(try fixture.entryNames(Ident.lane3) == ["index.md"])
#expect(!fixture.exists(Ident.lane3))
#expect(store.banners.oneShots.isEmpty)
}
}
@@ -411,14 +395,13 @@ struct ReceiveCardsTests {
defer { destination.tearDown() }
let source = try makeSourceBoard()
defer { source.tearDown() }
try destination.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
let store = try BoardStore(rootURL: destination.root)
store.receiveCards([source.url("\(Foreign.lane)/\(Foreign.card)")],
operation: .copy, toLane: lane3, at: 0)
operation: .copy, toLane: ItemID(rawValue: Ident.lane3), at: 0)
store.receiveCards([], operation: .copy, toLane: lane2, at: 0)
#expect(try destination.entryNames(Ident.lane3) == ["index.md"])
#expect(!destination.exists(Ident.lane3))
#expect(try titles(lane2, in: destination) == ["Fourth"])
#expect(source.exists("\(Foreign.lane)/\(Foreign.card)"))
#expect(store.banners.oneShots.isEmpty)
@@ -524,8 +507,12 @@ struct MoveLanesTests {
@Suite("BoardStore ▸ receiveLanes")
struct ReceiveLanesTests {
@Test("A lane copy transfers the content and strips the tombstoned cards")
func laneCopyStripsTombstones() throws {
/// 04-interactions.md Drag and drop, resettled 2026-07-28: "A lane carries exactly its cards
/// the trash is board-level (`.trash/`), so there is nothing lane-nested to strip or carry: copy
/// and -drag move alike transfer the lane's folder as it is; the old tombstone-stripping rule is
/// retired with the tombstone model."
@Test("A lane copy transfers the whole lane, minting fresh identities at every level")
func laneCopyRemintsThroughout() throws {
let destination = try makeBoard()
defer { destination.tearDown() }
let source = try makeSourceBoard()
@@ -538,19 +525,15 @@ struct ReceiveLanesTests {
#expect(model.lanes.map(\.title.value) == ["Imported", "Todo", "Doing"])
let arrived = try #require(model.lanes.first)
#expect(arrived.id.rawValue != Foreign.lane, "a copy mints fresh UUIDs at every level")
#expect(arrived.cards.map(\.title.value) == ["Travelling"],
"trash isn't content — the tombstoned card did not come")
#expect(arrived.cards.allSatisfy { !$0.isDeleted })
#expect(arrived.cards[0].id.rawValue != Foreign.card, "a copied lane's cards are new cards")
// The tombstoned original stays recoverable in the source board.
#expect(source.exists("\(Foreign.lane)/\(Foreign.trashed)"))
#expect(try source.indexText("\(Foreign.lane)/\(Foreign.trashed)").contains("deleted:"))
#expect(arrived.cards.map(\.title.value) == ["Travelling", "Second"], "nothing to strip")
#expect(arrived.cards.allSatisfy { $0.id.rawValue != Foreign.card },
"a copied lane's cards are new cards")
#expect(model.trash.isEmpty, "the source's trash is board-level and never travels with a lane")
#expect(store.banners.oneShots.isEmpty)
}
@Test("A lane move carries its tombstoned cards whole, into the destination's trash")
func laneMoveCarriesTombstones() throws {
@Test("A lane move carries its cards whole, identity and all")
func laneMoveCarriesItsCards() throws {
let destination = try makeBoard()
defer { destination.tearDown() }
let source = try makeSourceBoard()
@@ -563,10 +546,9 @@ struct ReceiveLanesTests {
#expect(model.lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, Foreign.lane],
"identity travels, and the drop position is honoured")
let arrived = try #require(model.lanes.last)
#expect(arrived.cards.map(\.id.rawValue) == [Foreign.card, Foreign.trashed])
#expect(arrived.cards[1].isDeleted, "the tombstone came along as-is")
#expect(TrashModel.entries(of: model).map(\.id) == [ItemID(rawValue: Foreign.trashed)],
"and it renders in the destination's trash")
#expect(arrived.cards.map(\.id.rawValue) == [Foreign.card, Foreign.second],
"a lane carries exactly its cards: nothing lane-nested to strip or carry")
#expect(model.trash.isEmpty, "and nothing arrived in the destination's own trash")
#expect(!source.exists(Foreign.lane))
#expect(store.banners.oneShots.isEmpty)
}
@@ -591,7 +573,7 @@ struct ReceiveLanesTests {
let arrivedCards = arrived.cards.map(\.id.rawValue)
#expect(arrivedCards.count == 2)
#expect(arrivedCards[0] != Ident.card1, "the colliding card was repaired too")
#expect(arrivedCards[1] == Foreign.trashed, "and nothing else was — degradation is per folder")
#expect(arrivedCards[1] == Foreign.second, "and nothing else was — degradation is per folder")
#expect(try titles(lane1, in: destination) == ["First", "Second", "Third"],
"the residents kept their identities and their ranks")
}
@@ -609,26 +591,37 @@ struct ReceiveLanesTests {
}
}
// MARK: - Drag to restore, positionally
// MARK: - Restore is an ordinary move out
/// A lane holding a live card, a tombstoned one, and another live one so a restore has somewhere
/// to land that is neither the head nor the tail.
/// A lane holding two cards, plus one card in the board's `.trash/` so a restore has somewhere to
/// land that is neither the head nor the tail.
///
/// **There is no tombstone anywhere in it.** "Restoring is an ordinary move out: drag a trash card
/// into any lane at any position there is no restore-specific machinery and no Put Back"
/// (03-board-ui.md § Trash, resettled 2026-07-28), so the fixture is an ordinary board plus a
/// reserved folder.
@MainActor
private func makeTrashBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Trashed"))
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Trashed"))
return fixture
}
/// Restoring out of the trash **through `moveCards` and `copyCards`, with no method of its own**.
///
/// That is the claim these tests exist to pin. The tombstone model needed `restoreByDrag` and
/// `receiveRestoredCards` because a restore had to clear a key as well as place a rank; a
/// materialized trash makes it "an ordinary move" (03-board-ui.md § Trash), so the ordinary drop
/// commits take a trash-side source and the retired pair is gone.
@MainActor
@Suite("BoardStore ▸ positional drag-to-restore")
struct RestoreByDragPositionTests {
@Suite("BoardStore ▸ restore by move-out")
struct RestoreByMoveOutTests {
@Test("The drop position sets the restored card's order")
func dropPositionSetsTheOrder() throws {
@@ -636,76 +629,89 @@ struct RestoreByDragPositionTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Index 0 among the lane's two live cards: ahead of both, not back at its recorded 2048.
store.restoreByDrag(cardID: card2, intoLane: lane1, at: 0)
// Index 0 among the lane's two cards: ahead of both.
store.moveCards([card2], toLane: lane1, at: 0)
#expect(try titles(lane1, in: fixture) == ["Trashed", "First", "Third"])
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(0))
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"), "same lane never moves a folder")
#expect(TrashModel.isEmpty(try loaded(fixture)))
#expect(!fixture.exists(".trash/\(Ident.card2)"), "the folder physically left the trash")
#expect(try loaded(fixture).trash.isEmpty)
#expect(store.banners.oneShots.isEmpty)
}
@Test("A cross-lane restore lands at the drop position, not at the bottom")
func crossLanePositional() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.restoreByDrag(cardID: card2, intoLane: lane2, at: 0)
#expect(try titles(lane2, in: fixture) == ["Trashed", "Fourth"])
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
#expect(try order(fixture, "\(Ident.lane2)/\(Ident.card2)") == .valid(0))
let text = try fixture.indexText("\(Ident.lane2)/\(Ident.card2)")
#expect(!text.contains("deleted:"))
}
@Test("An out-of-range index clamps to the lane's bottom")
func indexClamps() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.restoreByDrag(cardID: card2, intoLane: lane1, at: 99)
store.moveCards([card2], toLane: lane1, at: 99)
#expect(try titles(lane1, in: fixture) == ["First", "Third", "Trashed"])
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(4096))
}
@Test("A multi-row restore lands the run contiguously, in drop order, in one bracket")
@Test("The identity travels, exactly as it does for any move")
func identityTravels() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.moveCards([card2], toLane: lane2, at: 0)
#expect(try ids(lane2, in: fixture) == [Ident.card2, Ident.card4])
#expect(!fixture.exists(".trash/\(Ident.card2)"))
// Nothing about the restored card says it was ever deleted: there is no key to remove,
// because there is no key.
#expect(!(try fixture.indexText("\(Ident.lane2)/\(Ident.card2)").contains("deleted:")))
}
@Test("A multi-card restore lands the run contiguously, in one bracket")
func multiRestore() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "TrashedA"))
try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstoned(order: "3072", title: "TrashedB"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
// The trash's own order is its display order `card3` above `card2`, newest-first.
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "2048", title: "TrashedA"))
try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "TrashedB"))
let store = try BoardStore(rootURL: fixture.root)
store.restoreByDrag(cardIDs: [card3, card2], intoLane: lane2, at: 0)
store.moveCards([card3, card2], toLane: lane2, at: 0)
#expect(try titles(lane2, in: fixture) == ["TrashedB", "TrashedA", "Fourth"],
"the payload's order is the landing order")
#expect(TrashModel.isEmpty(try loaded(fixture)))
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card3)"))
"the trash's own order is the landing order")
#expect(try loaded(fixture).trash.isEmpty)
#expect(store.banners.oneShots.isEmpty)
}
@Test("A row that is not a trash row is skipped, and an empty list writes nothing")
func skipsWhatIsNotARow() throws {
@Test("⌥ copies out and leaves the original in the trash")
func copyOutLeavesTheOriginal() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.copyCards([card2], toLane: lane2, at: 0)
#expect(try titles(lane2, in: fixture) == ["Trashed", "Fourth"])
let landed = try #require(try ids(lane2, in: fixture).first)
#expect(landed != Ident.card2, "a copy out of the trash is still a copy")
#expect(fixture.exists(".trash/\(Ident.card2)"), "the original stays in the trash")
#expect(try loaded(fixture).trash.count == 1)
}
@Test("A card that is not in the trash and an empty list both write nothing")
func skipsWhatIsNotThere() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let untouched = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
// `card1` is live it has no trash row to drag and `indexless` names nothing.
store.restoreByDrag(cardIDs: [card1, ItemID(rawValue: Ident.indexless)], intoLane: lane2, at: 0)
store.restoreByDrag(cardIDs: [], intoLane: lane2, at: 0)
store.moveCards([ItemID(rawValue: Ident.indexless)], toLane: lane2, at: 0)
store.moveCards([], toLane: lane2, at: 0)
#expect(try titles(lane2, in: fixture) == ["Fourth"])
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == untouched)
@@ -713,52 +719,49 @@ struct RestoreByDragPositionTests {
}
}
/// The cross-board half **`receiveCards`, with no restore variant** (04-interactions.md The
/// trash: "Dropped on *another* board it follows the copy default a live copy lands there, the
/// original stays in the source trash; -drag forces the true cross-board restore-move").
@MainActor
@Suite("BoardStore ▸ receiveRestoredCards")
struct ReceiveRestoredCardsTests {
@Suite("BoardStore ▸ cross-board restore")
struct CrossBoardRestoreTests {
@Test("A cross-board restore-copy lands live and leaves the source tombstone standing")
func restoreCopyStripsDeletedAndKeepsTheOriginal() throws {
@Test("A cross-board copy out of the trash lands a fresh identity and leaves the original")
func copyLeavesTheOriginal() throws {
let destination = try makeBoard()
defer { destination.tearDown() }
let source = try makeSourceBoard()
defer { source.tearDown() }
let store = try BoardStore(rootURL: destination.root)
store.receiveRestoredCards([source.url("\(Foreign.lane)/\(Foreign.trashed)")],
operation: .copy, toLane: lane2, at: 0)
store.receiveCards([source.url(".trash/\(Foreign.trashed)")],
operation: .copy, toLane: lane2, at: 0)
#expect(try titles(lane2, in: destination) == ["Trashed", "Fourth"])
let landedIDs = try ids(lane2, in: destination)
let landed = try #require(landedIDs.first)
let landed = try #require(try ids(lane2, in: destination).first)
#expect(landed != Foreign.trashed, "a copy out of the trash is still a copy")
let text = try destination.indexText("\(Ident.lane2)/\(landed)")
#expect(!text.contains("deleted:"), "`deleted:` is stripped on paste/duplicate/drop")
#expect(text.contains("created: 2026-01-01T09:00:00Z"), "a copy is a fork")
// The tombstoned original stays recoverable in the source board's trash.
#expect(source.exists("\(Foreign.lane)/\(Foreign.trashed)"))
#expect(try source.indexText("\(Foreign.lane)/\(Foreign.trashed)").contains("deleted:"))
#expect(source.exists(".trash/\(Foreign.trashed)"), "the original stays in the source trash")
#expect(store.banners.oneShots.isEmpty)
}
@Test("A cross-board restore-move clears the tombstone and the source loses the folder")
func restoreMoveClearsTheTombstone() throws {
@Test("A ⌘-drag move out of another board's trash carries the identity and empties it")
func moveCarriesTheIdentity() throws {
let destination = try makeBoard()
defer { destination.tearDown() }
let source = try makeSourceBoard()
defer { source.tearDown() }
let store = try BoardStore(rootURL: destination.root)
store.receiveRestoredCards([source.url("\(Foreign.lane)/\(Foreign.trashed)")],
operation: .move, toLane: lane2, at: 1)
store.receiveCards([source.url(".trash/\(Foreign.trashed)")],
operation: .move, toLane: lane2, at: 1)
#expect(try ids(lane2, in: destination) == [Ident.card4, Foreign.trashed], "identity travels")
let text = try destination.indexText("\(Ident.lane2)/\(Foreign.trashed)")
#expect(!text.contains("deleted:"))
#expect(!source.exists("\(Foreign.lane)/\(Foreign.trashed)"), "the tombstone left the source")
#expect(TrashModel.isEmpty(try loaded(source)))
#expect(TrashModel.isEmpty(try loaded(destination)))
#expect(!source.exists(".trash/\(Foreign.trashed)"), "the card left the source trash")
#expect(try loaded(source).trash.isEmpty)
#expect(try loaded(destination).trash.isEmpty)
#expect(store.banners.oneShots.isEmpty)
}
}
+10 -13
View File
@@ -184,27 +184,25 @@ struct ImportAttachmentsToCardTests {
#expect(store.banners.oneShots.isEmpty)
}
@Test("A tombstoned, ancestor-tombstoned, or vanished target writes nothing")
@Test("A trashed or vanished target writes nothing")
func inertTargets() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
// A tombstoned card, and a live card under a tombstoned lane effective liveness is
// ancestor-walked, so both render nowhere and both are inert.
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second"))
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
try fixture.item("\(Ident.lane3)/\(Ident.card4)", Item.rich(order: "1024", title: "Buried"))
// "Finder file drops on trash cards are inert" (04 The trash), and a card whose lane was
// deleted is simply not there.
try fixture.move("\(Ident.lane1)/\(Ident.card2)", toTrash: Ident.card2)
let store = try BoardStore(rootURL: fixture.root)
let shot = try sources.file("shot.png")
store.importAttachments([shot], toCard: card2) // tombstoned card
store.importAttachments([shot], toCard: ItemID(rawValue: Ident.card4)) // under a tombstoned lane
store.importAttachments([shot], toCard: card2) // a trash card
store.importAttachments([shot], toCard: ItemID(rawValue: Ident.card4)) // its lane is gone
store.importAttachments([shot], toCard: ItemID(rawValue: Ident.indexless)) // no such card
store.importAttachments([shot], toCard: lane1) // a lane, not a card
store.importAttachments([], toCard: card1) // nothing dropped
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)/attachments"))
#expect(!fixture.exists(".trash/\(Ident.card2)/attachments"))
#expect(!fixture.exists("\(Ident.lane3)/\(Ident.card4)/attachments"))
#expect(!fixture.exists("\(Ident.lane1)/attachments"))
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)/attachments"))
@@ -387,21 +385,20 @@ struct CreateCardsFromFilesTests {
#expect(store.banners.oneShots.isEmpty)
}
@Test("A tombstoned, vanished, or empty destination writes nothing")
@Test("A deleted, vanished, or empty destination writes nothing")
func inertDestinations() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
let store = try BoardStore(rootURL: fixture.root)
let shot = try sources.file("shot.png")
store.createCards(fromFiles: [shot], inLane: lane3, at: 0) // tombstoned
store.createCards(fromFiles: [shot], inLane: lane3, at: 0) // no such lane
store.createCards(fromFiles: [shot], inLane: ItemID(rawValue: Ident.indexless), at: 0) // no such lane
store.createCards(fromFiles: [], inLane: lane1, at: 0) // nothing dropped
#expect(try fixture.entryNames(Ident.lane3) == ["index.md"])
#expect(!fixture.exists(Ident.lane3))
#expect(try titles(lane1, in: fixture) == ["First", "Second", "Third"])
#expect(store.banners.oneShots.isEmpty)
}
+7 -7
View File
@@ -220,21 +220,21 @@ struct InlineRenameWriteTests {
#expect(store.banners.oneShots.isEmpty)
}
@Test("A commit at a card under a tombstoned lane writes nothing — liveness is effective")
func targetUnderATombstonedLaneWritesNothing() throws {
@Test("A commit at a card that entered the trash writes nothing")
func targetInTheTrashWritesNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// The lane carries the tombstone; the card's own flag is untouched, and it renders nowhere
// regardless (03-board-ui.md collapses the lane to one trash entry).
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
// "Entering the trash is a vanish from the board; nothing is ever written into a vanished
// folder" (04 Grammar).
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
let before = try fixture.indexData(".trash/\(Ident.card1)")
store.transient.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft("Never lands")
store.commitRename()
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
#expect(try fixture.indexData(".trash/\(Ident.card1)") == before)
#expect(store.banners.oneShots.isEmpty)
}
+39 -48
View File
@@ -75,9 +75,9 @@ private func target(
width: CGFloat = 100,
height: CGFloat = 100,
kind: SelectionKind = .card,
side: Liveness = .live
container: ItemContainer = .board
) -> MarqueeTarget {
MarqueeTarget(id: id, kind: kind, side: side, frame: CGRect(x: x, y: y, width: width, height: height))
MarqueeTarget(id: id, kind: kind, container: container, frame: CGRect(x: x, y: y, width: width, height: height))
}
/// A two-by-two grid: `card1` `card3` on the top row, `card2` `card4` beneath them the smallest
@@ -145,7 +145,7 @@ struct NavigationMathTests {
@Test("The predicate is the ⇧-arrow's restriction — the trash side is simply not a candidate")
func predicateRestrictsCandidates() {
let trashed = target(card2, x: 0, y: 100, side: .trashed)
let trashed = target(card2, x: 0, y: 100, container: .trash)
let live = target(card3, x: 0, y: 400)
let all = [trashed, live]
@@ -154,7 +154,7 @@ struct NavigationMathTests {
"a plain arrow walks across the boundary"
)
#expect(
NavigationMath.nearest(from: originFrame, direction: .down, among: all, where: { $0.side == .live }) == card3
NavigationMath.nearest(from: originFrame, direction: .down, among: all, where: { $0.container == .board }) == card3
)
}
}
@@ -216,10 +216,10 @@ struct SuccessorTests {
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(SelectionGrammar.successor(afterDeleting: [card2], in: snapshot) == card3)
#expect(SelectionGrammar.successor(afterDeleting: [card1], in: snapshot) == card2)
#expect(SelectionGrammar.successor(afterDeleting: [card2], snapshot: snapshot) == card3)
#expect(SelectionGrammar.successor(afterDeleting: [card1], snapshot: snapshot) == card2)
#expect(
SelectionGrammar.successor(afterDeleting: [card1, card2], in: snapshot) == card3,
SelectionGrammar.successor(afterDeleting: [card1, card2], snapshot: snapshot) == card3,
"a block's successor is the first survivor after its last member"
)
}
@@ -230,8 +230,8 @@ struct SuccessorTests {
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(SelectionGrammar.successor(afterDeleting: [card4], in: snapshot) == card3)
#expect(SelectionGrammar.successor(afterDeleting: [card3, card4], in: snapshot) == card2)
#expect(SelectionGrammar.successor(afterDeleting: [card4], snapshot: snapshot) == card3)
#expect(SelectionGrammar.successor(afterDeleting: [card3, card4], snapshot: snapshot) == card2)
}
@Test("A survivor between the members is found forwards first")
@@ -242,7 +242,7 @@ struct SuccessorTests {
// Doomed at positions 0 and 2: forward from the last one finds card4, which is what makes
// repeated keep moving down rather than bouncing back up the lane.
#expect(SelectionGrammar.successor(afterDeleting: [card1, card3], in: snapshot) == card4)
#expect(SelectionGrammar.successor(afterDeleting: [card1, card3], snapshot: snapshot) == card4)
}
@Test("An emptied container selects nothing")
@@ -251,10 +251,10 @@ struct SuccessorTests {
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(SelectionGrammar.successor(afterDeleting: [card1, card2, card3, card4], in: snapshot) == nil)
#expect(SelectionGrammar.successor(afterDeleting: [], in: snapshot) == nil)
#expect(SelectionGrammar.successor(afterDeleting: [card1, card2, card3, card4], snapshot: snapshot) == nil)
#expect(SelectionGrammar.successor(afterDeleting: [], snapshot: snapshot) == nil)
#expect(
SelectionGrammar.successor(afterDeleting: [ItemID(rawValue: "nobody")], in: snapshot) == nil,
SelectionGrammar.successor(afterDeleting: [ItemID(rawValue: "nobody")], snapshot: snapshot) == nil,
"ids naming nothing name no container either"
)
}
@@ -267,7 +267,7 @@ struct SuccessorTests {
// card5 is later than card2 in flatten order (lane `order`, then card `order`), so the
// container is lane2 the same "last member" anchor N and paste already share.
#expect(SelectionGrammar.successor(afterDeleting: [card2, card5], in: snapshot) == card6)
#expect(SelectionGrammar.successor(afterDeleting: [card2, card5], snapshot: snapshot) == card6)
}
@Test("Lanes follow the same rule in the live lane order")
@@ -276,9 +276,9 @@ struct SuccessorTests {
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(SelectionGrammar.successor(afterDeleting: [lane1], in: snapshot) == lane2)
#expect(SelectionGrammar.successor(afterDeleting: [lane3], in: snapshot) == lane2, "the last lane's predecessor")
#expect(SelectionGrammar.successor(afterDeleting: [lane1, lane2, lane3], in: snapshot) == nil)
#expect(SelectionGrammar.successor(afterDeleting: [lane1], snapshot: snapshot) == lane2)
#expect(SelectionGrammar.successor(afterDeleting: [lane3], snapshot: snapshot) == lane2, "the last lane's predecessor")
#expect(SelectionGrammar.successor(afterDeleting: [lane1, lane2, lane3], snapshot: snapshot) == nil)
}
@Test("⌫ selects the successor immediately, before the reload echoes the tombstone back")
@@ -287,7 +287,7 @@ struct SuccessorTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card2], liveness: .live)
store.select([card2], in: .board)
store.deleteSelection()
#expect(store.selection.ids == [card3])
@@ -306,7 +306,7 @@ struct SuccessorTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card5, card6], liveness: .live)
store.select([card5, card6], in: .board)
store.deleteSelection()
#expect(store.selection.isEmpty)
@@ -324,13 +324,13 @@ struct SelectionHeadTests {
func headDefaults() throws {
let state = TransientBoardState()
state.select([card1], liveness: .live)
state.select([card1], in: .board)
#expect(state.selectionHead == card1)
state.select([card1, card2], liveness: .live)
state.select([card1, card2], in: .board)
#expect(state.selectionHead == nil, "a set with no gesture behind it names no cursor")
state.select([card1, card2], liveness: .live, anchor: card1, head: card2)
state.select([card1, card2], in: .board, anchor: card1, head: card2)
#expect(state.selectionAnchor == card1)
#expect(state.selectionHead == card2, "an explicit head is kept whatever the count")
@@ -346,9 +346,9 @@ struct SelectionHeadTests {
let snapshot = try load(fixture)
let outcome = SelectionGrammar.click(
SelectionTarget(id: card3, kind: .card, side: .live),
SelectionTarget(id: card3, kind: .card, container: .board),
modifier: .shift,
selection: ItemReferenceSet(ids: [card1], liveness: .live),
selection: ItemReferenceSet(ids: [card1], container: .board),
anchor: card1,
snapshot: snapshot
)
@@ -364,7 +364,7 @@ struct SelectionHeadTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1, card2], liveness: .live, anchor: card1, head: card2)
store.select([card1, card2], in: .board, anchor: card1, head: card2)
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
store.handleWatcherEvent(.treeChanged(.foreign))
@@ -375,25 +375,16 @@ struct SelectionHeadTests {
#expect(store.transient.selectionAnchor == card1, "the anchor survived — it is still in the tree")
}
@Test("A liveness flip is a vanish for the head too")
func resolveDropsALivenessFlippedHead() async throws {
@Test("A container crossing is a vanish for the head too")
func resolveDropsAContainerCrossedHead() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1], liveness: .live)
store.select([card1], in: .board)
#expect(store.transient.selectionHead == card1)
try fixture.item("\(Ident.lane1)/\(Ident.card1)", """
---
schema: 1
title: First
order: 1024
deleted: 2026-03-03T09:00:00Z
---
First body.
""")
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
@@ -416,7 +407,7 @@ struct SortWriteTests {
let untouchedFirst = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
let untouchedFourth = try fixture.indexText("\(Ident.lane1)/\(Ident.card4)")
store.select([card3], liveness: .live)
store.select([card3], in: .board)
store.sortSelection(.up)
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card3, card2, card4])
@@ -435,7 +426,7 @@ struct SortWriteTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card2, card4], liveness: .live)
store.select([card2, card4], in: .board)
store.sortSelection(.up)
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2, card4, card3])
@@ -447,7 +438,7 @@ struct SortWriteTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1, card2], liveness: .live)
store.select([card1, card2], in: .board)
store.sortSelection(.down)
#expect(try cardOrder(Ident.lane1, in: fixture) == [card3, card1, card2, card4])
@@ -467,7 +458,7 @@ struct SortWriteTests {
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2])
store.select([card2], liveness: .live)
store.select([card2], in: .board)
store.sortSelection(.up)
#expect(try cardOrder(Ident.lane1, in: fixture) == [card2, card1])
@@ -482,16 +473,16 @@ struct SortWriteTests {
#expect(store.sortPlan(.up) == nil, "nothing selected")
store.select([lane1], liveness: .live)
store.select([lane1], in: .board)
#expect(store.sortPlan(.up) == nil, "a lane selection — ⌥⌘↑/⌥⌘↓ are inert on lanes")
store.select([card2, card5], liveness: .live)
store.select([card2, card5], in: .board)
#expect(store.sortPlan(.up) == nil, "a card selection spanning lanes — cards never change lanes by ⌘-arrow")
store.select([card1], liveness: .trashed)
store.select([card1], in: .trash)
#expect(store.sortPlan(.up) == nil, "a tombstoned selection")
store.select([card1], liveness: .live)
store.select([card1], in: .board)
#expect(store.sortPlan(.up) == nil, "already at the top")
#expect(store.sortPlan(.down) != nil, "but the other direction is live")
}
@@ -513,7 +504,7 @@ struct MoveLaneConventionTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let lanes = SelectionGrammar.liveLanes(in: store.snapshot)
let lanes = SelectionGrammar.lanes(in: store.snapshot)
#expect(lanes == [lane1, lane2, lane3])
let from = try #require(lanes.firstIndex(of: lane2))
@@ -527,7 +518,7 @@ struct MoveLaneConventionTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let lanes = SelectionGrammar.liveLanes(in: store.snapshot)
let lanes = SelectionGrammar.lanes(in: store.snapshot)
let from = try #require(lanes.firstIndex(of: lane1))
store.moveLane(lane1, toIndex: from + 1)
+9 -9
View File
@@ -665,9 +665,9 @@ struct LooseFilePasteTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live)
harness.store.select([ItemID(rawValue: Ident.card1)], in: .board)
harness.clipboard.copy(from: harness.store)
target.select([ItemID(rawValue: Ident.lane4)], liveness: .live)
target.select([ItemID(rawValue: Ident.lane4)], in: .board)
await harness.clipboard.paste(into: target)?.value
let arrived = try arrivedCard(in: destination)
@@ -693,9 +693,9 @@ struct LooseFilePasteTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live)
harness.store.select([ItemID(rawValue: Ident.card1)], in: .board)
harness.clipboard.copy(from: harness.store)
target.select([ItemID(rawValue: Ident.lane4)], liveness: .live)
target.select([ItemID(rawValue: Ident.lane4)], in: .board)
await harness.clipboard.paste(into: target)?.value
let arrived = try arrivedCard(in: destination)
@@ -718,9 +718,9 @@ struct LooseFilePasteTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live)
harness.store.select([ItemID(rawValue: Ident.card1)], in: .board)
harness.clipboard.copy(from: harness.store)
target.select([ItemID(rawValue: Ident.lane4)], liveness: .live)
target.select([ItemID(rawValue: Ident.lane4)], in: .board)
await harness.clipboard.paste(into: target)?.value
#expect(harness.fixture.exists("\(cardPath)/notes.txt"))
@@ -735,7 +735,7 @@ struct LooseFilePasteTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([ItemID(rawValue: Ident.lane1)], liveness: .live)
harness.store.select([ItemID(rawValue: Ident.lane1)], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: target)?.value
@@ -760,7 +760,7 @@ struct LooseFilePasteTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live)
harness.store.select([ItemID(rawValue: Ident.card1)], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
// The snapshot goes missing between the copy and the paste 04's degraded paste.
@@ -768,7 +768,7 @@ struct LooseFilePasteTests {
try FileManager.default.removeItem(at: harness.staging.appendingPathComponent(staged))
}
target.select([ItemID(rawValue: Ident.lane4)], liveness: .live)
target.select([ItemID(rawValue: Ident.lane4)], in: .board)
await harness.clipboard.paste(into: target)?.value
let arrived = try arrivedCard(in: destination)
+28 -28
View File
@@ -38,7 +38,6 @@ private func makeBoard() throws -> WriterFixture {
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Archive"))
return fixture
}
@@ -71,14 +70,14 @@ struct NewCardTargetTests {
// "With a card selected, the new card is created in that card's lane, immediately after it
// (paste-anchor consistency)."
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], liveness: .live))
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], container: .board))
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card1))
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card3], liveness: .live))
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card3], container: .board))
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))
// The last card in a lane is still an anchor here "after the last card" and "at the
// bottom" coincide, and it is the commit that notices (`BoardStore.insertionIndex`).
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card2], liveness: .live))
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card2], container: .board))
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2))
}
@@ -89,7 +88,7 @@ struct NewCardTargetTests {
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// "With a lane selected, appended at its bottom (Return consistency)."
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane2], liveness: .live))
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane2], container: .board))
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
}
@@ -107,25 +106,25 @@ struct NewCardTargetTests {
#expect(resolve(snapshot) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
#expect(resolve(snapshot, lastActive: ItemID(rawValue: Ident.indexless))
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
// A *tombstoned* lane is not a target either it renders nowhere, and the trash is never a
// creation destination.
// A lane that has been deleted is not a target either a lane delete is physical, so the
// memory simply names nothing.
#expect(resolve(snapshot, lastActive: lane3)
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
}
@Test("A tombstoned selection never anchors creation — it behaves as nothing selected")
func aTombstonedSelectionNeverAnchors() throws {
@Test("A trash selection never anchors creation — it behaves as nothing selected")
func aTrashSelectionNeverAnchors() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// Settled in 04 The map, on the N rule's own wording: "a **tombstoned** selection, which
// never anchors creation". The trash-side lane is a real lane on disk with a live sibling
// list the rule must not let its identity leak in as a target.
let trashed = ItemReferenceSet(ids: [lane3], liveness: .trashed)
#expect(resolve(snapshot, selection: trashed, lastActive: lane2)
// 04 The map, on the N rule's own wording: "with nothing selected or a **trash**
// selection, which never anchors creation". A trashed card's board-side lane must not leak
// in as a target.
let inTrash = ItemReferenceSet(ids: [card1], container: .trash)
#expect(resolve(snapshot, selection: inTrash, lastActive: lane2)
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
#expect(resolve(snapshot, selection: trashed)
#expect(resolve(snapshot, selection: inTrash)
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
}
@@ -140,21 +139,22 @@ struct NewCardTargetTests {
// target New Card, Return-creation, and Paste with a card payload disable via menu
// validation until a lane exists."
#expect(resolve(empty) == nil)
#expect(resolve(empty, selection: ItemReferenceSet(ids: [card1], liveness: .live)) == nil)
#expect(resolve(empty, selection: ItemReferenceSet(ids: [card1], container: .board)) == nil)
#expect(resolve(empty, lastActive: lane1) == nil)
}
@Test("A board whose every lane is tombstoned is a zero-lane board")
func everyLaneTombstonedIsAlsoZeroLane() throws {
let fixture = try WriterFixture()
@Test("A board whose every lane has been deleted is a zero-lane board")
func everyLaneDeletedIsAlsoZeroLane() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
for lane in [Ident.lane1, Ident.lane2, Ident.lane3] where fixture.exists(lane) {
try FileManager.default.removeItem(at: fixture.url(lane))
}
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// The lanes are still in the snapshot the trash renders them but none is on the board,
// and "every lane deleted" is the design's own second reading of the zero-lane case.
#expect(snapshot.lanes.count == 1)
// "Every lane deleted" is the design's own second reading of the zero-lane case and a lane
// delete is physical now, so it is literally the same board as the hand-made one above.
#expect(snapshot.lanes.isEmpty)
#expect(resolve(snapshot) == nil)
}
@@ -167,15 +167,15 @@ struct NewCardTargetTests {
// "A multi-selection anchors at its last member in flatten order (lane `order`, then card
// `order`, the multi-drag order)": card3 lives in lane2, which sorts after lane1's pair, so
// creation follows card3 the last-active lane never enters into it.
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card3], liveness: .live), lastActive: lane1)
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card3], container: .board), lastActive: lane1)
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))
// Within one lane the flatten order is card order: card2 sorts after card1.
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card2], liveness: .live), lastActive: lane2)
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card2], container: .board), lastActive: lane2)
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2))
// A multi-LANE selection appends to the last selected lane's bottom.
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane1, lane2], liveness: .live))
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane1, lane2], container: .board))
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
}
@@ -187,7 +187,7 @@ struct NewCardTargetTests {
// A selection naming something the board does not render the reload that drops it has not
// landed yet must not refuse the creation the user just asked for.
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], liveness: .live))
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], container: .board))
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
}
}
+84 -93
View File
@@ -61,9 +61,9 @@ struct PasteFromStagingTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
@@ -85,9 +85,9 @@ struct PasteFromStagingTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
@@ -107,9 +107,9 @@ struct PasteFromStagingTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
#expect(try pastedTitles(clipboardLane1, in: harness.fixture) == ["First", "Second"])
@@ -123,13 +123,13 @@ struct PasteFromStagingTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
target.handleWatcherEvent(.treeChanged(.appMediated))
await target.awaitQuiescence()
target.select([destinationLane], liveness: .live)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
let ids = try pastedIDs(destinationLane, in: destination)
@@ -142,7 +142,7 @@ struct PasteFromStagingTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: harness.store)?.value
@@ -159,15 +159,15 @@ struct PasteFromStagingTests {
@Suite("Paste ▸ lanes")
struct PasteLaneTests {
@Test("A pasted lane copy takes fresh GUIDs throughout and strips tombstoned cards")
func laneCopyStripsTombstones() async throws {
@Test("A pasted lane copy takes fresh GUIDs throughout and carries exactly its cards")
func laneCopyRemintsThroughout() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardLane1], liveness: .live)
harness.store.select([clipboardLane1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: target)?.value
@@ -176,12 +176,11 @@ struct PasteLaneTests {
let arrived = try #require(model.lanes.last)
#expect(arrived.id.rawValue != Ident.lane1)
#expect(arrived.title.value == "Todo")
// The tombstoned card is gone "the copy transfers content, and trash isn't content".
// "A lane carries exactly its cards the trash is board-level, so there is nothing
// lane-nested to strip or carry" (04 Drag and drop, resettled 2026-07-28).
#expect(arrived.cards.count == 2)
#expect(arrived.cards.allSatisfy { !$0.isDeleted })
#expect(arrived.cards.map(\.id.rawValue).allSatisfy { $0 != Ident.card1 && $0 != Ident.card2 })
// The tombstoned original is still recoverable where it always was.
#expect(try lane(clipboardLane1, in: harness.fixture)?.cards.count == 3)
#expect(try pasted(harness.fixture).trash.count == 1, "and the source's trash is untouched")
}
@Test("A lane paste with nothing selected lands at the board's right end")
@@ -192,7 +191,7 @@ struct PasteLaneTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardLane1], liveness: .live)
harness.store.select([clipboardLane1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: target)?.value
@@ -208,30 +207,30 @@ struct PasteLaneTests {
try empty.item("", Item.board)
let target = try BoardStore(rootURL: empty.root)
harness.store.select([clipboardLane1], liveness: .live)
harness.store.select([clipboardLane1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: target)?.value
#expect(try pasted(empty).lanes.compactMap(\.title.value) == ["Todo"])
}
@Test("A lane cut-move carries its tombstoned cards whole")
func laneCutMoveCarriesTombstones() async throws {
@Test("A lane cut-move carries its cards whole, identity and all")
func laneCutMoveCarriesItsCards() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardLane1], liveness: .live)
harness.store.select([clipboardLane1], in: .board)
harness.clipboard.cut(from: harness.store)
await harness.clipboard.paste(into: target)?.value
let model = try pasted(destination)
let arrived = try #require(model.lanes.first { $0.id == clipboardLane1 })
// Identity travelled, and the tombstone landed in the destination's trash.
#expect(arrived.cards.count == 3)
#expect(arrived.cards.contains { $0.isDeleted })
// Identity travelled, and every card came with it nothing to strip.
#expect(arrived.cards.count == 2)
#expect(model.trash.isEmpty, "the source board's trash is board-level and stays there")
// The lane left the source board entirely.
#expect(try pasted(harness.fixture).lanes.map(\.id) == [clipboardLane2])
}
@@ -241,7 +240,7 @@ struct PasteLaneTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardLane1], liveness: .live)
harness.store.select([clipboardLane1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: harness.store)?.value
@@ -259,8 +258,8 @@ struct PasteLaneTests {
@Suite("Paste ▸ from the trash")
struct PasteFromTrashTests {
@Test("A card copied out of the trash arrives live")
func cardArrivesLive() async throws {
@Test("A card copied out of the trash is an ordinary copy of an ordinary card")
func cardCopiesOutOrdinarily() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
@@ -268,46 +267,39 @@ struct PasteFromTrashTests {
let target = try BoardStore(rootURL: destination.root)
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardCard3], liveness: .trashed)
harness.store.select([clipboardCard3], in: .trash)
harness.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"])
// And the tombstoned original stays in the source trash copy-out, never a move.
#expect(try lane(clipboardLane1, in: harness.fixture)?
.cards.first { $0.id == clipboardCard3 }?.isDeleted == true)
// The original stays in the source trash a copy is a copy (04 The trash: "C copies a
// trash card; a live copy lands wherever pasted, like copying out of Finder's Trash").
#expect(try pasted(harness.fixture).trash.map(\.id) == [clipboardCard3])
// And nothing had to be stripped on arrival: a trashed card carries no key at all.
let landed = try #require(try lane(destinationLane, in: destination)?.cards.last)
#expect(landed.deleted.isMissing)
}
@Test("A lane entry copied out of the trash arrives live, its tombstoned interior cards stripped")
func laneEntryStripsBothWays() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
// A tombstoned lane holding one plain card and one that carries its own tombstone.
try fixture.item(Ident.lane1, tombstonedItem(order: "1024", title: "Archive"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Kept"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstonedItem(order: "2048", title: "Gone"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
let harness = try ClipboardHarness(fixture: fixture)
defer { try? FileManager.default.removeItem(at: harness.staging) }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
/// **Cut in the trash, paste into a lane, is the keyboard-native restore** (04-interactions.md
/// The trash, resettled 2026-07-28) "an ordinary folder move", which is exactly what the
/// armed cut already does.
@Test("Cut in the trash and paste into a lane is the keyboard restore — the folder moves")
func cutFromTheTrashIsTheKeyboardRestore() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardLane1], liveness: .trashed)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: target)?.value
harness.store.select([clipboardCard3], in: .trash)
harness.clipboard.cut(from: harness.store)
harness.store.select([clipboardLane2], in: .board)
await harness.clipboard.paste(into: harness.store)?.value
let model = try pasted(destination)
let arrived = try #require(model.lanes.last)
#expect(arrived.isDeleted == false)
#expect(arrived.title.value == "Archive")
#expect(arrived.cards.count == 1)
#expect(arrived.cards.first?.title.value == "Kept")
#expect(arrived.cards.first?.isDeleted == false)
let model = try pasted(harness.fixture)
#expect(model.trash.isEmpty, "the folder left the trash")
let lane = try #require(model.lanes.first { $0.id == clipboardLane2 })
#expect(lane.cards.map(\.id).contains(clipboardCard3), "identity travels — it is a move")
#expect(harness.store.transient.pendingCut.isEmpty, "the cut was consumed")
}
}
@@ -337,9 +329,9 @@ struct PasteSearchAndStalenessTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live)
target.select([destinationLane], in: .board)
// "First" would be hidden by this query exactly the card that must not arrive invisible.
target.searchQuery = "resident"
await harness.clipboard.paste(into: target)?.value
@@ -356,7 +348,7 @@ struct PasteSearchAndStalenessTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardLane2], liveness: .live)
harness.store.select([clipboardLane2], in: .board)
harness.clipboard.copy(from: harness.store)
target.searchQuery = "resident"
await harness.clipboard.paste(into: target)?.value
@@ -373,10 +365,10 @@ struct PasteSearchAndStalenessTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
harness.pasteboard.takeOver()
target.select([destinationLane], liveness: .live)
target.select([destinationLane], in: .board)
target.searchQuery = "resident"
// `refresh()` at the front of the paste sees the moved changeCount, so there is no payload
@@ -395,9 +387,9 @@ struct PasteSearchAndStalenessTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live)
target.select([destinationLane], in: .board)
target.searchQuery = "resident"
// The gesture passed validation; the takeover lands while the task is still waiting on the
@@ -427,9 +419,9 @@ struct PasteCutTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.cut(from: harness.store)
target.select([destinationLane], liveness: .live)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
// Identity travelled.
@@ -450,13 +442,13 @@ struct PasteCutTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.cut(from: harness.store)
target.select([destinationLane], liveness: .live)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
target.handleWatcherEvent(.treeChanged(.appMediated))
await target.awaitQuiescence()
target.select([destinationLane], liveness: .live)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
let ids = try pastedIDs(destinationLane, in: destination)
@@ -474,12 +466,12 @@ struct PasteCutTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.cut(from: harness.store)
// The source board closes its store goes, and with it the cut's arming.
harness.store.transient.pendingCut = .empty
target.select([destinationLane], liveness: .live)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
// A copy: a fresh identity at the destination, and the original still at home.
@@ -496,22 +488,21 @@ struct PasteCutTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1, clipboardCard2], liveness: .live)
harness.store.select([clipboardCard1, clipboardCard2], in: .board)
harness.clipboard.cut(from: harness.store)
// One of the two is tombstoned before the paste: the reload ejects it from the pending cut.
// One of the two is deleted before the paste: the reload ejects it from the pending cut.
harness.store.delete([clipboardCard1])
harness.store.handleWatcherEvent(.treeChanged(.appMediated))
await harness.store.awaitQuiescence()
#expect(harness.store.transient.pendingCut.ids == [clipboardCard2])
target.select([destinationLane], liveness: .live)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
#expect(try pastedIDs(destinationLane, in: destination) == [Ident.indexless, Ident.card2])
// The tombstoned one stayed behind, in the source board's trash.
#expect(try lane(clipboardLane1, in: harness.fixture)?
.cards.first { $0.id == clipboardCard1 }?.isDeleted == true)
// The deleted one stayed behind, in the source board's trash.
#expect(try pasted(harness.fixture).trash.map(\.id).contains(clipboardCard1))
}
@Test("A cut emptied down to nothing is simply void — a paste copies instead")
@@ -522,14 +513,14 @@ struct PasteCutTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.cut(from: harness.store)
harness.store.delete([clipboardCard1])
harness.store.handleWatcherEvent(.treeChanged(.appMediated))
await harness.store.awaitQuiescence()
#expect(harness.store.transient.pendingCut.isEmpty)
target.select([destinationLane], liveness: .live)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
// The staged snapshot is still there, so the paste is a copy content intact.
@@ -551,7 +542,7 @@ struct PasteFallbackTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
// The snapshot goes a swept tree, a full disk, an unreadable container.
@@ -560,7 +551,7 @@ struct PasteFallbackTests {
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
)
target.select([destinationLane], liveness: .live)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
@@ -585,7 +576,7 @@ struct PasteFallbackTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let copyID = try #require(harness.clipboard.payload?.copyID)
@@ -593,7 +584,7 @@ struct PasteFallbackTests {
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
)
target.select([destinationLane], liveness: .live)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
#expect(target.banners.losses.map(\.message) == ["Pasted 'First' without its 2 attachments"])
@@ -608,7 +599,7 @@ struct PasteFallbackTests {
let target = try BoardStore(rootURL: destination.root)
// `card2` has no attachments, so a fallback loses nothing at all.
harness.store.select([clipboardCard2], liveness: .live)
harness.store.select([clipboardCard2], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let copyID = try #require(harness.clipboard.payload?.copyID)
@@ -616,7 +607,7 @@ struct PasteFallbackTests {
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
)
target.select([destinationLane], liveness: .live)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Second"])
@@ -631,7 +622,7 @@ struct PasteFallbackTests {
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardLane1], liveness: .live)
harness.store.select([clipboardLane1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let copyID = try #require(harness.clipboard.payload?.copyID)
@@ -643,14 +634,14 @@ struct PasteFallbackTests {
let arrived = try #require(try pasted(destination).lanes.last)
#expect(arrived.title.value == "Todo")
// The two live cards, and not the tombstoned third.
// Both of the lane's cards.
#expect(arrived.cards.count == 2)
#expect(Set(arrived.cards.compactMap(\.title.value)) == ["First", "Second"])
#expect(target.banners.losses.map(\.message) == ["Pasted 'Todo' without its 2 attachments"])
}
@Test("A trash-sourced fallback still strips `deleted:` at materialization")
func trashedFallbackStripsDeleted() async throws {
@Test("A trash-sourced fallback materializes an ordinary card — there is no key to strip")
func trashedFallbackIsOrdinary() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
@@ -658,7 +649,7 @@ struct PasteFallbackTests {
let target = try BoardStore(rootURL: destination.root)
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardCard3], liveness: .trashed)
harness.store.select([clipboardCard3], in: .trash)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let copyID = try #require(harness.clipboard.payload?.copyID)
@@ -666,7 +657,7 @@ struct PasteFallbackTests {
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
)
target.select([destinationLane], liveness: .live)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"])
+7 -7
View File
@@ -423,18 +423,18 @@ struct RawSourceStoreTests {
func aTombstonedCardIsRefused() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try BoardWriter.deleteItem(at: fixture.url(cardPath))
try fixture.move(cardPath, toTrash: Ident.card1)
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexData(cardPath)
let before = try fixture.indexData(".trash/\(Ident.card1)")
let card = ItemID(rawValue: Ident.card1)
// 05 Deletion & lifecycle: "An open raw-source buffer discards instead: its Apply writes
// the *whole* pre-tombstone `index.md` and would silently undelete the card a foreign
// delete is never reverted by a stale buffer." The Edit buffer's flush is the opposite rule,
// deliberately, which is why the two walks differ (`liveItem` vs `cardBodyTarget`).
// 05 Deletion & lifecycle: "An open raw-source buffer discards instead: its Apply would
// write a whole stale `index.md` over the trashed card a delete is never fought by a stale
// buffer." The Edit buffer's flush is the opposite rule, deliberately, which is why the two
// walks differ (`boardItem` vs `cardBodyTarget`).
#expect(store.readCardSource(inCard: card) == .vanished)
#expect(store.applyCardSource(inCard: card, text: handEditedSource) == .vanished)
#expect(try fixture.indexData(cardPath) == before)
#expect(try fixture.indexData(".trash/\(Ident.card1)") == before)
}
@Test("A card that is not in the board at all is vanished too")
+48 -75
View File
@@ -105,18 +105,8 @@ private func makeTrashBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, item(order: "1024", title: "Todo", body: "Inbox lane."))
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
tombstoned(order: "1024", title: "Fix login", body: "Auth.", deleted: "2026-03-05T10:00:00Z")
)
try fixture.item(
"\(Ident.lane1)/\(Ident.card2)",
tombstoned(order: "2048", title: "Polish", body: "Wording.", deleted: "2026-03-05T06:00:00Z")
)
try fixture.item(
More.laneX,
tombstoned(order: "2048", title: "Old login lane", body: "Retired.", deleted: "2026-03-05T08:00:00Z")
)
try fixture.item(".trash/\(Ident.card1)", item(order: "1024", title: "Fix login", body: "Auth."))
try fixture.item(".trash/\(Ident.card2)", item(order: "2048", title: "Polish", body: "Wording."))
return fixture
}
@@ -238,17 +228,17 @@ struct SearchFilterPredicateTests {
#expect(!SearchFilter(query: "csv").matches(unrelated))
}
@Test("A lane matches by its own title and body — the trash's rows, not the board's lanes")
func lanesMatchByTheirOwnText() throws {
@Test("There is no lane predicate: the filter is a card predicate, end to end")
func lanesAreNeverFiltered() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
let todo = try #require(model.lanes.first { $0.id == lane1 })
#expect(SearchFilter(query: "todo").matches(todo))
#expect(SearchFilter(query: "inbox").matches(todo))
// A lane is not matched through its cards: `card1` says "login", the lane does not.
#expect(!SearchFilter(query: "login").matches(todo))
// 04 § Search filters *cards*. Under the tombstone model the trash held lane *entries* that
// had to be matched like rows, which is why `matches(_: Lane)` existed; lanes are never
// trashed now, so the overload went with them and every lane is always visible.
let visible = SearchFilter(query: "nothing-matches-this").visibleIDs(in: model, container: .board)
#expect(visible == Set(model.lanes.map(\.id)))
}
@Test("The visible universe keeps every live lane and only the matching cards")
@@ -257,7 +247,7 @@ struct SearchFilterPredicateTests {
defer { fixture.tearDown() }
let model = try load(fixture)
let visible = SearchFilter(query: "login").visibleIDs(in: model, on: .live)
let visible = SearchFilter(query: "login").visibleIDs(in: model, container: .board)
// Lanes are never hidden by a card query a lane the filter empties is still a lane on the
// board, so a lane selection survives a query that empties its body.
#expect(visible.isSuperset(of: [lane1, lane2, lane3]))
@@ -277,12 +267,9 @@ struct SearchFilterOrderTests {
defer { fixture.tearDown() }
let model = try load(fixture)
#expect(SelectionGrammar.liveCards(in: model) == [card1, card2, card3, card4, card5])
#expect(SelectionGrammar.liveCards(in: model, filter: SearchFilter(query: "login")) == [card1, card3])
#expect(SelectionGrammar.order(
of: .card,
on: .live,
in: model,
#expect(SelectionGrammar.boardCards(in: model) == [card1, card2, card3, card4, card5])
#expect(SelectionGrammar.boardCards(in: model, filter: SearchFilter(query: "login")) == [card1, card3])
#expect(SelectionGrammar.order(of: .card, in: .board, snapshot: model,
filter: SearchFilter(query: "login")
) == [card1, card3])
}
@@ -295,10 +282,7 @@ struct SearchFilterOrderTests {
// `lane3`'s only card misses the query, and the lane is still in the order: the width
// division is layout, and a `0` badge is the honest report.
#expect(SelectionGrammar.order(
of: .lane,
on: .live,
in: model,
#expect(SelectionGrammar.order(of: .lane, in: .board, snapshot: model,
filter: SearchFilter(query: "login")
) == [lane1, lane2, lane3])
}
@@ -310,16 +294,11 @@ struct SearchFilterOrderTests {
let model = try load(fixture)
// Unfiltered, card1 card3 sweeps card2 up with it.
#expect(SelectionGrammar.range(from: card1, to: card3, kind: .card, on: .live, in: model)
#expect(SelectionGrammar.range(from: card1, to: card3, kind: .card, in: .board, snapshot: model)
== [card1, card2, card3])
// Filtered, the span between the same two endpoints is the two that are on screen.
#expect(SelectionGrammar.range(
from: card1,
to: card3,
kind: .card,
on: .live,
in: model,
#expect(SelectionGrammar.range(from: card1, to: card3, kind: .card, in: .board, snapshot: model,
filter: SearchFilter(query: "login")
) == [card1, card3])
}
@@ -330,34 +309,31 @@ struct SearchFilterOrderTests {
defer { fixture.tearDown() }
let model = try load(fixture)
#expect(SelectionGrammar.range(
from: card1,
to: card2,
kind: .card,
on: .live,
in: model,
#expect(SelectionGrammar.range(from: card1, to: card2, kind: .card, in: .board, snapshot: model,
filter: SearchFilter(query: "login")
) == nil)
}
@Test("Trash entries filter like any lane — card rows and lane rows alike, by their own text")
func trashEntriesNarrow() throws {
@Test("Trash cards participate in the filter exactly like any other card")
func trashCardsNarrow() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
#expect(SelectionGrammar.trashEntries(of: .card, in: model) == [card1, card2])
#expect(SelectionGrammar.trashEntries(of: .lane, in: model) == [laneX])
#expect(SelectionGrammar.trashCards(in: model) == [card1, card2])
#expect(SelectionGrammar.trashCards(in: model, filter: SearchFilter(query: "login")) == [card1])
// And there is no lane list in the trash at all "Cards only. Lanes are never trashed".
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: model).isEmpty)
}
let filter = SearchFilter(query: "login")
#expect(SelectionGrammar.trashEntries(of: .card, in: model, filter: filter) == [card1])
// The lane row matches on its *own* title, not on the card buried inside it.
#expect(SelectionGrammar.trashEntries(of: .lane, in: model, filter: filter) == [laneX])
#expect(SelectionGrammar.trashEntries(
of: .lane,
in: model,
filter: SearchFilter(query: "polish")
).isEmpty)
@Test("The trash's visible universe is its matching cards")
func trashUniverseNarrows() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
#expect(SearchFilter(query: "login").visibleIDs(in: model, container: .trash) == [card1])
#expect(SearchFilter.inactive.visibleIDs(in: model, container: .trash) == [card1, card2])
}
@Test("The delete successor is drawn from what the lane is showing")
@@ -367,12 +343,12 @@ struct SearchFilterOrderTests {
let model = try load(fixture)
// Unfiltered, deleting the untitled card lands on its lane's next card.
#expect(SelectionGrammar.successor(afterDeleting: [card3], in: model) == card4)
#expect(SelectionGrammar.successor(afterDeleting: [card3], snapshot: model) == card4)
// Under `login`, card4 is hidden and there is nothing else visible in that lane, so the
// honest answer is nothing rather than a card the query animated out.
#expect(SelectionGrammar.successor(
afterDeleting: [card3],
in: model,
snapshot: model,
filter: SearchFilter(query: "login")
) == nil)
}
@@ -405,11 +381,11 @@ struct SearchFilterStoreTests {
let store = try BoardStore(rootURL: fixture.root)
store.transient.isTrashVisible = true
store.select([card2], liveness: .trashed)
store.select([card2], in: .trash)
store.selectAll()
#expect(store.selection.ids == [card1, card2])
store.select([card1], liveness: .trashed)
store.select([card1], in: .trash)
store.searchQuery = "login"
store.selectAll()
#expect(store.selection.ids == [card1])
@@ -421,7 +397,7 @@ struct SearchFilterStoreTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1, card2], liveness: .live, anchor: card1, head: card2)
store.select([card1, card2], in: .board, anchor: card1, head: card2)
store.searchQuery = "login"
#expect(store.selection.ids == [card1])
@@ -437,7 +413,7 @@ struct SearchFilterStoreTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([lane3], liveness: .live)
store.select([lane3], in: .board)
store.searchQuery = "login"
#expect(store.selection.ids == [lane3])
}
@@ -449,12 +425,12 @@ struct SearchFilterStoreTests {
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.select([card1], liveness: .live)
store.select([card1], in: .board)
store.clearSearch()
#expect(store.searchQuery.isEmpty)
#expect(store.selection.ids == [card1])
#expect(SelectionGrammar.liveCards(in: store.snapshot, filter: store.searchFilter).count == 5)
#expect(SelectionGrammar.boardCards(in: store.snapshot, filter: store.searchFilter).count == 5)
}
@Test("A reload landing under an active query re-applies the filter to the selection")
@@ -464,7 +440,7 @@ struct SearchFilterStoreTests {
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.select([card1, card3], liveness: .live, anchor: card1, head: card3)
store.select([card1, card3], in: .board, anchor: card1, head: card3)
// An agent edits the title out of the match. The card is still there this is not a vanish,
// so only the *filter's* universe can eject it.
@@ -502,7 +478,7 @@ struct SearchFilterStoreTests {
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.select([card1], liveness: .live)
store.select([card1], in: .board)
store.transient.beginRename(of: card1, currentTitle: "Fix login")
#expect(store.searchQuery == "login")
@@ -526,7 +502,7 @@ struct SearchFilterStoreTests {
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.select([card1], liveness: .live)
store.select([card1], in: .board)
store.transient.beginRename(of: card1, currentTitle: "Fix login")
store.transient.updateRenameDraft("Fix login again")
@@ -544,7 +520,7 @@ struct SearchFilterStoreTests {
// `Todo` shows both its cards under this query, so the successor is the visible neighbour.
store.searchQuery = "the"
store.select([card1], liveness: .live)
store.select([card1], in: .board)
store.delete([card1])
#expect(store.selection.ids == [card2])
}
@@ -572,7 +548,7 @@ struct SearchFilterExemptionTests {
let filter = SearchFilter(query: "login")
// Every live lane is in the visible universe, `Done` which holds only a miss included.
let visible = filter.visibleIDs(in: model, on: .live)
let visible = filter.visibleIDs(in: model, container: .board)
#expect(visible.isSuperset(of: [lane1, lane2, lane3]))
#expect(!visible.contains(card5))
@@ -623,7 +599,7 @@ struct SearchFilterExemptionTests {
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.select([card1], liveness: .live)
store.select([card1], in: .board)
store.transient.beginRename(of: card1, currentTitle: "Fix login")
store.transient.updateRenameDraft("Fix login thoroughly")
@@ -644,7 +620,7 @@ struct SearchFilterExemptionTests {
#expect(try card(card1, in: store.snapshot).title.value == "Fix login thoroughly")
}
@Test("A vanish still discards the editor — the carve-out is the filter's, not liveness's")
@Test("A vanish still discards the editor — the carve-out is the filter's, not the container's")
func aVanishStillDiscardsTheEditor() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
@@ -652,10 +628,7 @@ struct SearchFilterExemptionTests {
store.searchQuery = "login"
store.transient.beginRename(of: card1, currentTitle: "Fix login")
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
tombstoned(order: "1024", title: "Fix login", body: "The auth flow breaks on retry.")
)
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
await reload(store)
#expect(store.transient.renameEditor == nil)
+119 -148
View File
@@ -8,40 +8,27 @@ import Testing
///
/// The grammar is written as a pure function precisely so it can be tested like one: a click, a
/// selection, an anchor and a snapshot in, a selection and an anchor out no window, no gesture, no
/// modifier flags. The boards underneath are **real loads off real temp trees**, because every rule
/// here reads `isDeleted`, card ordering, or `TrashModel`'s sort, and a hand-built `BoardModel`
/// would let all three drift from what the loader actually produces.
/// modifier flags. The boards underneath are **real loads off real temp trees**, because the rules
/// read card ordering and the trash container, and a hand-built `BoardModel` would let both drift
/// from what the loader actually produces.
///
/// **Two homogeneity axes, not three** (resettled 2026-07-28): cards XOR lanes, and board XOR trash.
/// The third card entries XOR lane entries *inside* the trash retired with the lane entries it
/// separated, because lanes are never trashed.
///
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
// MARK: - Fixtures
/// More literal identities than `Ident` offers: a three-lane range needs five cards, and the trash's
/// interleaving needs entries whose folder names are distinguishable in the sort's tie-break.
/// More literal identities than `Ident` offers: a three-lane range needs five cards.
private enum More {
static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
static let laneX = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
static let laneY = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
static let card6 = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
}
private func tombstoned(order: String, title: String, deleted: String = "2026-03-03T09:00:00Z") -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
deleted: \(deleted)
---
\(title) body.
"""
}
/// Three live lanes, five live cards and one tombstoned card enough that a flatten-order range
/// crosses two lane boundaries and has something to *skip* on the way.
/// Three lanes and five cards enough that a flatten-order range crosses two lane boundaries.
///
/// Flatten order of the live cards is `[card1, card2, card3, card5]`; `card4` carries its own
/// `deleted:` and is in none of it.
/// Flatten order is `[card1, card2, card3, card4, card5]`.
@MainActor
private func makeLiveBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
@@ -51,27 +38,23 @@ private func makeLiveBoard() throws -> WriterFixture {
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
try fixture.item("\(Ident.lane2)/\(Ident.card4)", tombstoned(order: "2048", title: "Fourth"))
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Fourth"))
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
try fixture.item("\(Ident.lane3)/\(More.card5)", Item.rich(order: "1024", title: "Fifth"))
return fixture
}
/// A trash whose two row kinds **interleave**, which is the only shape that can prove a range skips.
///
/// `TrashModel`'s sort is newest `deleted` first, so the entry order is
/// `[card1, laneX, card2, laneY, card3]` a card range from `card1` to `card3` has two lane rows
/// sitting inside its span, and a lane range from `laneX` to `laneY` has a card row inside its own.
/// A board with one lane and three cards in its `.trash/` the container the trash-side grammar
/// walks, in `order` display order (`[card1, card2, card3]`, newest first by ordinary ranks).
@MainActor
private func makeTrashBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First", deleted: "2026-03-05T10:00:00Z"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second", deleted: "2026-03-05T08:00:00Z"))
try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstoned(order: "3072", title: "Third", deleted: "2026-03-05T06:00:00Z"))
try fixture.item(More.laneX, tombstoned(order: "2048", title: "Archive", deleted: "2026-03-05T09:00:00Z"))
try fixture.item(More.laneY, tombstoned(order: "3072", title: "Old", deleted: "2026-03-05T07:00:00Z"))
try fixture.item("\(Ident.lane1)/\(More.card6)", Item.rich(order: "1024", title: "Live"))
try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "256", title: "First"))
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "512", title: "Second"))
try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
return fixture
}
@@ -83,19 +66,18 @@ private let card2 = ItemID(rawValue: Ident.card2)
private let card3 = ItemID(rawValue: Ident.card3)
private let card4 = ItemID(rawValue: Ident.card4)
private let card5 = ItemID(rawValue: More.card5)
private let laneX = ItemID(rawValue: More.laneX)
private let laneY = ItemID(rawValue: More.laneY)
private let card6 = ItemID(rawValue: More.card6)
private func load(_ fixture: WriterFixture) throws -> BoardModel {
try BoardLoader.load(boardRoot: fixture.root).model
}
private func target(_ id: ItemID, _ kind: SelectionKind, _ side: Liveness = .live) -> SelectionTarget {
SelectionTarget(id: id, kind: kind, side: side)
private func target(_ id: ItemID, _ kind: SelectionKind, _ container: ItemContainer = .board) -> SelectionTarget {
SelectionTarget(id: id, kind: kind, container: container)
}
private func set(_ ids: Set<ItemID>, _ side: Liveness = .live) -> ItemReferenceSet {
ItemReferenceSet(ids: ids, liveness: side)
private func set(_ ids: Set<ItemID>, _ container: ItemContainer = .board) -> ItemReferenceSet {
ItemReferenceSet(ids: ids, container: container)
}
/// One click, with the grammar's own defaults filled in.
@@ -123,7 +105,7 @@ private func click(
@Suite("SelectionGrammar ▸ order")
struct SelectionOrderTests {
@Test("Live cards flatten lane order first, then card order — tombstones excluded")
@Test("Board cards flatten lane order first, then card order")
func flattenOrder() throws {
let fixture = try makeLiveBoard()
defer { fixture.tearDown() }
@@ -131,22 +113,21 @@ struct SelectionOrderTests {
// "Lane `order` first, then card `order` (a cross-lane selection flattens left-to-right,
// top-to-bottom)" the multi-drag order (04-interactions.md Drag and drop).
#expect(SelectionGrammar.liveCards(in: snapshot) == [card1, card2, card3, card5])
#expect(SelectionGrammar.liveLanes(in: snapshot) == [lane1, lane2, lane3])
#expect(SelectionGrammar.boardCards(in: snapshot) == [card1, card2, card3, card4, card5])
#expect(SelectionGrammar.lanes(in: snapshot) == [lane1, lane2, lane3])
}
@Test("Trash order lists are one sort, filtered to one kind")
func trashOrderPerKind() throws {
@Test("The trash's list is its cards, in `order`; its lane list is empty by construction")
func trashOrder() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
// The single ordering the quasi-lane shows, newest first.
#expect(TrashModel.entries(of: snapshot).map(\.id) == [card1, laneX, card2, laneY, card3])
// Each kind's list is that ordering with the other kind's rows dropped which is exactly
// what makes a -range step over them (04-interactions.md The trash).
#expect(SelectionGrammar.trashEntries(of: .card, in: snapshot) == [card1, card2, card3])
#expect(SelectionGrammar.trashEntries(of: .lane, in: snapshot) == [laneX, laneY])
#expect(SelectionGrammar.trashCards(in: snapshot) == [card1, card2, card3])
#expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == [card1, card2, card3])
// "Cards only. Lanes are never trashed" so there is no list to walk rather than a rule
// saying not to (03-board-ui.md § Trash).
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot).isEmpty)
}
@Test("A selection's kind is derived from the snapshot, and a ghost selection has none")
@@ -158,10 +139,10 @@ struct SelectionOrderTests {
#expect(SelectionGrammar.kind(of: set([card1, card3]), in: snapshot) == .card)
#expect(SelectionGrammar.kind(of: set([lane2]), in: snapshot) == .lane)
#expect(SelectionGrammar.kind(of: .empty, in: snapshot) == nil)
// A tombstoned card is on neither side's live list, and the live side is what this set says.
#expect(SelectionGrammar.kind(of: set([card4]), in: snapshot) == nil)
// A set claiming a container that does not hold its members answers nothing.
#expect(SelectionGrammar.kind(of: set([card1], .trash), in: snapshot) == nil)
// Members that name nothing are ignored; one that names something still answers.
#expect(SelectionGrammar.kind(of: set([card4, card1]), in: snapshot) == .card)
#expect(SelectionGrammar.kind(of: set([ItemID(rawValue: Ident.indexless), card1]), in: snapshot) == .card)
}
}
@@ -205,9 +186,9 @@ struct PlainClickTests {
#expect(card.selection == set([card1]))
#expect(card.anchor == card1)
// Nor does the toggle reach across the boundary: a live click on a trashed sole selection
// of the same id is a replace.
let crossed = click(target(lane1, .lane), .plain, selection: set([lane1], .trashed), anchor: lane1, in: snapshot, togglesOnRepeat: true)
// Nor does the toggle reach across the boundary: a board click on a trash-side sole
// selection of the same id is a replace.
let crossed = click(target(lane1, .lane), .plain, selection: set([lane1], .trash), anchor: lane1, in: snapshot, togglesOnRepeat: true)
#expect(crossed.selection == set([lane1]))
}
}
@@ -260,24 +241,19 @@ struct CommandClickTests {
#expect(ontoCard.anchor == card1)
}
@Test("⌘-click across the liveness boundary replaces too")
func acrossSideReplaces() throws {
@Test("⌘-click across the container boundary replaces too")
func acrossContainerReplaces() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
// "Selection is homogeneous by liveness a selection never mixes live and tombstoned"
// (04 The trash). The live lane1 is the only live thing on this board.
let intoTrash = click(target(card1, .card, .trashed), .command, selection: set([lane1]), anchor: lane1, in: snapshot)
#expect(intoTrash.selection == set([card1], .trashed))
// "A selection never mixes trash cards with board cards a single container rule replacing
// the old liveness law" (04 The trash, resettled 2026-07-28).
let intoTrash = click(target(card1, .card, .trash), .command, selection: set([card6]), anchor: card6, in: snapshot)
#expect(intoTrash.selection == set([card1], .trash))
let backOut = click(target(lane1, .lane), .command, selection: set([card1, card2], .trashed), anchor: card2, in: snapshot)
#expect(backOut.selection == set([lane1]))
// And within the trash, the second axis: card entries XOR lane entries.
let ontoLaneEntry = click(target(laneX, .lane, .trashed), .command, selection: set([card1, card2], .trashed), anchor: card2, in: snapshot)
#expect(ontoLaneEntry.selection == set([laneX], .trashed))
#expect(ontoLaneEntry.anchor == laneX)
let backOut = click(target(card6, .card), .command, selection: set([card1, card2], .trash), anchor: card2, in: snapshot)
#expect(backOut.selection == set([card6]))
}
@Test("⌘-click with nothing — or nothing real — selected replaces")
@@ -292,7 +268,8 @@ struct CommandClickTests {
// A selection whose members all name nothing the board renders counts as empty: a -click
// after a foreign delete starts a fresh set rather than extending a ghost.
let fromGhost = click(target(card1, .card), .command, selection: set([card4]), anchor: card4, in: snapshot)
let ghost = ItemID(rawValue: Ident.indexless)
let fromGhost = click(target(card1, .card), .command, selection: set([ghost]), anchor: ghost, in: snapshot)
#expect(fromGhost.selection == set([card1]))
}
}
@@ -303,18 +280,16 @@ struct CommandClickTests {
@Suite("SelectionGrammar ▸ ⇧-click")
struct ShiftClickTests {
@Test("A ⇧-range spans the flatten order across lanes, skipping tombstones, and leaves the anchor put")
@Test("A ⇧-range spans the flatten order across lanes and leaves the anchor put")
func rangeAcrossLanes() throws {
let fixture = try makeLiveBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let outcome = click(target(card5, .card), .shift, selection: set([card1]), anchor: card1, in: snapshot)
#expect(outcome.selection == set([card1, card2, card3, card5]))
#expect(outcome.selection == set([card1, card2, card3, card4, card5]))
// Finder-list style: the anchor is unchanged, so successive -clicks sweep from one origin.
#expect(outcome.anchor == card1)
// card4 is tombstoned and in no order list, so no range can pick it up.
#expect(!outcome.selection.ids.contains(card4))
}
@Test("Direction does not matter — the range is the span between anchor and target")
@@ -324,7 +299,7 @@ struct ShiftClickTests {
let snapshot = try load(fixture)
let backwards = click(target(card1, .card), .shift, selection: set([card5]), anchor: card5, in: snapshot)
#expect(backwards.selection == set([card1, card2, card3, card5]))
#expect(backwards.selection == set([card1, card2, card3, card4, card5]))
#expect(backwards.anchor == card5)
}
@@ -350,8 +325,9 @@ struct ShiftClickTests {
#expect(noAnchor.selection == set([card3]))
#expect(noAnchor.anchor == card3)
// An anchor that names nothing the board renders (card4 is tombstoned).
let vanished = click(target(card3, .card), .shift, selection: set([card1]), anchor: card4, in: snapshot)
// An anchor that names nothing the board renders.
let ghost = ItemID(rawValue: Ident.indexless)
let vanished = click(target(card3, .card), .shift, selection: set([card1]), anchor: ghost, in: snapshot)
#expect(vanished.selection == set([card3]))
#expect(vanished.anchor == card3)
@@ -361,32 +337,35 @@ struct ShiftClickTests {
#expect(acrossKind.selection == set([card3]))
#expect(acrossKind.anchor == card3)
// Same for a side crossing: the live card list holds no trash row.
let acrossSide = click(target(card3, .card, .trashed), .shift, selection: set([card1]), anchor: card1, in: snapshot)
#expect(acrossSide.selection == set([card3], .trashed))
// Same for a container crossing: the board's card list holds no trash card.
let acrossContainer = click(target(card3, .card, .trash), .shift, selection: set([card1]), anchor: card1, in: snapshot)
#expect(acrossContainer.selection == set([card3], .trash))
}
@Test("A trash card range steps over the lane entries inside its span")
func trashCardRangeSkipsLaneEntries() throws {
@Test("A trash range walks the column's own order")
func trashRangeWalksTheColumn() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
// Sorted order is [card1, laneX, card2, laneY, card3]; a card range collects card rows only.
let outcome = click(target(card3, .card, .trashed), .shift, selection: set([card1], .trashed), anchor: card1, in: snapshot)
#expect(outcome.selection == set([card1, card2, card3], .trashed))
// The column's order is `[card1, card2, card3]`; a range between the ends takes all three,
// with no kind to step over lanes are never trashed.
let outcome = click(target(card3, .card, .trash), .shift, selection: set([card1], .trash), anchor: card1, in: snapshot)
#expect(outcome.selection == set([card1, card2, card3], .trash))
#expect(outcome.anchor == card1)
}
@Test("A trash lane range steps over the card entries inside its span")
func trashLaneRangeSkipsCardEntries() throws {
@Test("A range never crosses the container boundary")
func trashRangeStopsAtTheBoundary() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let outcome = click(target(laneY, .lane, .trashed), .shift, selection: set([laneX], .trashed), anchor: laneX, in: snapshot)
#expect(outcome.selection == set([laneX, laneY], .trashed))
#expect(outcome.anchor == laneX)
// `card6` is on the board and `card1` is in the trash: no single order list holds both, so
// the anchor is not findable and the -click degrades to a plain one.
let outcome = click(target(card1, .card, .trash), .shift, selection: set([card6]), anchor: card6, in: snapshot)
#expect(outcome.selection == set([card1], .trash))
#expect(outcome.anchor == card1)
}
}
@@ -395,53 +374,46 @@ struct ShiftClickTests {
@Suite("MarqueeMath")
struct MarqueeMathTests {
private static func card(_ id: ItemID, _ y: CGFloat, side: Liveness = .live) -> MarqueeTarget {
MarqueeTarget(id: id, kind: .card, side: side, frame: CGRect(x: 0, y: y, width: 100, height: 40))
private static func card(_ id: ItemID, _ y: CGFloat, container: ItemContainer = .board) -> MarqueeTarget {
MarqueeTarget(id: id, kind: .card, container: container, frame: CGRect(x: 0, y: y, width: 100, height: 40))
}
private static func laneEntry(_ id: ItemID, _ y: CGFloat) -> MarqueeTarget {
MarqueeTarget(id: id, kind: .lane, side: .trashed, frame: CGRect(x: 0, y: y, width: 100, height: 40))
}
@Test("On the live side the band takes intersecting cards, and only cards")
func liveSideTakesCards() {
@Test("On the board side the band takes intersecting cards, and only cards")
func boardSideTakesCards() {
let targets = [
Self.card(card1, 0),
Self.card(card2, 100),
// A lane registered by mistake is still never swept: "click-drag rubber-bands across
// lanes" (04-interactions.md § Selection) across, not over.
MarqueeTarget(id: lane1, kind: .lane, side: .live, frame: CGRect(x: 0, y: 0, width: 200, height: 400)),
MarqueeTarget(id: lane1, kind: .lane, container: .board, frame: CGRect(x: 0, y: 0, width: 200, height: 400)),
// A trash row cannot be reached by a band that began on the board.
Self.card(card3, 10, side: .trashed)
Self.card(card3, 10, container: .trash)
]
let ids = MarqueeMath.selection(rect: CGRect(x: 0, y: 0, width: 50, height: 120), targets: targets, side: .live)
let ids = MarqueeMath.selection(rect: CGRect(x: 0, y: 0, width: 50, height: 120), targets: targets, in: .board)
#expect(ids == [card1, card2])
}
@Test("On the trash side the topmost intersecting row's kind wins")
func trashSideIsHomogeneousByKind() {
// Interleaved rows, the trash's own shape: card, lane, card.
/// **There is no kind rule any more.** The tombstone model interleaved card rows and lane rows
/// in one column, so the band needed a topmost-wins tie-break to stay homogeneous by kind; lanes
/// are never trashed now, so both containers hold cards and one line serves both.
@Test("On the trash side the band takes the trash's cards, and stays on its own side")
func trashSideTakesItsOwnCards() {
let targets = [
Self.card(card1, 0, side: .trashed),
Self.laneEntry(laneX, 50),
Self.card(card2, 100, side: .trashed)
Self.card(card1, 0, container: .trash),
Self.card(card2, 100, container: .trash),
Self.card(card3, 50)
]
let all = CGRect(x: 0, y: 0, width: 50, height: 200)
// Begun on a card row: the lane row between the two cards is stepped over, exactly as a
// -range does (04-interactions.md The trash).
#expect(MarqueeMath.selection(rect: all, targets: targets, side: .trashed) == [card1, card2])
// Begun below it, so the lane row is topmost: only lane entries come back.
let lower = CGRect(x: 0, y: 60, width: 50, height: 200)
#expect(MarqueeMath.selection(rect: lower, targets: targets, side: .trashed) == [laneX])
#expect(MarqueeMath.selection(rect: all, targets: targets, in: .trash) == [card1, card2])
#expect(MarqueeMath.selection(rect: all, targets: targets, in: .board) == [card3])
}
@Test("A band touching nothing selects nothing")
func emptyBand() {
let targets = [Self.card(card1, 0), Self.card(card2, 100)]
#expect(MarqueeMath.selection(rect: CGRect(x: 500, y: 500, width: 10, height: 10), targets: targets, side: .live).isEmpty)
#expect(MarqueeMath.selection(rect: .zero, targets: [], side: .trashed).isEmpty)
#expect(MarqueeMath.selection(rect: CGRect(x: 500, y: 500, width: 10, height: 10), targets: targets, in: .board).isEmpty)
#expect(MarqueeMath.selection(rect: .zero, targets: [], in: .trash).isEmpty)
}
}
@@ -463,15 +435,15 @@ struct SelectionAnchorTests {
func anchorDefaults() {
let state = TransientBoardState()
state.select([card1], liveness: .live)
state.select([card1], in: .board)
#expect(state.selectionAnchor == card1)
// "A marquee and wholesale selections pass no anchor deliberately."
state.select([card1, card2], liveness: .live)
state.select([card1, card2], in: .board)
#expect(state.selectionAnchor == nil)
// An explicit anchor wins over the default in both directions.
state.select([card1, card2, card3], liveness: .live, anchor: card2)
state.select([card1, card2, card3], in: .board, anchor: card2)
#expect(state.selectionAnchor == card2)
state.clearSelection()
@@ -484,7 +456,7 @@ struct SelectionAnchorTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1, card2], liveness: .live, anchor: card1)
store.select([card1, card2], in: .board, anchor: card1)
// A survivor of the same reload proves the rule is about the anchor, not about reloading.
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
await reload(store)
@@ -497,17 +469,18 @@ struct SelectionAnchorTests {
#expect(store.transient.selectionAnchor == nil)
}
@Test("A liveness flip is a vanish for the anchor too")
func flippedAnchorIsDropped() async throws {
@Test("A container crossing is a vanish for the anchor too")
func crossedAnchorIsDropped() async throws {
let fixture = try makeLiveBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1, card2], liveness: .live, anchor: card1)
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First"))
store.select([card1, card2], in: .board, anchor: card1)
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
await reload(store)
// "A flip is a vanish from its side of the boundary" (02-architecture.md's reload rule).
// "A container crossing is a vanish for this purpose" (02-architecture.md's reload rule,
// resettled 2026-07-28).
#expect(store.selection.ids == [card2])
#expect(store.transient.selectionAnchor == nil)
}
@@ -520,7 +493,7 @@ struct SelectionAnchorTests {
let state = TransientBoardState()
// A -click that toggled the anchor's own row out leaves the anchor standing.
state.select([card2, card3], liveness: .live, anchor: card1)
state.select([card2, card3], in: .board, anchor: card1)
#expect(state.selectionAnchor == card1)
let outcome = click(target(card3, .card), .shift, selection: state.selection, anchor: state.selectionAnchor, in: snapshot)
#expect(outcome.selection == set([card1, card2, card3]))
@@ -533,17 +506,17 @@ struct SelectionAnchorTests {
@Suite("BoardStore ▸ Select All")
struct SelectAllTests {
@Test("Select All takes every rendered card — tombstones excluded, lanes never")
@Test("Select All takes every rendered card, and never a lane")
func liveBranch() throws {
let fixture = try makeLiveBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([lane2], liveness: .live)
store.select([lane2], in: .board)
store.selectAll()
// "All visible cards on the board" (04-interactions.md The map).
#expect(store.selection == set([card1, card2, card3, card5]))
#expect(store.selection == set([card1, card2, card3, card4, card5]))
// The lane the anchor named is not in the new set, so the anchor goes with it.
#expect(store.transient.selectionAnchor == nil)
}
@@ -554,25 +527,23 @@ struct SelectAllTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card3], liveness: .live)
store.select([card3], in: .board)
store.selectAll()
#expect(store.transient.selectionAnchor == card3)
}
@Test("On the trash side Select All stays within the selection's kind")
func trashBranchIsHomogeneousByKind() throws {
@Test("On the trash side Select All takes every visible trash card")
func trashBranchTakesTheColumn() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.isTrashVisible = true
store.select([card2], liveness: .trashed)
// "With the trash visible and a non-empty trash selection, Select All selects all visible
// trash cards" (04 The map, resettled 2026-07-28). There is no kind clause left to honour.
store.select([card2], in: .trash)
store.selectAll()
#expect(store.selection == set([card1, card2, card3], .trashed))
store.select([laneX], liveness: .trashed)
store.selectAll()
#expect(store.selection == set([laneX, laneY], .trashed))
#expect(store.selection == set([card1, card2, card3], .trash))
}
@Test("The trash branch is narrow: hidden, live, empty, or ghost selections take the board")
@@ -582,20 +553,20 @@ struct SelectAllTests {
let store = try BoardStore(rootURL: fixture.root)
// Hidden the column is invisible to every gesture (04 The trash).
store.select([card1], liveness: .trashed)
store.select([card1], in: .trash)
store.selectAll()
#expect(store.selection.liveness == .live)
#expect(store.selection.container == .board)
// Shown, but nothing tombstoned is selected.
// Shown, but nothing in the trash is selected.
store.transient.isTrashVisible = true
store.clearSelection()
store.selectAll()
#expect(store.selection.liveness == .live)
#expect(store.selection.container == .board)
// Shown, trashed side, but the ids name no row: a guess would be worse than the board.
store.select([card5], liveness: .trashed)
// Shown, trash side, but the ids name no card there: a guess would be worse than the board.
store.select([card5], in: .trash)
store.selectAll()
#expect(store.selection.liveness == .live)
#expect(store.selection.container == .board)
}
@Test("Select All on a board with no rendered cards clears rather than selecting an empty set")
@@ -606,7 +577,7 @@ struct SelectAllTests {
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
let store = try BoardStore(rootURL: fixture.root)
store.select([lane1], liveness: .live)
store.select([lane1], in: .board)
store.selectAll()
#expect(store.selection.isEmpty)
#expect(store.transient.selectionAnchor == nil)
+5 -5
View File
@@ -109,7 +109,7 @@ struct StyleEditorSessionTests {
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginStyleEditor(for: .items([card1, card2]))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second"))
try fixture.move("\(Ident.lane1)/\(Ident.card2)", toTrash: Ident.card2)
await reload(store)
let session = try #require(store.transient.styleEditor)
@@ -126,7 +126,7 @@ struct StyleEditorSessionTests {
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginStyleEditor(for: .items([card3]))
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
await reload(store)
// The card's own flag never changed its lane's did. Effective liveness is ancestor-walked,
@@ -141,7 +141,7 @@ struct StyleEditorSessionTests {
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginStyleEditor(for: .items([card1]))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First"))
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
await reload(store)
#expect(store.transient.styleEditor?.target != .board)
@@ -155,8 +155,8 @@ struct StyleEditorSessionTests {
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginStyleEditor(for: .board)
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
await reload(store)
#expect(store.transient.styleEditor?.target == .board)
+13 -12
View File
@@ -54,9 +54,9 @@ private func makeBoard() throws -> WriterFixture {
try fixture.item("\(Ident.lane1)/\(Ident.card1)", styled(order: "1024", title: "First", keys: ["background: fern", "iconColor: chalk"]))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", styled(order: "2048", title: "Second"))
try fixture.item(Ident.lane2, styled(order: "2048", title: "Doing", keys: ["background: chalk", "icon: tray"]))
try fixture.item("\(Ident.lane2)/\(Ident.card3)", styled(order: "1024", title: "Third", keys: ["deleted: 2026-03-03T09:00:00Z"]))
try fixture.item("\(Ident.lane2)/\(Ident.card3)", styled(order: "1024", title: "Third"))
try fixture.item(Ident.lane3, Item.uneditable)
try fixture.item(Ident.lane4, styled(order: "4096", title: "Gone", keys: ["deleted: 2026-03-03T09:00:00Z"]))
try fixture.item(Ident.lane4, styled(order: "4096", title: "Gone"))
try fixture.item("\(Ident.lane4)/\(Ident.card4)", styled(order: "1024", title: "Hidden"))
return fixture
}
@@ -256,26 +256,27 @@ struct StyleWriteTests {
#expect(lane(lane1, in: model)?.background.isMissing == true)
}
@Test("Vanished, tombstoned and hidden targets are skipped silently")
@Test("Vanished and trashed targets are skipped silently")
func skipsTargetsThatRenderNowhere() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.move("\(Ident.lane2)/\(Ident.card3)", toTrash: Ident.card3)
try FileManager.default.removeItem(at: fixture.url(Ident.lane4))
let store = try BoardStore(rootURL: fixture.root)
let log = BracketLog()
log.attach(to: store)
let tombstoned = try fixture.indexData("\(Ident.lane2)/\(Ident.card3)")
let hidden = try fixture.indexData("\(Ident.lane4)/\(Ident.card4)")
let trashedBytes = try fixture.indexData(".trash/\(Ident.card3)")
// An id that names nothing, a tombstoned card, and a live card under a tombstoned lane
// "nothing is ever written into a vanished folder", ancestor walk included.
// An id that names nothing, and a card that has been moved to the trash "everything
// edit-shaped is disabled on trash selections" (04 The trash), and "nothing is ever
// written into a vanished folder".
store.applyStyle(
to: .items([ItemID(rawValue: Ident.indexless), card3, card4]),
background: .set("obsidian")
)
#expect(log.begins == 0)
#expect(try fixture.indexData("\(Ident.lane2)/\(Ident.card3)") == tombstoned)
#expect(try fixture.indexData("\(Ident.lane4)/\(Ident.card4)") == hidden)
#expect(try fixture.indexData(".trash/\(Ident.card3)") == trashedBytes)
#expect(store.banners.oneShots.isEmpty)
}
@@ -314,13 +315,13 @@ struct StyleWriteTests {
// MARK: Subjects and levels
@Test("Subjects are the live targets in display order, with their current values")
func subjectsAreLiveTargetsInDisplayOrder() throws {
@Test("Subjects are the board's targets in display order, with their current values")
func subjectsAreBoardTargetsInDisplayOrder() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let subjects = store.styleSubjects(of: .items([card2, card1, card3, lane1]))
let subjects = store.styleSubjects(of: .items([card2, card1, lane1]))
#expect(subjects.map(\.id) == [lane1, card1, card2], "lane first, then its cards top to bottom")
#expect(subjects.map(\.background) == [.missing, .valid("fern"), .missing])
#expect(subjects.last?.folder.lastPathComponent == Ident.card2)
+7 -7
View File
@@ -328,19 +328,19 @@ struct StoreToggleTaskMarkerTests {
#expect(try fixture.indexData(cardPath) == before)
}
@Test("A tombstoned card's checkbox does not write")
func aTombstonedCardWritesNothing() throws {
@Test("A trashed card's checkbox does not write")
func aTrashedCardWritesNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try BoardWriter.deleteItem(at: fixture.url(cardPath))
try fixture.move(cardPath, toTrash: Ident.card1)
let store = try BoardStore(rootURL: fixture.root)
let before = try body(of: fixture, cardPath)
let before = try body(of: fixture, ".trash/\(Ident.card1)")
let offsets = markerOffsets(in: checklistBody)
// Effective liveness, ancestor-walked (`BoardStore.liveItem`): a card in the trash renders
// nowhere, so nothing may write through a preview of it.
// `BoardStore.boardItem` is the board container and only it: a trashed card does not open,
// so nothing may write through a preview of one (03 Trash's no-editing rule).
store.toggleTaskMarker(inCard: ItemID(rawValue: Ident.card1), bodyOffset: offsets[0], checked: false)
#expect(try body(of: fixture, cardPath) == before)
#expect(try body(of: fixture, ".trash/\(Ident.card1)") == before)
}
}
+67 -73
View File
@@ -76,9 +76,9 @@ struct TransientBoardStateTests {
// Overlapping but different: card1 is selected and cut, card2 is selected and dragged,
// card3 is dragged and cut. Whatever happens to one member, two of the three sets are
// always the control.
store.transient.select([card1, card2], liveness: .live)
store.transient.dragMembers = ItemReferenceSet(ids: [card2, card3], liveness: .live)
store.transient.pendingCut = ItemReferenceSet(ids: [card1, card3], liveness: .live)
store.transient.select([card1, card2], in: .board)
store.transient.dragMembers = ItemReferenceSet(ids: [card2, card3], container: .board)
store.transient.pendingCut = ItemReferenceSet(ids: [card1, card3], container: .board)
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
await reload(store)
@@ -98,7 +98,7 @@ struct TransientBoardStateTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let seeded = ItemReferenceSet(ids: [card1, card2], liveness: .live)
let seeded = ItemReferenceSet(ids: [card1, card2], container: .board)
// Direction one the live search filter (04-interactions.md § Search): card1's title and
// body both miss the query, so it is not in the visible set the predicate produced, and
@@ -116,75 +116,71 @@ struct TransientBoardStateTests {
#expect(filtered == resolved, "one primitive, two universes — the two rules are one rule")
}
@Test("Tombstoning a lane ejects its cards from every referencing set — liveness is effective")
func effectiveLivenessEjectsFromEverySet() async throws {
@Test("A container crossing is a vanish for every item-referencing set")
func aContainerCrossingEjectsFromEverySet() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.select([card1, card2], liveness: .live)
store.transient.pendingCut = ItemReferenceSet(ids: [card2, card3], liveness: .live)
store.transient.select([card1, card2], in: .board)
store.transient.pendingCut = ItemReferenceSet(ids: [card2, card3], container: .board)
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
// A foreign writer moves card1 and card2 into the board's trash the move a delete is.
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
try fixture.move("\(Ident.lane1)/\(Ident.card2)", toTrash: Ident.card2)
await reload(store)
// The cards' own flags never changed, but their lane's did and liveness is ancestor-walked
// (02, settled): they render nowhere once 03 collapses the lane to a single trash entry, and
// nothing invisible may stay selected or pending-cut.
let survivor = store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }
#expect(survivor?.isDeleted == false, "the card's own flag is untouched")
// 02-architecture.md § Live-reload resilience, resettled 2026-07-28: "re-resolution matches
// UUID *and* container side a foreign move that trashes a selected board card ejects it
// from the selection (and from the pending cut)".
let trashedIDs = Set(store.snapshot.trash.map(\.id))
#expect(trashedIDs == Set([card1, card2]))
#expect(store.transient.selection.ids.isEmpty)
#expect(store.transient.pendingCut.ids == [card3], "card3's lane is untouched, so card3 stays cut")
#expect(store.transient.pendingCut.ids == [card3], "card3 never moved, so card3 stays cut")
}
@Test("A card with its own deleted: under a tombstoned lane is in neither universe")
func ownFlaggedCardUnderATombstonedLaneIsInNeitherUniverse() async throws {
@Test("A restore ejects a trash-side set the same way, in the other direction")
func restoringEjectsATrashSideSet() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.move("\(Ident.lane2)/\(Ident.card3)", toTrash: Ident.card3)
let store = try BoardStore(rootURL: fixture.root)
// Everything that can point at an item, all aimed at card3 and on the *trashed* side, as
// if the user had clicked its trash row a moment before its lane went too.
store.transient.select([card3], liveness: .trashed, anchor: card3, head: card3)
store.transient.dragMembers = ItemReferenceSet(ids: [card3], liveness: .trashed)
store.transient.pendingCut = ItemReferenceSet(ids: [card3], liveness: .trashed)
// Everything that can point at an item, all aimed at the trashed card.
store.transient.select([card3], in: .trash, anchor: card3, head: card3)
store.transient.dragMembers = ItemReferenceSet(ids: [card3], container: .trash)
store.transient.pendingCut = ItemReferenceSet(ids: [card3], container: .trash)
store.transient.beginRename(of: card3, currentTitle: "Third")
#expect(store.snapshot.trash.map(\.id) == [card3])
// Both flags at once: the card carries its own `deleted:` *and* an agent tombstones its lane.
try fixture.item("\(Ident.lane2)/\(Ident.card3)", tombstoned(order: "1024", title: "Third"))
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
// An agent moves it back out a container crossing the other way.
try fixture.move(".trash/\(Ident.card3)", toLane: Ident.lane2, card: Ident.card3)
await reload(store)
let lane = try #require(store.snapshot.lanes.first { $0.id == lane2 })
#expect(lane.isDeleted)
#expect(lane.cards.first { $0.id == card3 }?.isDeleted == true, "the card's own flag is on disk")
#expect(store.snapshot.trash.isEmpty)
#expect(!ItemContainer.trash.ids(in: store.snapshot).contains(card3))
#expect(ItemContainer.board.ids(in: store.snapshot).contains(card3),
"presence is the whole test — the card is simply on the other side now")
// The lane's single entry subsumes it (03-board-ui.md's absolute ancestor walk), so it has
// no row and "the trashed side has exactly one definition: the set with trash rows"
// (02-architecture.md, settled). No row, no membership, on either side.
#expect(!TrashModel.entries(of: store.snapshot).map(\.id).contains(card3))
#expect(!ItemReferenceSet.idUniverse(of: store.snapshot, on: .trashed).contains(card3))
#expect(!ItemReferenceSet.idUniverse(of: store.snapshot, on: .live).contains(card3))
// So every set holding it is ejected from the trashed side here, and from the live side
// for the same reason, which the value function says directly since a set has one side.
#expect(store.transient.selection.ids.isEmpty)
#expect(store.transient.dragMembers.ids.isEmpty)
#expect(store.transient.pendingCut.ids.isEmpty)
#expect(ItemReferenceSet(ids: [card3], liveness: .live).resolved(against: store.snapshot).isEmpty)
// And no cursor or editor survives on it: an anchor that ranges from somewhere the board
// draws nowhere would be a range the user cannot see the origin of.
// And no cursor survives on it: an anchor that ranges from a container the selection has left
// would be a range the user cannot see the origin of.
#expect(store.transient.selectionAnchor == nil)
#expect(store.transient.selectionHead == nil)
#expect(store.transient.renameEditor == nil)
// Menu validation agrees, which is the point of the sets and the commands reading one rule:
// neither twin offers to act on it.
let stale = ItemReferenceSet(ids: [card3], liveness: .trashed)
#expect(!TrashModel.canActOnTrash(selection: stale, in: store.snapshot))
#expect(!TrashModel.canDelete(selection: ItemReferenceSet(ids: [card3], liveness: .live),
in: store.snapshot))
// The rename editor tracks the *board* container, so a card arriving back on the board keeps
// its editor "a foreign move mid-rename is invisible" (04 Grammar). It is the departure
// into the trash that discards it, which the delete tests cover.
#expect(store.transient.renameEditor?.targetID == card3)
// Menu validation agrees with the sets, which is the point of both reading one rule.
let stale = ItemReferenceSet(ids: [card3], container: .trash)
#expect(!TrashModel.canDelete(selection: stale, in: store.snapshot))
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [card3], container: .board),
in: store.snapshot))
}
@Test("Every set resolves to empty against a board whose lanes all vanished")
@@ -193,9 +189,9 @@ struct TransientBoardStateTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.select([card1, lane1], liveness: .live)
store.transient.dragMembers = ItemReferenceSet(ids: [card2], liveness: .live)
store.transient.pendingCut = ItemReferenceSet(ids: [card3], liveness: .live)
store.transient.select([card1, lane1], in: .board)
store.transient.dragMembers = ItemReferenceSet(ids: [card2], container: .board)
store.transient.pendingCut = ItemReferenceSet(ids: [card3], container: .board)
store.transient.beginPlaceholder(inLane: lane2)
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
@@ -238,13 +234,12 @@ struct TransientBoardStateTests {
store.transient.beginPlaceholder(inLane: lane2)
store.transient.updateDraft("Half a title")
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
await reload(store)
// Still in the snapshot the trash renders its single entry but the lane the editor was
// sitting in is not on the board any more, which is the same vanish as far as an overlay
// anchored to it is concerned.
#expect(store.snapshot.lanes.first { $0.id == lane2 }?.isDeleted == true)
// A lane delete is physical (03-board-ui.md § Trash), so "the placeholder's lane vanished in
// the reload" is literally the whole test.
#expect(store.snapshot.lanes.first { $0.id == lane2 } == nil)
#expect(store.transient.newCardPlaceholder == nil)
}
@@ -376,37 +371,36 @@ struct TransientBoardStateTests {
#expect(store.transient.renameEditor == nil)
}
@Test("A rename whose target is tombstoned is discarded — a liveness flip is a vanish")
func renameDiscardedWhenItsTargetIsTombstoned() async throws {
@Test("A rename whose target enters the trash is discarded — a container crossing is a vanish")
func renameDiscardedWhenItsTargetIsTrashed() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginRename(of: card1, currentTitle: "First")
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First"))
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
await reload(store)
#expect(store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }?.isDeleted == true)
// 04 Grammar: "a target that is trashed, deleted, or gone at commit time discards the
// editor and its keystrokes silently entering the trash is a vanish from the board".
#expect(store.snapshot.trash.map(\.id) == [card1])
#expect(store.transient.renameEditor == nil)
}
@Test("A rename under a tombstoned lane is discarded too — liveness is effective")
func renameDiscardedWhenItsLaneIsTombstoned() async throws {
@Test("A rename whose lane is deleted is discarded too — the card went with it")
func renameDiscardedWhenItsLaneIsDeleted() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginRename(of: card1, currentTitle: "First")
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
await reload(store)
// The card's own flag never changed; its lane's did. The ancestor walk is absolute the
// card renders nowhere, so the editor sitting on it has no target
// (`CardWindowHost.cardWindowFate`'s rule, applied to the third inline editor).
let card = store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }
#expect(card?.isDeleted == false)
// A lane delete is physical, so the card is simply not in the snapshot no ancestor walk.
#expect(store.snapshot.lanes.first { $0.id == lane1 } == nil)
#expect(store.transient.renameEditor == nil)
}
@@ -457,17 +451,17 @@ struct TransientBoardStateTests {
#expect(store.transient.lastActiveLaneID == nil, "a fresh board has no history to remember")
store.select([lane2], liveness: .live)
store.select([lane2], in: .board)
#expect(store.transient.lastActiveLaneID == lane2)
// A *card* selection is its lane holding selection too 04's "the lane that most recently
// held selection or a creation".
store.select([card1], liveness: .live)
store.select([card1], in: .board)
#expect(store.transient.lastActiveLaneID == lane1)
// A cross-lane selection names no single lane, so it leaves the memory alone rather than
// guessing at one of the two.
store.select([card1, card3], liveness: .live)
store.select([card1, card3], in: .board)
#expect(store.transient.lastActiveLaneID == lane1)
// Deselecting does not un-happen where the user was working: N with nothing selected is
@@ -485,11 +479,11 @@ struct TransientBoardStateTests {
store.transient.beginPlaceholder(inLane: lane2)
#expect(store.transient.lastActiveLaneID == lane2)
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
await reload(store)
// A lane that renders nowhere is no target at all; `NewCardTarget` then falls through to
// the first lane rather than proposing the trash.
// A lane that is gone is no target at all; `NewCardTarget` then falls through to the first
// lane rather than proposing the trash.
#expect(store.transient.lastActiveLaneID == nil)
}
+260 -372
View File
@@ -2,476 +2,364 @@ import Foundation
import Testing
@testable import Kanban
/// `TrashModel` is the trash quasi-lane's whole heart, and it is a pure function so this suite is
/// the executable form of 03-board-ui.md § Trash Contents: the deterministic sort, the absolute
/// ancestor walk, and what a lane entry's card count actually counts.
/// What is left of the trash as a *model* once the trash became a folder 03-board-ui.md § Trash,
/// resettled 2026-07-28 (the materialized trash).
///
/// **The suite is much smaller than the tombstone model's was, and that is the finding.** The rows
/// no longer need deriving the trash's contents *are* `snapshot.trash`, parsed by the same card
/// parse the lanes use and already in `order` display order so the deterministic timestamp sort,
/// the absolute ancestor walk, and the returning-card count all went with the entries they described.
/// What remains is what the *commands* need: the two purge confirmations' phrasing, and the menu
/// validation that stages Delete by place. Plus `ItemPath`, the location vocabulary that replaced
/// `TrashModel.paths`.
///
/// The snapshots are **loaded from real temp boards** rather than hand-built, for the reason every
/// other model suite here does it: the interesting inputs are `FieldValue<Date>` shapes a valid
/// stamp, an unparseable one, an absent key and only the loader produces those the way production
/// does. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
/// other model suite here does it: only the loader produces `.trash` the way production does.
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
// MARK: - Fixtures
/// A few more literal identities than `Ident` offers: the sort tests need enough rows to prove an
/// *ordering* rather than a comparison, and folder name is the tie-break, so the names matter.
/// A few more literal identities than `Ident` offers folder name is the display tie-break, so the
/// names matter.
private enum More {
static let laneA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
static let laneB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
static let cardD = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"
static let cardE = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"
static let cardF = "ffffffff-ffff-4fff-8fff-ffffffffffff"
}
/// An item with a `deleted:` key `stamp` goes in verbatim, so a test can write an unparseable one.
private func tombstoned(order: String, title: String, deleted: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
deleted: \(deleted)
---
\(title) body.
"""
private func card(order: String, title: String) -> String {
"---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\(title) body.\n"
}
private func live(order: String, title: String) -> String {
"---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\(title) body.\n"
private func untitled(order: String) -> String {
"---\nschema: 1\norder: \(order)\n---\nNo title.\n"
}
private func load(_ fixture: WriterFixture) throws -> BoardModel {
try BoardLoader.load(boardRoot: fixture.root).model
}
private func ids(_ entries: [TrashEntry]) -> [String] {
entries.map(\.id.rawValue)
/// One lane with two cards, and three cards in the board's trash.
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(More.laneA, card(order: "1024", title: "Todo"))
try fixture.item("\(More.laneA)/\(Ident.card1)", card(order: "1024", title: "First"))
try fixture.item("\(More.laneA)/\(Ident.card2)", card(order: "2048", title: "Second"))
// Newest-first by ordinary ranks: every arrival mints above the current top.
try fixture.item(".trash/\(More.cardD)", card(order: "1024", title: "Oldest"))
try fixture.item(".trash/\(More.cardE)", card(order: "512", title: "Middle"))
try fixture.item(".trash/\(More.cardF)", card(order: "256", title: "Newest"))
return fixture
}
// MARK: - The sort
private let laneA = ItemID(rawValue: More.laneA)
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
private let cardD = ItemID(rawValue: More.cardD)
private let cardE = ItemID(rawValue: More.cardE)
private let cardF = ItemID(rawValue: More.cardF)
@Suite("TrashModel ▸ sort")
struct TrashModelSortTests {
// MARK: - The contents
@Test("Dated entries sort newest first, and lane entries interleave by their own stamp")
func newestFirstWithLanesInterleaved() throws {
let fixture = try WriterFixture()
/// **There is no derivation left to test** so what this suite pins instead is that the container
/// *is* the list, in the order the column shows it, which is the pivot's whole claim.
@MainActor
@Suite("The trash's contents are the container")
struct TrashContentsTests {
@Test("The trash is `snapshot.trash`, newest first by ordinary ranks")
func theContainerIsTheList() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
tombstoned(order: "1024", title: "Oldest card", deleted: "2026-03-01T09:00:00Z"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
tombstoned(order: "2048", title: "Newest card", deleted: "2026-03-03T09:00:00Z"))
// A tombstoned lane whose own stamp falls between the two cards': the sort is one ordering
// over both kinds, not cards-then-lanes.
try fixture.item(Ident.lane2,
tombstoned(order: "2048", title: "Doing", deleted: "2026-03-02T09:00:00Z"))
let snapshot = try load(fixture)
let entries = TrashModel.entries(of: try load(fixture))
#expect(ids(entries) == [Ident.card2, Ident.lane2, Ident.card1])
#expect(entries[1].isLaneEntry)
#expect(snapshot.trash.compactMap(\.title.value) == ["Newest", "Middle", "Oldest"],
"03 ▸ Trash: the trash sorts by `order` like any lane, and entry is at the top")
#expect(snapshot.trash.allSatisfy { $0.deleted.isMissing },
"there is no `deleted:` key and no timestamp sort")
}
@Test("Ties on the second break by folder name, ascending")
func tiesBreakByFolderName() throws {
let fixture = try WriterFixture()
@Test("Lanes are never in it, whatever a hand-editor nests in there")
func lanesAreNeverTrashed() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
// One multi-card stamps one second onto N cards. The three are written in an order that
// is neither their folder-name order nor their `order` order, so only the rule can produce
// the expectation below.
let stamp = "2026-03-03T09:00:00Z"
try fixture.item("\(Ident.lane1)/\(More.cardF)", tombstoned(order: "1024", title: "F", deleted: stamp))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "2048", title: "5", deleted: stamp))
try fixture.item("\(Ident.lane1)/\(More.cardD)", tombstoned(order: "3072", title: "D", deleted: stamp))
// A lane-shaped nesting inside `.trash/` is a stray by construction: the container holds
// card folders directly, and the walk does not descend.
try fixture.item(".trash/\(Ident.lane3)/\(Ident.card4)", card(order: "1024", title: "Nested"))
let snapshot = try load(fixture)
let entries = TrashModel.entries(of: try load(fixture))
#expect(ids(entries) == [Ident.card1, More.cardD, More.cardF])
#expect(!snapshot.trash.map(\.id).contains(ItemID(rawValue: Ident.card4)))
#expect(snapshot.lanes.map(\.id) == [laneA], "and nothing in there is a lane")
}
@Test("An unparseable stamp sorts oldest — after every dated entry, however old")
func unparseableSortsOldest() throws {
@Test("An absent container is an empty trash")
func absentIsEmpty() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
tombstoned(order: "1024", title: "Corrupt", deleted: "whenever"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
tombstoned(order: "2048", title: "Ancient", deleted: "1999-01-01T00:00:00Z"))
try fixture.item(More.laneA, card(order: "1024", title: "Todo"))
let model = try load(fixture)
// The premise: the key is present but has no date reading still a tombstone (presence,
// not validity), and still with no position in time.
let corrupt = try #require(model.lanes.first?.cards.first { $0.id.rawValue == Ident.card1 })
#expect(corrupt.isDeleted)
#expect(corrupt.deleted.value == nil)
// A corrupt stamp must not outrank a fresh deletion for the trash's most prominent rows
// and here it does not even outrank a 1999 one.
#expect(ids(TrashModel.entries(of: model)) == [Ident.card2, Ident.card1])
}
@Test("Undated entries order by folder name among themselves, lanes included")
func undatedOrderByFolderName() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(More.laneB, tombstoned(order: "3072", title: "B lane", deleted: "not-a-date"))
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(More.cardE)", tombstoned(order: "1024", title: "E", deleted: "corrupt"))
try fixture.item(More.laneA, tombstoned(order: "2048", title: "A lane", deleted: "later"))
// `a < b < e`: one folder-name ordering across both kinds, exactly as the dated half has
// one date ordering across both kinds.
#expect(ids(TrashModel.entries(of: try load(fixture))) == [More.laneA, More.laneB, More.cardE])
#expect(try load(fixture).trash.isEmpty)
}
}
// MARK: - The ancestor walk and the returning count
// MARK: - ItemPath
@Suite("TrashModel ▸ contents")
struct TrashModelContentsTests {
/// The location vocabulary that replaced `TrashModel.paths` three cases because the board has
/// exactly three places an identity-bearing folder can be.
@MainActor
@Suite("ItemPath")
struct ItemPathTests {
/// The board every test below reads: one live lane holding a tombstoned card and a live one, and
/// one tombstoned lane holding a live card, a card with its own flag, and another live card.
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
tombstoned(order: "1024", title: "Trashed card", deleted: "2026-03-03T09:00:00Z"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", live(order: "2048", title: "Live card"))
try fixture.item(Ident.lane2,
tombstoned(order: "2048", title: "Doing", deleted: "2026-03-02T09:00:00Z"))
try fixture.item("\(Ident.lane2)/\(Ident.card3)", live(order: "1024", title: "Rides along"))
try fixture.item("\(Ident.lane2)/\(More.cardD)",
tombstoned(order: "2048", title: "Own flag", deleted: "2026-03-04T09:00:00Z"))
try fixture.item("\(Ident.lane2)/\(More.cardE)", live(order: "3072", title: "Rides along too"))
return fixture
@Test("Each case resolves to the folder it names")
func foldersResolve() throws {
let root = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true)
#expect(ItemPath.lane(laneA).folder(under: root).path == "/Boards/Work.kanban/\(More.laneA)")
#expect(ItemPath.card(lane: laneA, id: card1).folder(under: root).path
== "/Boards/Work.kanban/\(More.laneA)/\(Ident.card1)")
#expect(ItemPath.trashCard(cardD).folder(under: root).path
== "/Boards/Work.kanban/.trash/\(More.cardD)")
}
@Test("A card with its own deleted: under a tombstoned lane has no row — the walk is absolute")
func ownFlagUnderTombstonedLaneHasNoRow() throws {
@Test("The container is the case, and only a lane is a lane")
func containerAndKind() {
#expect(ItemPath.lane(laneA).container == .board)
#expect(ItemPath.card(lane: laneA, id: card1).container == .board)
#expect(ItemPath.trashCard(cardD).container == .trash)
#expect(ItemPath.lane(laneA).isLane)
#expect(!ItemPath.card(lane: laneA, id: card1).isLane)
#expect(!ItemPath.trashCard(cardD).isLane)
}
@Test("Resolution is per container, in display order, skipping what is not there")
func resolutionIsPerContainer() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let everything: Set<ItemID> = [laneA, card1, card2, cardD, cardE, cardF]
let entries = TrashModel.entries(of: try load(fixture))
// `cardD`'s stamp is the newest on the board, so if it had a row at all it would be the
// first one. The lane's single entry subsumes it instead.
#expect(ids(entries) == [Ident.card1, Ident.lane2])
#expect(!ids(entries).contains(More.cardD))
#expect(ItemPath.resolve(everything, in: .board, snapshot: snapshot)
== [.lane(laneA), .card(lane: laneA, id: card1), .card(lane: laneA, id: card2)],
"lanes left to right, each lane then its cards — never the caller's set order")
#expect(ItemPath.resolve(everything, in: .trash, snapshot: snapshot)
== [.trashCard(cardF), .trashCard(cardE), .trashCard(cardD)],
"and the trash top to bottom")
#expect(ItemPath.resolve([], in: .board, snapshot: snapshot).isEmpty)
#expect(ItemPath.resolve([ItemID(rawValue: Ident.indexless)], in: .trash, snapshot: snapshot).isEmpty)
}
@Test("A lane entry counts what Put Back returns — cards without their own flag")
func returningCountExcludesOwnFlaggedCards() throws {
@Test("A lookup that spans containers finds an item wherever it is, and nothing where it is not")
func lookupSpansContainers() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let entries = TrashModel.entries(of: try load(fixture))
let lane = try #require(entries.first { $0.id.rawValue == Ident.lane2 })
guard case let .lane(_, returning) = lane else {
Issue.record("expected a lane entry")
return
}
// Three cards sit in the folder; two come back with the lane. The third comes back to the
// *trash*, which is why it is not in this number.
#expect(returning == 2)
}
@Test("A lane entry with nothing to return counts zero rather than being suppressed")
func emptyLaneEntryCountsZero() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Empty", deleted: "2026-03-03T09:00:00Z"))
let entries = TrashModel.entries(of: try load(fixture))
#expect(entries.count == 1)
guard case let .lane(_, returning) = entries[0] else {
Issue.record("expected a lane entry")
return
}
#expect(returning == 0)
}
@Test("An empty trash is empty, and a board with any tombstone is not")
func emptiness() throws {
let clean = try WriterFixture()
defer { clean.tearDown() }
try clean.item("", Item.board)
try clean.item(Ident.lane1, live(order: "1024", title: "Todo"))
try clean.item("\(Ident.lane1)/\(Ident.card1)", live(order: "1024", title: "Card"))
#expect(TrashModel.isEmpty(try load(clean)))
#expect(TrashModel.entries(of: try load(clean)).isEmpty)
let dirty = try makeBoard()
defer { dirty.tearDown() }
#expect(!TrashModel.isEmpty(try load(dirty)))
}
@Test("Paths resolve on the row set's liveness side, in display order")
func pathsResolveByEffectiveLiveness() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
let everything: Set<ItemID> = [
ItemID(rawValue: Ident.lane1), ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2),
ItemID(rawValue: Ident.lane2), ItemID(rawValue: Ident.card3), ItemID(rawValue: More.cardD),
]
// Live: the live lane and its live card. `card3` is live by its own flag but its lane is
// tombstoned, so the ancestor walk takes it off this side.
let liveSide = TrashModel.paths(of: everything, on: .live, in: model)
#expect(liveSide.map { ($0.laneID.rawValue, $0.cardID?.rawValue) }.map { "\($0.0)/\($0.1 ?? "-")" }
== ["\(Ident.lane1)/-", "\(Ident.lane1)/\(Ident.card2)"])
// Trashed: the tombstoned card and the tombstoned lane the trash's two rows, in display
// order, lanes left to right and each lane before its cards. Nothing under `lane2` is here:
// its cards have no rows, and the lane's *folder* is what takes them (Put Back returns them
// with it; Delete Immediately purges them with it).
let trashedSide = TrashModel.paths(of: everything, on: .trashed, in: model)
#expect(trashedSide.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "-")" }
== ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane2)/-"])
#expect(TrashModel.paths(of: [], on: .live, in: model).isEmpty)
#expect(TrashModel.paths(of: [ItemID(rawValue: Ident.indexless)], on: .trashed, in: model).isEmpty)
}
@Test("The trashed universe is exactly the trash's rows — one function, not two")
func trashedUniverseIsTheRowSet() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
// The guarantee 02-architecture.md settles: "universe and rows are one function, never a
// broader set with a pointer-side subset". Anything a set may reference on the trashed side
// is something the quasi-lane draws.
let rows = Set(TrashModel.entries(of: model).map(\.id))
let trashed = ItemReferenceSet.idUniverse(of: model, on: .trashed)
#expect(trashed == rows)
// And the two universes therefore do **not** partition the board. `cardD` carries its own
// `deleted:` under a tombstoned lane and `card3` rides along unflagged under the same one;
// both render nowhere, so neither is on either side.
let liveUniverse = ItemReferenceSet.idUniverse(of: model, on: .live)
for hidden in [ItemID(rawValue: More.cardD), ItemID(rawValue: Ident.card3)] {
#expect(!rows.contains(hidden))
#expect(!trashed.contains(hidden))
#expect(!liveUniverse.contains(hidden))
#expect(ItemReferenceSet(ids: [hidden], liveness: .trashed).resolved(against: model).isEmpty)
#expect(ItemReferenceSet(ids: [hidden], liveness: .live).resolved(against: model).isEmpty)
}
// Paths are the same walk in folder form, so they name the same things one per row.
#expect(TrashModel.paths(of: rows, on: .trashed, in: model).count == rows.count)
#expect(TrashModel.emptyTrashTargets(in: model).count == rows.count)
// And `isEmpty`'s short-circuit is that walk's non-emptiness, on both a dirty board and a
// clean one.
#expect(TrashModel.isEmpty(model) == rows.isEmpty)
let clean = try WriterFixture()
defer { clean.tearDown() }
try clean.item("", Item.board)
try clean.item(Ident.lane1, live(order: "1024", title: "Todo"))
let cleanModel = try load(clean)
#expect(TrashModel.isEmpty(cleanModel) == TrashModel.entries(of: cleanModel).isEmpty)
#expect(ItemReferenceSet.idUniverse(of: cleanModel, on: .trashed).isEmpty)
}
@Test("Empty Trash targets every tombstone, a tombstoned lane contributing only its own folder")
func emptyTrashTargets() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let targets = TrashModel.emptyTrashTargets(in: try load(fixture))
// The own-flag card inside the tombstoned lane is not listed removing the lane folder
// takes it, and a second purge of a path that is already gone would be noise.
#expect(targets.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "-")" }
== ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane2)/-"])
#expect(TrashModel.counts(of: targets) == TrashModel.EntryCounts(lanes: 1, cards: 1))
}
@Test("A path resolves under whichever root it is given")
func pathsResolveUnderTheGivenRoot() {
let root = URL(fileURLWithPath: "/tmp/Board.kanban")
let card = TrashModel.ItemPath(laneID: ItemID(rawValue: Ident.lane1), cardID: ItemID(rawValue: Ident.card1))
#expect(card.folder(under: root).path == "/tmp/Board.kanban/\(Ident.lane1)/\(Ident.card1)")
#expect(!card.isLane)
let lane = TrashModel.ItemPath(laneID: ItemID(rawValue: Ident.lane1), cardID: nil)
#expect(lane.folder(under: root).path == "/tmp/Board.kanban/\(Ident.lane1)")
#expect(lane.isLane)
#expect(ItemPath.of(laneA, in: snapshot) == .lane(laneA))
#expect(ItemPath.of(card1, in: snapshot) == .card(lane: laneA, id: card1))
#expect(ItemPath.of(cardD, in: snapshot) == .trashCard(cardD))
#expect(ItemPath.of(ItemID(rawValue: Ident.indexless), in: snapshot) == nil)
}
}
// MARK: - Counts, phrasing, and the confirmations
// MARK: - The universes
@MainActor
@Suite("ItemContainer ▸ the universe")
struct ItemContainerUniverseTests {
@Test("The board's universe is its lanes and their cards; the trash's is its cards")
func universesArePresenceOnly() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(ItemContainer.board.ids(in: snapshot) == [laneA, card1, card2])
#expect(ItemContainer.trash.ids(in: snapshot) == [cardD, cardE, cardF])
}
@Test("The two universes partition the board — nothing is in both, nothing is in neither")
func theyPartition() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let board = ItemContainer.board.ids(in: snapshot)
let trash = ItemContainer.trash.ids(in: snapshot)
#expect(board.isDisjoint(with: trash))
// The tombstone model's two sides deliberately did *not* partition a card under a
// tombstoned lane was in neither. Presence being the whole test is what closed that gap.
var everything: Set<ItemID> = []
for lane in snapshot.lanes {
everything.insert(lane.id)
for card in lane.cards { everything.insert(card.id) }
}
for card in snapshot.trash { everything.insert(card.id) }
#expect(board.union(trash) == everything)
}
}
// MARK: - Phrasing
@Suite("TrashModel ▸ phrasing")
struct TrashModelPhrasingTests {
struct TrashPhrasingTests {
@Test("Plural folding reads counts, singular and plural, cards and lanes and both")
@Test("Plurals fold, and there is only one noun left to fold")
func pluralFolding() {
#expect(TrashModel.phrase(.init(lanes: 0, cards: 41)) == "41 cards")
#expect(TrashModel.phrase(.init(lanes: 0, cards: 1)) == "1 card")
#expect(TrashModel.phrase(.init(lanes: 1, cards: 0)) == "1 lane")
#expect(TrashModel.phrase(.init(lanes: 3, cards: 0)) == "3 lanes")
#expect(TrashModel.phrase(.init(lanes: 2, cards: 3)) == "2 lanes and 3 cards")
#expect(TrashModel.phrase(.init(lanes: 1, cards: 1)) == "1 lane and 1 card")
#expect(TrashModel.phrase(.init()) == "nothing")
#expect(TrashModel.phrase(1) == "1 card")
#expect(TrashModel.phrase(41) == "41 cards")
#expect(TrashModel.phrase(0) == "0 cards")
}
@Test("Entry counts split lane entries from card entries")
func entryCounts() throws {
let fixture = try WriterFixture()
@MainActor
@Test("A sole card is named; several fold into a count")
func purgePromptNamesOrCounts() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
tombstoned(order: "1024", title: "One", deleted: "2026-03-01T09:00:00Z"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
tombstoned(order: "2048", title: "Two", deleted: "2026-03-02T09:00:00Z"))
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Gone", deleted: "2026-03-03T09:00:00Z"))
let counts = TrashModel.counts(of: TrashModel.entries(of: try load(fixture)))
#expect(counts == TrashModel.EntryCounts(lanes: 1, cards: 2))
#expect(counts.total == 3)
#expect(!counts.isEmpty)
}
@Test("Delete Immediately names a sole item and folds several into counts")
func purgePrompt() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
tombstoned(order: "1024", title: "Fix login", deleted: "2026-03-01T09:00:00Z"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
tombstoned(order: "2048", title: "Ship it", deleted: "2026-03-02T09:00:00Z"))
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing", deleted: "2026-03-03T09:00:00Z"))
let model = try load(fixture)
let snapshot = try load(fixture)
let sole = try #require(TrashModel.purgePrompt(
for: [ItemID(rawValue: Ident.card1)], in: model, unrecoverable: true))
#expect(sole.title == "Permanently delete \u{201C}Fix login\u{201D}?")
for: [cardF], in: .trash, snapshot: snapshot, unrecoverable: true
))
#expect(sole.title == "Permanently delete \u{201C}Newest\u{201D}?")
#expect(sole.message == "This can\u{2019}t be undone.")
#expect(sole.confirmTitle == "Delete")
let several = try #require(TrashModel.purgePrompt(
for: [ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)],
in: model, unrecoverable: true))
for: [cardE, cardF], in: .trash, snapshot: snapshot, unrecoverable: true
))
#expect(several.title == "Permanently delete 2 cards?")
// A lane in the set earns the sentence that matters most: the row says how many cards come
// *back*, while a purge takes every card in the folder.
let withLane = try #require(TrashModel.purgePrompt(
for: [ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.lane2)],
in: model, unrecoverable: true))
#expect(withLane.title == "Permanently delete 1 lane and 1 card?")
#expect(withLane.message.hasPrefix("Deleting a lane also deletes every card inside it."))
// m7-git: a board whose history keeps the content says so instead.
let recoverable = try #require(TrashModel.purgePrompt(
for: [ItemID(rawValue: Ident.card1)], in: model, unrecoverable: false))
#expect(recoverable.message == "The board\u{2019}s history still has them.")
// Nothing tombstoned in the set: no prompt, which is also the command's own refusal.
#expect(TrashModel.purgePrompt(for: [ItemID(rawValue: Ident.lane1)], in: model, unrecoverable: true) == nil)
#expect(TrashModel.purgePrompt(for: [], in: model, unrecoverable: true) == nil)
}
@Test("An untitled item is named by its rendering, never by an empty string")
func untitledPrompt() throws {
@MainActor
@Test("Delete Immediately reads the same either side of the boundary")
func purgePromptSpansContainers() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let fromBoard = try #require(TrashModel.purgePrompt(
for: [card1], in: .board, snapshot: snapshot, unrecoverable: true
))
#expect(fromBoard.title == "Permanently delete \u{201C}First\u{201D}?",
"11 ▸ Delete Immediately skips the trash from anywhere")
}
@MainActor
@Test("A lane in the set contributes nothing — no purge path reaches one")
func lanesAreNeverPurged() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(TrashModel.purgePrompt(for: [laneA], in: .board, snapshot: snapshot, unrecoverable: true) == nil)
}
@MainActor
@Test("An untitled card reads as the untitled rendering, never as an empty pair of quotes")
func untitledReadsAsARendering() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\norder: 1024\ndeleted: 2026-03-01T09:00:00Z\n---\nbody\n")
try fixture.item(More.laneA, card(order: "1024", title: "Todo"))
try fixture.item(".trash/\(More.cardD)", untitled(order: "1024"))
let snapshot = try load(fixture)
let prompt = try #require(TrashModel.purgePrompt(
for: [ItemID(rawValue: Ident.card1)], in: try load(fixture), unrecoverable: true))
for: [cardD], in: .trash, snapshot: snapshot, unrecoverable: true
))
#expect(prompt.title == "Permanently delete \u{201C}Untitled\u{201D}?")
}
@Test("Empty Trash names the whole trash's count and refuses on an empty one")
@MainActor
@Test("A set naming nothing raises no prompt — the refusal and the action agree")
func nothingToPurgeRaisesNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(TrashModel.purgePrompt(
for: [ItemID(rawValue: Ident.indexless)], in: .trash, snapshot: snapshot, unrecoverable: true
) == nil)
#expect(TrashModel.purgePrompt(for: [], in: .board, snapshot: snapshot, unrecoverable: true) == nil)
}
@MainActor
@Test("Empty Trash names the true count, and the message follows recoverability")
func emptyTrashPrompt() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let unrecoverable = try #require(TrashModel.emptyTrashPrompt(in: snapshot, unrecoverable: true))
#expect(unrecoverable.title == "Permanently delete 3 cards?")
#expect(unrecoverable.message == "This can\u{2019}t be undone.")
let recoverable = try #require(TrashModel.emptyTrashPrompt(in: snapshot, unrecoverable: false))
#expect(recoverable.message == "The board\u{2019}s history still has them.")
}
@MainActor
@Test("An empty trash raises no Empty Trash prompt")
func emptyTrashOnAnEmptyTrash() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
#expect(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true) == nil)
try fixture.item(More.laneA, card(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
tombstoned(order: "1024", title: "One", deleted: "2026-03-01T09:00:00Z"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
tombstoned(order: "2048", title: "Two", deleted: "2026-03-02T09:00:00Z"))
let prompt = try #require(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true))
// Counts even for a small trash: the command is about the trash, not about an item.
#expect(prompt.title == "Permanently delete 2 cards?")
#expect(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true) == nil)
}
}
// MARK: - Menu validation
@MainActor
@Suite("TrashModel ▸ validation")
struct TrashModelValidationTests {
struct TrashValidationTests {
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", live(order: "1024", title: "Live"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
tombstoned(order: "2048", title: "Trashed", deleted: "2026-03-01T09:00:00Z"))
return fixture
}
@Test("The ⌘⌫ twins enable exactly one of themselves, by the selection's liveness side")
func chordTwinsAreBinary() throws {
/// **One Delete, one predicate.** The tombstone model needed a mirror-image pair so two twins
/// could enable exactly one of themselves; Put Back's retirement left one item, so the predicate
/// is "does this selection name anything", asked in the selection's own container.
@Test("Delete enables for either container, and for nothing that names nothing")
func canDeleteIsStagedNotSplit() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
let snapshot = try load(fixture)
let liveSelection = ItemReferenceSet(ids: [ItemID(rawValue: Ident.card1)], liveness: .live)
#expect(TrashModel.canDelete(selection: liveSelection, in: model))
#expect(!TrashModel.canActOnTrash(selection: liveSelection, in: model))
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [card1], container: .board), in: snapshot))
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [laneA], container: .board), in: snapshot))
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [cardD], container: .trash), in: snapshot))
let trashedSelection = ItemReferenceSet(ids: [ItemID(rawValue: Ident.card2)], liveness: .trashed)
#expect(!TrashModel.canDelete(selection: trashedSelection, in: model))
#expect(TrashModel.canActOnTrash(selection: trashedSelection, in: model))
#expect(!TrashModel.canDelete(selection: .empty, in: snapshot))
// A selection the next reload will drop: the id is real, but not in the container it claims.
#expect(!TrashModel.canDelete(
selection: ItemReferenceSet(ids: [card1], container: .trash), in: snapshot
))
#expect(!TrashModel.canDelete(
selection: ItemReferenceSet(ids: [cardD], container: .board), in: snapshot
))
}
@Test("Neither enables on an empty selection, or on one whose members have gone")
func nothingToActOn() throws {
@Test("Delete Immediately is cards only, in either container")
func canDeleteImmediatelyIsCardsOnly() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
let snapshot = try load(fixture)
#expect(!TrashModel.canDelete(selection: .empty, in: model))
#expect(!TrashModel.canActOnTrash(selection: ItemReferenceSet(ids: [], liveness: .trashed), in: model))
// A selection the next reload will drop: the id names nothing, so the item that would write
// nothing does not look available.
let stale = ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], liveness: .live)
#expect(!TrashModel.canDelete(selection: stale, in: model))
}
@Test("A selection whose side disagrees with the item's own flag enables neither")
func sideMustMatch() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
// The homogeneous-by-liveness invariant makes this unreachable through the UI, and the
// reload rule ejects it if a foreign edit ever produces it but the predicates must not
// trust that, since they are what stands between a stale set and a write.
let wrongSide = ItemReferenceSet(ids: [ItemID(rawValue: Ident.card1)], liveness: .trashed)
#expect(!TrashModel.canActOnTrash(selection: wrongSide, in: model))
#expect(!TrashModel.canDelete(selection: wrongSide, in: model))
#expect(TrashModel.canDeleteImmediately(
selection: ItemReferenceSet(ids: [card1], container: .board), in: snapshot
))
#expect(TrashModel.canDeleteImmediately(
selection: ItemReferenceSet(ids: [cardD], container: .trash), in: snapshot
))
#expect(!TrashModel.canDeleteImmediately(
selection: ItemReferenceSet(ids: [laneA], container: .board), in: snapshot
), "a lane's delete is physical already — there is nothing for 'skip the trash' to mean")
#expect(!TrashModel.canDeleteImmediately(selection: .empty, in: snapshot))
}
}
File diff suppressed because it is too large Load Diff
+151 -108
View File
@@ -18,7 +18,10 @@ import Testing
// MARK: - Fixtures
private func tombstoned(order: String, title: String, deleted: String = "2026-03-03T09:00:00Z") -> String {
/// A card sitting in `<root>/.trash/` an ordinary card in a special place (03-board-ui.md §
/// Trash), with the unknown-key overlay every other fixture card carries so an inverse's
/// verbatim-preservation claim has something to preserve.
private func trashResident(order: String, title: String) -> String {
"""
---
schema: 1
@@ -26,7 +29,6 @@ private func tombstoned(order: String, title: String, deleted: String = "2026-03
order: \(order)
project: lanework # agent overlay
created: 2026-01-01T09:00:00Z
deleted: \(deleted)
---
\(title) body.
@@ -47,8 +49,8 @@ Styled body.
"""
/// Two live lanes the first with two live cards, a styled one and a tombstoned one; the second
/// with one card. Enough for every inverse in this file.
/// Two lanes the first with two plain cards and a styled one, the second with one card plus one
/// card already sitting in the board's `.trash/`. Enough for every inverse in this file.
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
@@ -57,9 +59,9 @@ private func makeBoard() throws -> WriterFixture {
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item("\(Ident.lane1)/\(Ident.card3)", styledCard)
try fixture.item("\(Ident.lane1)/\(Ident.card4)", tombstoned(order: "4096", title: "Trashed"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.indexless)", Item.rich(order: "1024", title: "Elsewhere"))
try fixture.item(".trash/\(Ident.card4)", trashResident(order: "1024", title: "Trashed"))
return fixture
}
@@ -74,7 +76,7 @@ private let elsewhere = ItemID(rawValue: Ident.indexless)
private let card1Path = "\(Ident.lane1)/\(Ident.card1)"
private let card2Path = "\(Ident.lane1)/\(Ident.card2)"
private let card3Path = "\(Ident.lane1)/\(Ident.card3)"
private let trashedPath = "\(Ident.lane1)/\(Ident.card4)"
private let trashedPath = ".trash/\(Ident.card4)"
/// A store with a stack behind it. The provider is returned because `BoardStore.history` is **weak**
/// the session owns the stack in the app, and a test that dropped it would watch its own steps
@@ -488,7 +490,7 @@ struct MoveUndoTests {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.select([card1], liveness: .live)
store.select([card1], in: .board)
store.sortSelection(.down)
@@ -506,13 +508,13 @@ struct MoveUndoTests {
}
}
// MARK: - The trash pair
// MARK: - Delete
@MainActor
@Suite("Undo ▸ the trash pair")
@Suite("Undo ▸ delete")
struct TrashUndoTests {
@Test("Undoing a delete is Put Back — the key goes, and nothing else moves")
@Test("Undoing a card delete moves it back out of the trash, to its lane and its rank")
func deleteRoundTrip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
@@ -520,20 +522,42 @@ struct TrashUndoTests {
let before = try fixture.indexText(card1Path)
store.delete([card1])
#expect(try document(fixture, card1Path).deleted.value != nil)
#expect(!fixture.exists(card1Path), "the folder physically left its lane")
#expect(fixture.exists(".trash/\(Ident.card1)"))
#expect(history.undoActionName == "Delete Card")
history.undo()
#expect(fixture.exists(card1Path), "13 ▸ Interaction with the trash: the undo is the move back")
#expect(!fixture.exists(".trash/\(Ident.card1)"))
let undone = try fixture.indexText(card1Path)
#expect(try FrontmatterDocument.parse(undone).deleted.isMissing)
// Byte-identical but for the stamp: the tombstone and its inverse are one key each.
#expect(untouchedLines(undone).filter { !$0.hasPrefix("deleted:") } == untouchedLines(before))
#expect(try FrontmatterDocument.parse(undone).order.value == 1024, "at its original rank")
// Byte-identical but for the stamps every app write owns no `deleted:` key was ever
// written, so there is none to come back and none to remove.
#expect(untouchedLines(undone) == untouchedLines(before))
history.redo()
#expect(try document(fixture, card1Path).deleted.value != nil)
#expect(fixture.exists(".trash/\(Ident.card1)"))
#expect(!fixture.exists(card1Path))
}
@Test("A multi-item delete is one step with a plural title")
@Test("The redo files the card under the rank the delete minted, not a fresh one")
func redoUsesTheCapturedTrashRank() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.delete([card1])
let minted = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value)
#expect(minted < 1024, "entry is at the top: a rank above the current topmost (order 1024)")
history.undo()
history.redo()
#expect(try document(fixture, ".trash/\(Ident.card1)").order.value == minted,
"the redo replays the write's own captured rank")
}
@Test("A multi-card delete is one step with a plural title, and lands newest-last on top")
func batchDeleteIsOneStep() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
@@ -542,69 +566,74 @@ struct TrashUndoTests {
store.delete([card1, card2])
#expect(history.undoActionName == "Delete 2 Cards")
let first = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value)
let second = try #require(try document(fixture, ".trash/\(Ident.card2)").order.value)
#expect(second < first, "each arrival in the run mints a rank above the one before it")
history.undo()
#expect(try document(fixture, card1Path).deleted.isMissing)
#expect(try document(fixture, card2Path).deleted.isMissing)
#expect(fixture.exists(card1Path))
#expect(fixture.exists(card2Path))
#expect(try loadedTrash(fixture).map(\.id) == [trashed], "only the board's own resident is left")
#expect(history.canUndo == false)
}
@Test("A lane delete is named for the lane")
func laneDeleteIsNamedForTheLane() throws {
@Test("A lane delete is physical, and its undo recreates the folder byte for byte")
func laneDeleteRecreatesTheSubtree() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let laneText = try fixture.indexText(Ident.lane2)
let cardText = try fixture.indexText("\(Ident.lane2)/\(Ident.indexless)")
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(history.undoActionName == "Delete Lane")
history.undo()
#expect(try document(fixture, Ident.lane2).deleted.isMissing)
#expect(fixture.exists(Ident.lane2))
#expect(try fixture.indexText(Ident.lane2) == laneText, "the capture replays bytes, it does not edit")
#expect(try fixture.indexText("\(Ident.lane2)/\(Ident.indexless)") == cardText,
"and the whole subtree comes back with it — nested cards included")
history.redo()
#expect(!fixture.exists(Ident.lane2))
}
@Test("Undoing a Put Back re-tombstones with the timestamp the row was filed under")
func putBackRoundTrip() throws {
@Test("A restore-by-move-out registers as an ordinary Move, with the ordinary move inverse")
func restoreIsAnOrdinaryMove() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let filedUnder = try #require(try document(fixture, trashedPath).deleted.value)
let trashRank = try #require(try document(fixture, trashedPath).order.value)
store.putBack([trashed])
#expect(try document(fixture, trashedPath).deleted.isMissing)
#expect(history.undoActionName == "Restore Card")
store.moveCards([trashed], toLane: lane2, at: 0)
history.undo()
#expect(try document(fixture, trashedPath).deleted.value == filedUnder,
"the trash sorts by this — a fresh stamp would reorder a list the user was reading")
history.redo()
#expect(try document(fixture, trashedPath).deleted.isMissing)
}
@Test("Drag-to-restore undoes the position half too")
func restoreByDragRoundTrip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let filedUnder = try #require(try document(fixture, trashedPath).deleted.value)
store.restoreByDrag(cardID: trashed, intoLane: lane2, at: 0)
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
#expect(try document(fixture, "\(Ident.lane2)/\(Ident.card4)").deleted.isMissing)
#expect(history.undoActionName == "Restore Card")
#expect(!fixture.exists(trashedPath))
#expect(history.undoActionName == "Move Card",
"13: a restore is an ordinary move between containers, named as one")
history.undo()
#expect(fixture.exists(trashedPath), "back in the lane it was trashed in")
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)") == false)
let undone = try document(fixture, trashedPath)
#expect(undone.deleted.value == filedUnder)
#expect(undone.order.value == 4096, "at the rank it was trashed holding")
#expect(fixture.exists(trashedPath), "back in the trash it came out of")
#expect(!fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
#expect(try document(fixture, trashedPath).order.value == trashRank,
"at the rank it was filed under")
history.redo()
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
#expect(try document(fixture, "\(Ident.lane2)/\(Ident.card4)").deleted.isMissing)
}
}
/// The board's trash as the loader reads it never the store's snapshot, which a write deliberately
/// does not touch.
@MainActor
private func loadedTrash(_ fixture: WriterFixture) throws -> [Card] {
try BoardLoader.load(boardRoot: fixture.root).model.trash
}
// MARK: - The Edit session
@MainActor
@@ -703,6 +732,7 @@ struct NotUndoableTests {
store.delete([card1])
let armed = try #require(history.undoActionName)
store.select([trashed], in: .trash)
store.deleteImmediately([trashed])
#expect(fixture.exists(trashedPath) == false)
@@ -758,7 +788,7 @@ struct NotUndoableTests {
store.delete([card1])
store.setLaneWidth(lane1, units: 2)
#expect(try document(fixture, card1Path).deleted.value != nil)
#expect(fixture.exists(".trash/\(Ident.card1)"))
#expect(try document(fixture, Ident.lane1).width.value == 2)
#expect(store.banners.oneShots.isEmpty)
}
@@ -803,9 +833,9 @@ struct CrossingIsAWriteTests {
store.delete([card1])
for _ in 0 ..< 3 {
history.undo()
#expect(try document(fixture, card1Path).deleted.isMissing)
#expect(fixture.exists(card1Path))
history.redo()
#expect(try document(fixture, card1Path).deleted.value != nil)
#expect(fixture.exists(".trash/\(Ident.card1)"))
}
}
}
@@ -840,8 +870,15 @@ private enum Foreign {
_ = try BoardWriter.writeBody(inItemFolder: fixture.url(path), body: body)
}
static func delete(_ fixture: WriterFixture, _ path: String) throws {
try BoardWriter.deleteItem(at: fixture.url(path))
/// A foreign delete of a **card** the shape a delete has on disk now: the folder moves into
/// `.trash/`. `FileManager` and nothing else, so no rank is minted and no stamp is written.
static func trash(_ fixture: WriterFixture, _ path: String, id: String) throws {
try fixture.move(path, toTrash: id)
}
/// A foreign delete of a **lane** physical, since lanes are never trashed.
static func removeLane(_ fixture: WriterFixture, _ path: String) throws {
try FileManager.default.removeItem(at: fixture.url(path))
}
static func purge(_ fixture: WriterFixture, _ path: String) throws {
@@ -886,12 +923,13 @@ struct StaleStepTests {
store.transient.updateRenameDraft("Second!")
store.commitRename()
try Foreign.delete(fixture, card2Path)
try Foreign.trash(fixture, card2Path, id: Ident.card2)
history.undo()
// The top step's card is in the trash now, so its rename is not ours to walk back; the one
// below it is untouched and applies in the same Z.
#expect(try document(fixture, card2Path).title.value == "Second!", "the foreign writer's board, left alone")
#expect(try document(fixture, ".trash/\(Ident.card2)").title.value == "Second!",
"the foreign writer's board, left alone")
#expect(try document(fixture, card1Path).title.value == "First", "⌘Z fell through and did something")
#expect(store.banners.signposts.map(\.message)
== ["Undo skipped — 'Second!' changed outside Lanework"])
@@ -906,29 +944,33 @@ struct StaleStepTests {
let (store, history) = try makeStore(fixture)
store.delete([card1])
try Foreign.purge(fixture, card1Path)
try Foreign.purge(fixture, ".trash/\(Ident.card1)")
history.undo()
#expect(fixture.exists(card1Path) == false)
#expect(!fixture.exists(".trash/\(Ident.card1)"))
#expect(store.banners.signposts.count == 1)
#expect(store.banners.oneShots.isEmpty, "a skip is not a write failure — no error row")
#expect(history.canUndo == false)
#expect(history.canRedo == false, "a skipped step leaves nothing behind")
}
@Test("A foreign Put Back skips the delete's undo — the item is not on the side we left it")
/// The container check, and it needs no field of its own: a delete step's undo expects its card
/// at `<root>/.trash/<id>`, and a foreign restore leaves that path empty (`HistoryStaleness`).
@Test("A foreign restore skips the delete's undo — the card is not in the container we left it")
func aForeignRestoreSkipsTheDeleteStep() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.delete([card1])
try BoardWriter.restoreItem(at: fixture.url(card1Path))
try fixture.move(".trash/\(Ident.card1)", toLane: Ident.lane2, card: Ident.card1)
history.undo()
#expect(try document(fixture, card1Path).deleted.isMissing, "still live, as they left it")
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card1)"), "where the foreign writer put it")
#expect(!fixture.exists(card1Path), "and not moved back on top of them")
#expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'First' changed outside Lanework"])
}
@@ -949,23 +991,25 @@ struct StaleStepTests {
#expect(history.canUndo == false)
}
@Test("A tombstoned card is stale for a field edit, even with the field itself untouched")
func aTombstonedTargetIsStaleForAFieldEdit() throws {
@Test("A foreign lane delete is stale for a field edit — there is nothing at the path")
func aRemovedLaneIsStaleForAFieldEdit() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.setLaneWidth(lane1, units: 3)
try Foreign.delete(fixture, Ident.lane1)
try Foreign.removeLane(fixture, Ident.lane1)
history.undo()
#expect(try document(fixture, Ident.lane1).width.value == 3, "not resized inside the trash")
#expect(!fixture.exists(Ident.lane1), "not conjured back to be resized")
#expect(store.banners.signposts.count == 1)
}
@Test("A card under a foreign-tombstoned lane is stale too — liveness is ancestor-walked")
func anAncestorTombstoneIsStale() throws {
/// 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.
@Test("A card whose lane a foreign writer deleted is stale too")
func aCardUnderARemovedLaneIsStale() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
@@ -973,11 +1017,11 @@ struct StaleStepTests {
store.transient.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft("Renamed")
store.commitRename()
try Foreign.delete(fixture, Ident.lane1)
try Foreign.removeLane(fixture, Ident.lane1)
history.undo()
#expect(try document(fixture, card1Path).title.value == "Renamed", "the card renders nowhere; nothing was written")
#expect(!fixture.exists(card1Path), "the card is gone; nothing was written")
#expect(store.banners.signposts.count == 1)
}
@@ -1014,7 +1058,7 @@ struct StaleStepTests {
store.commitRename()
try Foreign.rename(fixture, card2Path, to: "Theirs")
try Foreign.delete(fixture, card3Path)
try Foreign.trash(fixture, card3Path, id: Ident.card3)
history.undo()
#expect(try document(fixture, card1Path).title.value == "First", "the step applied")
@@ -1108,7 +1152,7 @@ struct StaleStepTests {
let (store, history) = try makeStore(fixture)
store.delete([card1])
try Foreign.purge(fixture, card1Path)
try Foreign.purge(fixture, ".trash/\(Ident.card1)")
history.undo()
let row = try #require(store.bannerRows.last)
@@ -1219,7 +1263,7 @@ struct FailedCrossingTests {
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.url(card1Path).path)
history.undo()
#expect(try document(fixture, card2Path).deleted.value != nil,
#expect(fixture.exists(".trash/\(Ident.card2)"),
"the step below was never reached — a refused disk is not a reason to attempt more")
#expect(history.undoActionName == "Rename Card")
}
@@ -1322,34 +1366,36 @@ struct HistoryPhraseTests {
/// that the two doors reach one state rather than two similar ones, and that alternating between
/// them leaves a stack that crosses cleanly in both directions.
@MainActor
@Suite("Undo ▸ the trash's two doors")
@Suite("Undo ▸ the trash and the stack are the same folder moves")
struct TrashInterplayTests {
@Test("Undoing a delete lands exactly where Put Back would have — the same bytes, not a near miss")
/// 13-native-undo.md Interaction with the trash: "The stack and the trash never conflict they
/// are the same folder moves addressed by recency instead of by selection."
@Test("Undoing a delete lands exactly where a manual move-out would — the same bytes")
func theTwoDoorsReachOneState() async throws {
// Two identical boards, one per door: the claim is about a *state*, so the honest comparison
// is the whole board read off disk, not the one field each path happens to write.
let byUndo = try makeBoard()
defer { byUndo.tearDown() }
let byPutBack = try makeBoard()
defer { byPutBack.tearDown() }
let byMove = try makeBoard()
defer { byMove.tearDown() }
let origin = try boardTexts(byUndo)
let (undoStore, history) = try makeStore(byUndo)
undoStore.delete([card1])
history.undo()
let (putBackStore, _) = try makeStore(byPutBack)
putBackStore.delete([card1])
// Put Back reads the trashed side of the snapshot, so it has to see the tombstone first
// which is the one-way flow, not a test artefact.
await reload(putBackStore)
putBackStore.putBack([card1])
let (moveStore, _) = try makeStore(byMove)
moveStore.delete([card1])
// The move-out reads the trash side of the snapshot, so it has to see the card arrive there
// first which is the one-way flow, not a test artefact.
await reload(moveStore)
moveStore.moveCards([card1], toLane: lane1, at: 0)
expectSameBoard(try boardTexts(byUndo), try boardTexts(byPutBack), "the two doors")
expectSameBoard(try boardTexts(byUndo), try boardTexts(byMove), "the two doors")
expectSameBoard(try boardTexts(byUndo), origin, "undo against the board it started from")
#expect(try document(byUndo, card1Path).deleted.isMissing)
#expect(try document(byPutBack, card1Path).deleted.isMissing)
#expect(byUndo.exists(card1Path))
#expect(byMove.exists(card1Path))
}
@Test("Undoing a delete is position-preserving — the card comes back where it was, not at an end")
@@ -1358,25 +1404,25 @@ struct TrashInterplayTests {
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
/// The lane's live cards in display order, read through the loader the order the board
/// actually renders, rather than the ranks it is derived from.
func liveCards() throws -> [String] {
/// The lane's cards in display order, read through the loader the order the board actually
/// renders, rather than the ranks it is derived from.
func laneCards() throws -> [String] {
let result = try BoardLoader.load(boardRoot: fixture.root)
let lane = try #require(result.model.lanes.first { $0.id == lane1 })
return lane.cards.filter { !$0.isDeleted }.map(\.id.rawValue)
return lane.cards.map(\.id.rawValue)
}
let before = try liveCards()
let before = try laneCards()
#expect(before == [Ident.card1, Ident.card2, Ident.card3], "the middle card is genuinely in the middle")
// The middle of three: an implementation that restored by appending would pass on a first or
// last card and fail here.
store.delete([card2])
await reload(store)
#expect(try liveCards() == [Ident.card1, Ident.card3])
#expect(try laneCards() == [Ident.card1, Ident.card3])
history.undo()
#expect(try liveCards() == before, "01's deletion bullet: restore is position-perfect")
#expect(try laneCards() == before, "13: the undo returns the card to its source lane and rank")
#expect(try document(fixture, card2Path).order.value == 2048, "the rank it held all along")
}
@@ -1387,30 +1433,27 @@ struct TrashInterplayTests {
let (store, history) = try makeStore(fixture)
let live = try boardTexts(fixture)
// Three gestures over one card, alternating the doors: , Put Back, .
// Three gestures over one card, alternating the doors: , move back out, .
store.delete([card1])
await reload(store)
let filedUnder = try #require(try document(fixture, card1Path).deleted.value)
#expect(fixture.exists(".trash/\(Ident.card1)"))
store.putBack([card1])
store.moveCards([card1], toLane: lane1, at: 0)
await reload(store)
#expect(try document(fixture, card1Path).deleted.isMissing)
#expect(fixture.exists(card1Path))
store.delete([card1])
await reload(store)
#expect(history.undoActionName == "Delete Card")
let trashed = try boardTexts(fixture)
let trashedBoard = try boardTexts(fixture)
// Back up the stack: delete restore tombstone restore. The middle step is Put Back's
// inverse, which has to re-file the row under the stamp it was filed under rather than under
// now the trash sorts by it.
// Back up the stack: delete move delete, each crossed in reverse.
history.undo()
#expect(try document(fixture, card1Path).deleted.isMissing)
#expect(history.undoActionName == "Restore Card")
#expect(fixture.exists(card1Path))
#expect(history.undoActionName == "Move Card")
history.undo()
#expect(try document(fixture, card1Path).deleted.value == filedUnder,
"re-tombstoned where it was filed, not where a fresh stamp would put it")
#expect(fixture.exists(".trash/\(Ident.card1)"), "back in the trash the move took it out of")
#expect(history.undoActionName == "Delete Card")
history.undo()
@@ -1419,12 +1462,12 @@ struct TrashInterplayTests {
// And forward again, the same three steps in the other direction.
history.redo()
#expect(try document(fixture, card1Path).deleted.value != nil)
#expect(fixture.exists(".trash/\(Ident.card1)"))
history.redo()
#expect(try document(fixture, card1Path).deleted.isMissing)
#expect(fixture.exists(card1Path))
history.redo()
#expect(history.canRedo == false)
expectSameBoard(try boardTexts(fixture), trashed, "three doors forward")
expectSameBoard(try boardTexts(fixture), trashedBoard, "three doors forward")
#expect(store.banners.signposts.isEmpty, "nothing was stale: the doors never collided")
#expect(store.banners.oneShots.isEmpty)
}
+23
View File
@@ -93,6 +93,29 @@ struct WriterFixture {
func entryNames(_ relativePath: String) throws -> [String] {
try FileManager.default.contentsOfDirectory(atPath: url(relativePath).path).sorted()
}
/// Moves a folder from one place in the board to another a **foreign** move, the way an agent
/// or a hand-editor makes one: `FileManager` and nothing else, no `index.md` rewritten, no rank
/// touched. What the container-crossing rules are stated in terms of (02-architecture.md §
/// Live-reload resilience, resettled 2026-07-28).
func moveFolder(_ relativePath: String, to destinationPath: String) throws {
let destination = url(destinationPath)
try FileManager.default.createDirectory(
at: destination.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try FileManager.default.moveItem(at: url(relativePath), to: destination)
}
/// A card folder moved into `<root>/.trash/` the shape of a delete on disk, made foreignly.
func move(_ relativePath: String, toTrash cardName: String) throws {
try moveFolder(relativePath, to: ".trash/\(cardName)")
}
/// A card folder moved out of the trash into a lane the shape of a restore, made foreignly.
func move(_ relativePath: String, toLane laneName: String, card cardName: String) throws {
try moveFolder(relativePath, to: "\(laneName)/\(cardName)")
}
}
// MARK: - Move/copy identities