Realign code with the 2026-07-31 rulings
The trash sorts by modified descending — the arrival rank mint retires (Ranks.isOrderedForTrash one comparator, loader + merged order agree; the legacy deleted: migration stamps modified from the tombstone timestamp where parseable; delete undo steps validate existence-only; agent guide v8). Trash selection goes kind-blind — ranges, marquee, Select All, and the successor walk sweep both kinds; the guard moves to the exits (mixed-payload drop refusal, copy/cut validation). The copy stamping preflight widens back to comment depth (load-scoped posture — the board always loads, the gesture refuses whole). Fixes a latent no-op: trashed-lane drag restore never fired (DragSession.beginLanes hard-coded the board container). 2403 tests in 413 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -687,6 +687,24 @@ struct AgentGuideContentTests {
|
||||
#expect(content.contains("the trash is the recoverable path for both"))
|
||||
}
|
||||
|
||||
/// **v8: the arrival rank is retired** (08-agent-integration.md's own line, re-ruled 2026-07-31:
|
||||
/// "move the card **or lane** folder into `<root>/.trash/` and restamp `modified` (the trash
|
||||
/// sorts newest-first by that stamp … no rank to mint)"). The guide has to teach the *stamp* as
|
||||
/// the position and to leave `order` alone — and, just as load-bearing, it must no longer teach
|
||||
/// the rank formula: an agent still computing "smallest `order` minus 1024" would be writing a
|
||||
/// key the app now deliberately preserves for the restore.
|
||||
@Test("v8 teaches the stamp as the trash's order, and the rank formula is gone")
|
||||
func v8TrashOrderingVocabularyIsPresent() {
|
||||
let content = AgentGuide.content
|
||||
#expect(content.contains("sorts by `modified`, newest first**"))
|
||||
#expect(content.contains("the stamp is also the position"))
|
||||
#expect(content.contains("there is no rank to mint"))
|
||||
#expect(content.contains("leave `order` exactly as it is"))
|
||||
// The retired formula, in the two spellings the v7 literal used.
|
||||
#expect(!content.contains("smallest `order` already in `.trash/`"))
|
||||
#expect(!content.contains("Arrivals go on top"))
|
||||
}
|
||||
|
||||
/// The pathfinder's guide taught `media/` and tombstone deletes; both are retired
|
||||
/// (01-storage-format.md ▸ Changes from the pathfinder schema; ▸ Deletion). The one legitimate
|
||||
/// mention of `deleted:` is the warning never to write it.
|
||||
|
||||
@@ -267,7 +267,7 @@ struct StoreWriteCardBodyTests {
|
||||
func aTrashedCardStillTakesTheFlush() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try BoardWriter.deleteCardToTrash(at: fixture.url(cardPath), inBoard: fixture.root, order: 1024)
|
||||
try BoardWriter.deleteCardToTrash(at: fixture.url(cardPath), inBoard: fixture.root)
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let trashedPath = ".trash/\(Ident.card1)"
|
||||
|
||||
|
||||
@@ -90,9 +90,7 @@ struct CardWindowFateTests {
|
||||
func aLaneTrashedDismissesItsCards() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try BoardWriter.deleteLaneToTrash(
|
||||
at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024
|
||||
)
|
||||
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root)
|
||||
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
|
||||
#expect(snapshot.trashedLanes.map(\.id.rawValue) == [Ident.lane1])
|
||||
|
||||
@@ -194,8 +194,7 @@ struct ClaimedNameDisplacementTests {
|
||||
store.displaceClaimedNames()
|
||||
try BoardWriter.deleteCardToTrash(
|
||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||
inBoard: fixture.root,
|
||||
order: 1024
|
||||
inBoard: fixture.root
|
||||
)
|
||||
|
||||
#expect(fixture.exists(".trash/\(Ident.card1)"))
|
||||
|
||||
@@ -669,6 +669,55 @@ struct ClipboardAvailabilityTests {
|
||||
== ItemReferenceSet(ids: [clipboardTrashedLane], container: .trash))
|
||||
}
|
||||
|
||||
/// **The guard the trash's kind-blind selection moved to the exits** (04-interactions.md ▸ The
|
||||
/// trash, ruled 2026-07-31): "the pasteboard's payload types are per-kind, so Cut and Copy grey
|
||||
/// out via ordinary menu validation while a trash selection mixes kinds — no failed gesture, no
|
||||
/// beep".
|
||||
///
|
||||
/// It is also what keeps `ClipboardManifest.kind` honest: the manifest names one payload type,
|
||||
/// and a set spanning both never reaches the capture.
|
||||
@Test("A mixed trash selection greys out both Copy and Cut")
|
||||
func mixedTrashSelectionClosesCopyAndCut() throws {
|
||||
let harness = try makeTrashedLaneHarness()
|
||||
defer { harness.tearDown() }
|
||||
harness.store.transient.isTrashVisible = true
|
||||
|
||||
// Each kind alone is fine — the selection is legal either way, and so is the gesture.
|
||||
harness.store.select([clipboardCard3], in: .trash)
|
||||
#expect(harness.clipboard.canCopy(from: harness.store))
|
||||
harness.store.select([clipboardTrashedLane], in: .trash)
|
||||
#expect(harness.clipboard.canCopy(from: harness.store))
|
||||
|
||||
// Together — a selection the grammar now allows — the exits close.
|
||||
harness.store.select([clipboardCard3, clipboardTrashedLane], in: .trash)
|
||||
#expect(harness.store.selection.ids.count == 2, "the selection itself is legal")
|
||||
#expect(harness.clipboard.canCopy(from: harness.store) == false)
|
||||
#expect(harness.clipboard.canCut(from: harness.store) == false)
|
||||
// Delete is deliberately *not* gated: it works on a mixed selection, the alert counting both
|
||||
// kinds (04 ▸ The trash).
|
||||
#expect(TrashModel.canDelete(selection: harness.store.selection, in: harness.store.snapshot))
|
||||
}
|
||||
|
||||
/// The live board's own mixed set cannot be built by any gesture — but the predicate answers for
|
||||
/// it anyway rather than assuming, so a future caller cannot smuggle one past the exits.
|
||||
@Test("The mixed-kind predicate answers for the live board too")
|
||||
func mixedKindPredicateCoversTheBoard() throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let snapshot = harness.store.snapshot
|
||||
let laneID = try #require(snapshot.lanes.first?.id)
|
||||
|
||||
#expect(!SelectionGrammar.mixesKinds(
|
||||
ItemReferenceSet(ids: [clipboardCard1], container: .board), in: snapshot))
|
||||
#expect(SelectionGrammar.mixesKinds(
|
||||
ItemReferenceSet(ids: [clipboardCard1, laneID], container: .board), in: snapshot))
|
||||
// Rows the container no longer holds are ignored — a ghost must not grey out a menu item.
|
||||
#expect(!SelectionGrammar.mixesKinds(
|
||||
ItemReferenceSet(ids: [clipboardCard1, ItemID(rawValue: Ident.indexless)], container: .board),
|
||||
in: snapshot))
|
||||
#expect(!SelectionGrammar.mixesKinds(.empty, in: snapshot))
|
||||
}
|
||||
|
||||
@Test("The read-only lock blocks cut but never copy")
|
||||
func lockBlocksCutOnly() throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
|
||||
@@ -232,6 +232,7 @@ struct CommentIndexMatchingTests {
|
||||
id: ItemID(rawValue: Ident.lane3),
|
||||
schema: 1,
|
||||
title: .valid("Retired"),
|
||||
modified: .missing,
|
||||
order: 1024,
|
||||
heldCards: 2,
|
||||
document: FrontmatterDocument(body: "")
|
||||
|
||||
@@ -638,27 +638,63 @@ struct CommentCopyTests {
|
||||
#expect(!fixture.exists("\(copiedCard)/comments/.trash"))
|
||||
}
|
||||
|
||||
@Test("A comment nobody can stamp never refuses the copy — it travels verbatim")
|
||||
func brokenCommentDoesNotRefuseACopy() throws {
|
||||
/// **The preflight reaches comment depth** (01-storage-format.md § Identity lifecycle and
|
||||
/// § Enhanced schema, ruled 2026-07-31 — reversing the 2026-07-30 carve-out this suite used to
|
||||
/// pin): "a comment whose frontmatter cannot take the stamp refuses the copy exactly like a card
|
||||
/// or lane — never-refuse … is a *load* posture, and a user-initiated copy is a transaction, not
|
||||
/// a load".
|
||||
///
|
||||
/// The board still loads with this comment on it — that is the other test in this file — and it
|
||||
/// is the gesture that refuses. Whole, and with nothing materialized: a partial copy is the one
|
||||
/// outcome the transaction rule exists to prevent.
|
||||
@Test("A comment nobody can stamp refuses the whole copy, and nothing is materialized")
|
||||
func brokenCommentRefusesACopy() 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.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
|
||||
// Readable, uneditable — the shape that refuses a copy at card level.
|
||||
// Readable, uneditable — the shape that refuses a copy at card level, now at comment depth.
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)", Item.uneditable)
|
||||
|
||||
let copy = try BoardWriter.copyItem(
|
||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||
toParent: fixture.url(Ident.lane2),
|
||||
order: 1024,
|
||||
stamps: .fork
|
||||
)
|
||||
let copied = "\(Ident.lane2)/\(copy.rawValue)"
|
||||
let names = try postedNames(fixture, inCard: copied)
|
||||
#expect(names.count == 1, "the copy landed whole")
|
||||
#expect(try fixture.indexText("\(copied)/comments/\(names[0])") == Item.uneditable, "verbatim")
|
||||
var thrown: BoardWriteError?
|
||||
do {
|
||||
_ = try BoardWriter.copyItem(
|
||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||
toParent: fixture.url(Ident.lane2),
|
||||
order: 1024,
|
||||
stamps: .fork
|
||||
)
|
||||
} catch {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
let error = try #require(thrown)
|
||||
if case .uneditableFrontmatter = error.reason {} else {
|
||||
Issue.record("expected an uneditable-frontmatter refusal, got \(error.reason)")
|
||||
}
|
||||
#expect(error.path.hasSuffix("comments/\(CommentIdent.one)/index.md"),
|
||||
"the path points at the annotation, which is where the fix is")
|
||||
// Nothing landed, and the source is untouched.
|
||||
#expect(try fixture.entryNames(Ident.lane2).filter { IntegrityRules.isIdentityShaped($0) }.isEmpty)
|
||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)")
|
||||
== Item.uneditable)
|
||||
}
|
||||
|
||||
/// The other side of "load-scoped": the very same board **loads**, and its card window's thread
|
||||
/// read tolerates the annotation. Only the copy gesture refuses.
|
||||
@Test("The same broken comment never refuses the board")
|
||||
func brokenCommentNeverRefusesTheBoard() 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: "Fix login"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)", Item.uneditable)
|
||||
|
||||
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
#expect(model.lanes.first?.cards.map(\.id.rawValue) == [Ident.card1])
|
||||
}
|
||||
|
||||
@Test("Template instantiation is born-today at comment depth too")
|
||||
@@ -739,7 +775,7 @@ struct CommentCopyTests {
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
let card = try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
|
||||
|
||||
try BoardWriter.deleteCardToTrash(at: fixture.url(card), inBoard: fixture.root, order: 1024)
|
||||
try BoardWriter.deleteCardToTrash(at: fixture.url(card), inBoard: fixture.root)
|
||||
|
||||
let trashedCard = ".trash/\(Ident.card1)"
|
||||
#expect(fixture.exists("\(trashedCard)/comments/\(CommentIdent.one)"))
|
||||
|
||||
@@ -241,6 +241,80 @@ struct TrashDropTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The mixed-kind drag out of the trash
|
||||
|
||||
/// **"A mixed-kind drag never leaves the trash"** (04-interactions.md ▸ The trash, ruled 2026-07-31
|
||||
/// with kind-blind trash selection): "pickup is allowed — the selection is legal — but every
|
||||
/// out-of-trash drop target refuses the mixed payload, and the release surfaces a notice explaining
|
||||
/// the rule … the refused drag ends like any refusal, rows staying put".
|
||||
///
|
||||
/// The refusal itself lives in `BoardDropContext.commitDrop`, which needs a live window and is not
|
||||
/// unit-testable — the same split every other drop suite makes. What is testable is the whole of
|
||||
/// what the refusal is *made* of: the flag a pickup records, and the notice the release posts.
|
||||
@MainActor
|
||||
@Suite("The mixed-kind drag out of the trash")
|
||||
struct MixedTrashDragTests {
|
||||
|
||||
private static let card1 = ItemID(rawValue: Ident.card1)
|
||||
private static let lane1 = ItemID(rawValue: Ident.lane1)
|
||||
|
||||
/// One lane and one trashed card — enough for a store to exist and a session to name folders.
|
||||
private func makeBoard() 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(".trash/\(Ident.card1)", Item.rich(order: "1024", title: "Trashed"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
/// Pickup is allowed, and the flag is what travels instead of the rows that cannot ride a
|
||||
/// per-kind payload — so nothing falls silently out of the drag.
|
||||
@Test("A pickup records whether its selection spanned both kinds")
|
||||
func theFlagTravels() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = DragSession()
|
||||
let folder = store.rootURL.appendingPathComponent(Ident.card1, isDirectory: true)
|
||||
|
||||
session.beginCards([Self.card1], folders: [folder], heights: [44], container: .trash, source: store)
|
||||
#expect(!session.mixesKinds, "an ordinary trash-card drag carries no flag")
|
||||
|
||||
session.beginCards(
|
||||
[Self.card1], folders: [folder], heights: [44],
|
||||
container: .trash, source: store, mixesKinds: true
|
||||
)
|
||||
#expect(session.mixesKinds)
|
||||
#expect(session.container == .trash)
|
||||
|
||||
// The lane level records it the same way, and a trashed lane row's session is in `.trash` —
|
||||
// which is what routes its release to the restore rather than to a strip permutation.
|
||||
session.beginLanes(
|
||||
[Self.lane1], folders: [folder], units: [1],
|
||||
container: .trash, source: store, mixesKinds: true
|
||||
)
|
||||
#expect(session.mixesKinds)
|
||||
#expect(session.container == .trash)
|
||||
|
||||
// And an ordinary strip drag is unaffected: board container, no flag.
|
||||
session.beginLanes([Self.lane1], folders: [folder], units: [1], source: store)
|
||||
#expect(!session.mixesKinds)
|
||||
#expect(session.container == .board)
|
||||
}
|
||||
|
||||
/// The notice is 04's own sentence, and it is a **loss row** — nothing failed and no write was
|
||||
/// attempted, but the gesture the user made did not happen (the `postSkippedFolders` register).
|
||||
@Test("The release's notice is the rule, in the design's own words")
|
||||
func theNoticeExplainsTheRule() {
|
||||
let banners = BannerCenter()
|
||||
banners.postMixedTrashDrag()
|
||||
|
||||
#expect(banners.losses.map(\.message)
|
||||
== ["Cards and lanes leave the trash separately \u{2014} restore one kind at a time"])
|
||||
#expect(banners.oneShots.isEmpty, "no write failed — this is not an error row")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The committed-overlay hold
|
||||
|
||||
@Suite("CommittedHold")
|
||||
|
||||
@@ -675,9 +675,14 @@ struct RestoreByMoveOutTests {
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||
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"))
|
||||
// The trash's own order is its display order, and since 2026-07-31 that is `modified`
|
||||
// descending (01-storage-format.md § Deletion) — `card3` above `card2`, newest-first.
|
||||
try fixture.item(
|
||||
".trash/\(Ident.card2)",
|
||||
"---\nschema: 1\ntitle: TrashedA\norder: 2048\nmodified: 2026-05-01T09:00:00Z\n---\n")
|
||||
try fixture.item(
|
||||
".trash/\(Ident.card3)",
|
||||
"---\nschema: 1\ntitle: TrashedB\norder: 1024\nmodified: 2026-05-03T09:00:00Z\n---\n")
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.moveCards([card3, card2], toLane: lane2, at: 0)
|
||||
@@ -829,7 +834,7 @@ struct RestoreLaneTests {
|
||||
#expect(try fixture.indexText(Ident.lane3).contains("project: lanework"), "unknown keys ride along")
|
||||
}
|
||||
|
||||
@Test("Its undo is the ordinary move back in, at the trash rank the row was holding")
|
||||
@Test("Its undo is the ordinary move back in, at the `order` the row was carrying")
|
||||
func undoMovesItBackIn() throws {
|
||||
let fixture = try makeTrashedLaneBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
@@ -271,7 +271,7 @@ struct EchoLedgerWriterTests {
|
||||
let from = fixture.url("\(lane1)/\(card1)")
|
||||
|
||||
try EchoLedger.$current.withValue(ledger) {
|
||||
_ = try BoardWriter.deleteCardToTrash(at: from, inBoard: fixture.root, order: 1024)
|
||||
_ = try BoardWriter.deleteCardToTrash(at: from, inBoard: fixture.root)
|
||||
}
|
||||
|
||||
let to = fixture.url(".trash/\(card1)")
|
||||
@@ -594,13 +594,11 @@ struct EchoLedgerHealMarkTests {
|
||||
try EchoLedger.$current.withValue(ledger) {
|
||||
_ = try BoardWriter.migrateTombstonedCard(
|
||||
at: fixture.url("\(lane1)/\(Ident.card3)"),
|
||||
inBoard: fixture.root,
|
||||
order: 1024
|
||||
inBoard: fixture.root
|
||||
)
|
||||
_ = try BoardWriter.deleteCardToTrash(
|
||||
at: fixture.url("\(lane1)/\(card1)"),
|
||||
inBoard: fixture.root,
|
||||
order: 2048
|
||||
inBoard: fixture.root
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ struct InertGitTests {
|
||||
// the pair most likely to notice a `.git` at the root: the first walks into `<root>/.trash/`
|
||||
// and the second walks back out of it.
|
||||
try BoardWriter.deleteCardToTrash(
|
||||
at: board.url("\(board.laneA)/\(board.card1)"), inBoard: board.root, order: 1024
|
||||
at: board.url("\(board.laneA)/\(board.card1)"), inBoard: board.root
|
||||
)
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: BoardWriter.trashFolder(inBoard: board.root).appendingPathComponent(board.card1),
|
||||
|
||||
@@ -470,8 +470,7 @@ struct ObjectKindWriteTests {
|
||||
|
||||
try BoardWriter.deleteCardToTrash(
|
||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||
inBoard: fixture.root,
|
||||
order: 1024
|
||||
inBoard: fixture.root
|
||||
)
|
||||
|
||||
#expect(try kind(of: ".trash/\(Ident.card1)", in: fixture) == .valid("card"))
|
||||
|
||||
@@ -25,10 +25,13 @@ private enum More {
|
||||
|
||||
/// A card or lane whose title and body are **independently controlled** — which `Item.rich` cannot
|
||||
/// be, since its body quotes its title, and a title-or-body test needs the two to disagree.
|
||||
private func item(order: String, title: String?, body: String) -> String {
|
||||
private func item(order: String, title: String?, body: String, modified: String? = nil) -> String {
|
||||
var lines = ["---", "schema: 1"]
|
||||
if let title { lines.append("title: \(title)") }
|
||||
lines.append("order: \(order)")
|
||||
// The trash sorts by `modified` descending (01-storage-format.md § Deletion, re-ruled
|
||||
// 2026-07-31), so a trash fixture states its column order here rather than in `order`.
|
||||
if let modified { lines.append("modified: \(modified)") }
|
||||
lines.append("---")
|
||||
return lines.joined(separator: "\n") + "\n" + body + "\n"
|
||||
}
|
||||
@@ -109,12 +112,17 @@ 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(".trash/\(Ident.card1)", item(order: "1024", title: "Fix login", body: "Auth."))
|
||||
// The column reads [card1, laneX, card2] — by their stamps, both kinds interleaved.
|
||||
try fixture.item(
|
||||
".trash/\(Ident.card1)",
|
||||
item(order: "1024", title: "Fix login", body: "Auth.", modified: "2026-05-05T09:00:00Z"))
|
||||
try fixture.item(
|
||||
".trash/\(More.laneX)",
|
||||
"---\nschema: 1\ntitle: Archive\norder: 1536\nkind: lane\n---\nOld login notes.\n"
|
||||
"---\nschema: 1\ntitle: Archive\norder: 1536\nmodified: 2026-05-03T09:00:00Z\nkind: lane\n---\nOld login notes.\n"
|
||||
)
|
||||
try fixture.item(".trash/\(Ident.card2)", item(order: "2048", title: "Polish", body: "Wording."))
|
||||
try fixture.item(
|
||||
".trash/\(Ident.card2)",
|
||||
item(order: "2048", title: "Polish", body: "Wording.", modified: "2026-05-01T09:00:00Z"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
@@ -335,19 +343,20 @@ struct SearchFilterOrderTests {
|
||||
/// "The row matches the search filter by lane title only" (03-board-ui.md § Trash) — the opaque
|
||||
/// unit's own rule, and the one place a lane *is* filtered: `laneX`'s body carries the query and
|
||||
/// the row still leaves the column, because its body is not on screen.
|
||||
@Test("A trashed lane row filters by title alone, and its own list is kind-scoped")
|
||||
@Test("A trashed lane row filters by title alone")
|
||||
func trashLaneRowsFilterByTitle() throws {
|
||||
let fixture = try makeTrashBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let model = try load(fixture)
|
||||
|
||||
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: model) == [laneX])
|
||||
#expect(SelectionGrammar.trashLanes(in: model) == [laneX])
|
||||
#expect(SelectionGrammar.trashLanes(in: model, filter: SearchFilter(query: "archive")) == [laneX])
|
||||
// The body says "login". The row does not.
|
||||
#expect(SelectionGrammar.trashLanes(in: model, filter: SearchFilter(query: "login")).isEmpty)
|
||||
// And the kind-scoped lists are disjoint slices of one rank order, which is what makes a
|
||||
// ⇧-range skip the other kind (04-interactions.md ▸ The trash).
|
||||
// And the ranging grammar's list is the whole column, both kinds — kind-blind trash selection
|
||||
// (04-interactions.md ▸ The trash, re-ruled 2026-07-31).
|
||||
#expect(SelectionGrammar.trashRows(in: model) == [card1, laneX, card2])
|
||||
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: model) == [card1, laneX, card2])
|
||||
}
|
||||
|
||||
@Test("The trash's visible universe is its matching rows, both kinds")
|
||||
@@ -371,7 +380,7 @@ struct SearchFilterOrderTests {
|
||||
defer { fixture.tearDown() }
|
||||
let model = try load(fixture)
|
||||
|
||||
// Both kinds, interleaved purely by rank (03-board-ui.md § Trash).
|
||||
// Both kinds, interleaved by `modified` descending (03-board-ui.md § Trash).
|
||||
#expect(TrashLaneView.rendered(model, filter: .inactive).map(\.id) == [card1, laneX, card2])
|
||||
#expect(TrashLaneView.rendered(model, filter: SearchFilter(query: "login")).map(\.id) == [card1])
|
||||
// The column and the arrow grammar cannot disagree about what is on screen.
|
||||
@@ -431,7 +440,8 @@ struct SearchFilterStoreTests {
|
||||
store.transient.isTrashVisible = true
|
||||
store.select([card2], in: .trash)
|
||||
store.selectAll()
|
||||
#expect(store.selection.ids == [card1, card2])
|
||||
// Every visible *row*, both kinds — kind-blind since 2026-07-31.
|
||||
#expect(store.selection.ids == [card1, laneX, card2])
|
||||
|
||||
store.select([card1], in: .trash)
|
||||
store.searchQuery = "login"
|
||||
|
||||
@@ -12,10 +12,11 @@ import Testing
|
||||
/// 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, and the kind one reaches into the trash** (resettled 2026-07-28; lanes
|
||||
/// rejoined 2026-07-29): cards XOR lanes, and board XOR trash. The trash's rows are cards *and*
|
||||
/// opaque lane units, so "a trash selection is either cards or lane rows" is the board's own kind
|
||||
/// rule in a second container rather than a third axis.
|
||||
/// **One container axis, and a kind axis that stops at it** (resettled 2026-07-28; kind-blind trash
|
||||
/// re-ruled 2026-07-31): a selection never mixes trash rows with board items, and *on the board* it
|
||||
/// is cards XOR lanes — but inside the trash "cards and lane rows select together", so the kind axis
|
||||
/// does not reach into the second container. The guard that used to live there moved to the exits
|
||||
/// (⌘C/⌘X validation and the mixed-payload drop refusal).
|
||||
///
|
||||
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
|
||||
|
||||
@@ -46,32 +47,50 @@ private func makeLiveBoard() throws -> WriterFixture {
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// walks, newest first by `modified` (`[card1, card2, card3]`).
|
||||
@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)/\(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"))
|
||||
// **The trash's order is `modified` descending** (01-storage-format.md § Deletion, re-ruled
|
||||
// 2026-07-31), so the stamps are what these fixtures state their sequence in; the `order` values
|
||||
// ride along and are deliberately in the *opposite* direction, so nothing here can pass by
|
||||
// accident of the retired rank rule.
|
||||
try fixture.item(".trash/\(Ident.card1)", trashItem(order: "1024", title: "First", modified: "2026-05-05T09:00:00Z"))
|
||||
try fixture.item(".trash/\(Ident.card2)", trashItem(order: "512", title: "Second", modified: "2026-05-03T09:00:00Z"))
|
||||
try fixture.item(".trash/\(Ident.card3)", trashItem(order: "256", title: "Third", modified: "2026-05-01T09:00:00Z"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
/// One trash entry, stated in the key the container actually sorts by.
|
||||
private func trashItem(order: String, title: String, modified: String, kind: String? = nil) -> String {
|
||||
"""
|
||||
---
|
||||
schema: 1
|
||||
title: \(title)
|
||||
order: \(order)
|
||||
modified: \(modified)
|
||||
\(kind.map { "kind: \($0)\n" } ?? "")---
|
||||
\(title) body.
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
/// The same trash with **two lane rows interleaved among its cards** (03-board-ui.md § Trash, lanes
|
||||
/// rejoined 2026-07-29): the rank order is `[card1, lane2, card2, lane3, card3]`, so every
|
||||
/// kind-boundary claim below has a row of the other kind sitting inside the span it asks about.
|
||||
/// rejoined 2026-07-29): the column order is `[card1, lane2, card2, lane3, card3]`, so every
|
||||
/// kind-crossing claim below has a row of the other kind sitting inside the span it asks about.
|
||||
@MainActor
|
||||
private func makeMixedTrashBoard() throws -> WriterFixture {
|
||||
let fixture = try makeTrashBoard()
|
||||
try fixture.item(
|
||||
".trash/\(Ident.lane2)",
|
||||
"---\nschema: 1\ntitle: Doing\norder: 384\nkind: lane\n---\n"
|
||||
trashItem(order: "384", title: "Doing", modified: "2026-05-04T09:00:00Z", kind: "lane")
|
||||
)
|
||||
try fixture.item(
|
||||
".trash/\(Ident.lane3)",
|
||||
"---\nschema: 1\ntitle: Done\norder: 768\nkind: lane\n---\n"
|
||||
trashItem(order: "768", title: "Done", modified: "2026-05-02T09:00:00Z", kind: "lane")
|
||||
)
|
||||
return fixture
|
||||
}
|
||||
@@ -135,7 +154,7 @@ struct SelectionOrderTests {
|
||||
#expect(SelectionGrammar.lanes(in: snapshot) == [lane1, lane2, lane3])
|
||||
}
|
||||
|
||||
@Test("The trash's list is its cards, in `order`; a trash with no lane rows has no lane list")
|
||||
@Test("The trash's list is its rows, newest first; a trash with no lane rows is all cards")
|
||||
func trashOrder() throws {
|
||||
let fixture = try makeTrashBoard()
|
||||
defer { fixture.tearDown() }
|
||||
@@ -143,21 +162,30 @@ struct SelectionOrderTests {
|
||||
|
||||
#expect(SelectionGrammar.trashCards(in: snapshot) == [card1, card2, card3])
|
||||
#expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == [card1, card2, card3])
|
||||
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot).isEmpty)
|
||||
// Kind-blind: with no lane rows in the container the two lists coincide, which is the point —
|
||||
// there is only ever *one* trash list to walk (04 ▸ The trash, re-ruled 2026-07-31).
|
||||
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot) == [card1, card2, card3])
|
||||
#expect(SelectionGrammar.trashLanes(in: snapshot).isEmpty)
|
||||
}
|
||||
|
||||
/// **The column's three lists** (03-board-ui.md § Trash; 04-interactions.md ▸ The trash): one
|
||||
/// merged rank order for *navigation*, and two kind-scoped slices of it for *ranging*. The
|
||||
/// slices are what make a ⇧-range skip the other kind without a rule that says so.
|
||||
@Test("The trash's rows interleave by rank, and each kind's list is a slice of that order")
|
||||
/// **The column has one list, and the kind argument does not narrow it** (04-interactions.md ▸
|
||||
/// The trash, re-ruled 2026-07-31 — kind-blind trash selection): navigation and ranging read the
|
||||
/// same merged sequence, so a ⇧-range sweeps the rows of the other kind rather than skipping
|
||||
/// them. The kind-scoped slices survive as `trashCards`/`trashLanes` for the consumers that
|
||||
/// genuinely mean one kind, and they are still slices of the same order.
|
||||
@Test("The trash's rows interleave by stamp, and `order(of:in:)` returns them whatever the kind")
|
||||
func trashRowsInterleave() throws {
|
||||
let fixture = try makeMixedTrashBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try load(fixture)
|
||||
|
||||
#expect(SelectionGrammar.trashRows(in: snapshot) == [card1, lane2, card2, lane3, card3])
|
||||
#expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == [card1, card2, card3])
|
||||
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot) == [lane2, lane3])
|
||||
let rows = [card1, lane2, card2, lane3, card3]
|
||||
#expect(SelectionGrammar.trashRows(in: snapshot) == rows)
|
||||
#expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == rows)
|
||||
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot) == rows)
|
||||
// The kind-scoped slices are still slices of it.
|
||||
#expect(SelectionGrammar.trashCards(in: snapshot) == [card1, card2, card3])
|
||||
#expect(SelectionGrammar.trashLanes(in: snapshot) == [lane2, lane3])
|
||||
// One merge, one order: the path resolver batches in exactly the order the column draws.
|
||||
#expect(ItemPath.resolve([card2, lane2, lane3], in: .trash, snapshot: snapshot)
|
||||
== [.trashLane(lane2), .trashCard(card2), .trashLane(lane3)])
|
||||
@@ -287,19 +315,21 @@ struct CommandClickTests {
|
||||
#expect(ontoCard.anchor == card1)
|
||||
}
|
||||
|
||||
/// The kind axis inside the trash: "a trash selection is either cards or lane rows,
|
||||
/// kind-homogeneous like the live board's own grammar" (04-interactions.md ▸ The trash).
|
||||
@Test("⌘-click across the kind boundary inside the trash replaces")
|
||||
func acrossKindInTheTrashReplaces() throws {
|
||||
/// **Inside the trash there is no kind boundary to cross** (04-interactions.md ▸ The trash,
|
||||
/// re-ruled 2026-07-31, superseding the kind-homogeneous trash grammar): "within the trash cards
|
||||
/// and lane rows select together — clicks, ⇧-click ranges, ⇧-arrow extension, and the rubber
|
||||
/// band all sweep every row". So a ⌘-click that used to replace now *extends*.
|
||||
@Test("⌘-click adds a lane row to a card selection inside the trash")
|
||||
func acrossKindInTheTrashExtends() throws {
|
||||
let fixture = try makeMixedTrashBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try load(fixture)
|
||||
|
||||
let ontoRow = click(target(lane2, .lane, .trash), .command, selection: set([card1], .trash), anchor: card1, in: snapshot)
|
||||
#expect(ontoRow.selection == set([lane2], .trash))
|
||||
#expect(ontoRow.selection == set([card1, lane2], .trash))
|
||||
|
||||
let ontoCard = click(target(card1, .card, .trash), .command, selection: set([lane2, lane3], .trash), anchor: lane3, in: snapshot)
|
||||
#expect(ontoCard.selection == set([card1], .trash))
|
||||
#expect(ontoCard.selection == set([lane2, lane3, card1], .trash))
|
||||
|
||||
// Within one kind it still toggles, which is what makes the branch above a rule rather than
|
||||
// a refusal of ⌘ in the trash.
|
||||
@@ -421,28 +451,29 @@ struct ShiftClickTests {
|
||||
#expect(outcome.anchor == card1)
|
||||
}
|
||||
|
||||
/// "⇧-click ranges skip rows of the other kind (resurrecting the 2026-07-28 skip-by-kind ruling,
|
||||
/// mooted when lanes left the trash and back with them)" — 04-interactions.md ▸ The trash.
|
||||
@Test("A trash range skips rows of the other kind")
|
||||
func trashRangeSkipsTheOtherKind() throws {
|
||||
/// **A trash range sweeps every row** (04-interactions.md ▸ The trash, re-ruled 2026-07-31 —
|
||||
/// superseding the skip-by-kind ruling this suite used to pin): "⇧-click ranges … all sweep every
|
||||
/// row". The kinds are not a boundary inside the container any more; the container still is.
|
||||
@Test("A trash range sweeps every row between its endpoints, both kinds")
|
||||
func trashRangeSweepsEveryRow() throws {
|
||||
let fixture = try makeMixedTrashBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try load(fixture)
|
||||
|
||||
// The column reads [card1, lane2, card2, lane3, card3]: a card range from the top to the
|
||||
// bottom takes the three cards and steps over both lane rows.
|
||||
// The column reads [card1, lane2, card2, lane3, card3]: a range from the top to the bottom
|
||||
// now takes all five rows rather than stepping over the two lane rows.
|
||||
let cards = click(target(card3, .card, .trash), .shift, selection: set([card1], .trash), anchor: card1, in: snapshot)
|
||||
#expect(cards.selection == set([card1, card2, card3], .trash))
|
||||
#expect(cards.selection == set([card1, lane2, card2, lane3, card3], .trash))
|
||||
|
||||
// And a lane-row range takes the rows, skipping the card sitting between them.
|
||||
// And a range anchored on a lane row picks up the card between them.
|
||||
let rows = click(target(lane3, .lane, .trash), .shift, selection: set([lane2], .trash), anchor: lane2, in: snapshot)
|
||||
#expect(rows.selection == set([lane2, lane3], .trash))
|
||||
#expect(rows.selection == set([lane2, card2, lane3], .trash))
|
||||
|
||||
// A range aimed across the kinds has no list holding both endpoints, so it degrades to a
|
||||
// plain click — never a mixed selection.
|
||||
// And a range aimed from a card to a lane row is now an ordinary range rather than a
|
||||
// degraded plain click: both endpoints sit in the one list, so the anchor stays put.
|
||||
let crossed = click(target(lane3, .lane, .trash), .shift, selection: set([card1], .trash), anchor: card1, in: snapshot)
|
||||
#expect(crossed.selection == set([lane3], .trash))
|
||||
#expect(crossed.anchor == lane3)
|
||||
#expect(crossed.selection == set([card1, lane2, card2, lane3], .trash))
|
||||
#expect(crossed.anchor == card1)
|
||||
}
|
||||
|
||||
@Test("A range never crosses the container boundary")
|
||||
@@ -483,12 +514,13 @@ struct MarqueeMathTests {
|
||||
#expect(ids == [card1, card2])
|
||||
}
|
||||
|
||||
/// **The kind filter is the whole rule, and in the trash it is load-bearing.** A trashed lane row
|
||||
/// registers its frame like a card does — the arrows navigate by those frames — so the band
|
||||
/// genuinely sweeps over one and must still leave it out: "the rubber band … selects cards only
|
||||
/// (as the board marquee does); lane rows join by click grammar" (04-interactions.md ▸ The trash).
|
||||
@Test("On the trash side the band takes cards only, and stays on its own side")
|
||||
func trashSideTakesItsOwnCards() {
|
||||
/// **In the trash the band sweeps every row** (04-interactions.md ▸ The trash, re-ruled
|
||||
/// 2026-07-31, superseding the card-only band): "the rubber band [sweeps] every row (the band's
|
||||
/// full-height backdrop covers both kinds)". A trashed lane row registers its frame like a card
|
||||
/// does, so this needed only the kind filter to come off on that side — the *container* filter
|
||||
/// stays, and the band still never leaves the side it began on.
|
||||
@Test("On the trash side the band takes every row, and stays on its own side")
|
||||
func trashSideTakesEveryRow() {
|
||||
let targets = [
|
||||
Self.card(card1, 0, container: .trash),
|
||||
MarqueeTarget(id: lane2, kind: .lane, container: .trash,
|
||||
@@ -498,7 +530,7 @@ struct MarqueeMathTests {
|
||||
]
|
||||
let all = CGRect(x: 0, y: 0, width: 50, height: 200)
|
||||
|
||||
#expect(MarqueeMath.selection(rect: all, targets: targets, in: .trash) == [card1, card2])
|
||||
#expect(MarqueeMath.selection(rect: all, targets: targets, in: .trash) == [card1, lane2, card2])
|
||||
#expect(MarqueeMath.selection(rect: all, targets: targets, in: .board) == [card3])
|
||||
}
|
||||
|
||||
@@ -637,17 +669,19 @@ struct SelectAllTests {
|
||||
store.transient.isTrashVisible = true
|
||||
|
||||
// "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.
|
||||
// trash rows" (04 ▸ The map; kind-blind since 2026-07-31). This container holds only cards,
|
||||
// so rows and cards coincide — the mixed case is the test below.
|
||||
store.select([card2], in: .trash)
|
||||
store.selectAll()
|
||||
#expect(store.selection == set([card1, card2, card3], .trash))
|
||||
}
|
||||
|
||||
/// "Select All is card-scoped everywhere, never lane rows" (04-interactions.md ▸ The trash,
|
||||
/// re-affirmed 2026-07-29): a lane-row selection is a *trash* selection, so the command reads the
|
||||
/// column — and what it selects there is its cards.
|
||||
@Test("A lane-row selection still selects the trash's cards, never the rows")
|
||||
func trashBranchIsCardScopedWithLaneRows() throws {
|
||||
/// **In the trash, "all" is all rows** (04-interactions.md ▸ The trash and 11-command-nexus.md ▸
|
||||
/// Select All, re-ruled 2026-07-31 with kind-blind trash selection: "Select All with a non-empty
|
||||
/// trash selection selects **all visible trash rows**"). The live board's own Select All stays
|
||||
/// card-scoped, which the board branch above pins.
|
||||
@Test("Select All in the trash takes every row, lane rows included")
|
||||
func trashBranchTakesEveryRow() throws {
|
||||
let fixture = try makeMixedTrashBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
@@ -656,9 +690,7 @@ struct SelectAllTests {
|
||||
store.select([lane2], in: .trash)
|
||||
store.selectAll()
|
||||
|
||||
#expect(store.selection == set([card1, card2, card3], .trash))
|
||||
#expect(!store.selection.ids.contains(lane2))
|
||||
#expect(!store.selection.ids.contains(lane3))
|
||||
#expect(store.selection == set([card1, lane2, card2, lane3, card3], .trash))
|
||||
}
|
||||
|
||||
@Test("The trash branch is narrow: hidden, live, empty, or ghost selections take the board")
|
||||
|
||||
@@ -28,8 +28,11 @@ private enum More {
|
||||
static let cardF = "ffffffff-ffff-4fff-8fff-ffffffffffff"
|
||||
}
|
||||
|
||||
private func card(order: String, title: String) -> String {
|
||||
"---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\(title) body.\n"
|
||||
private func card(order: String, title: String, modified: String? = nil) -> String {
|
||||
// The trash sorts by `modified` descending (01-storage-format.md § Deletion, re-ruled
|
||||
// 2026-07-31), so a trash fixture states its column position here rather than in `order`.
|
||||
let stamp = modified.map { "modified: \($0)\n" } ?? ""
|
||||
return "---\nschema: 1\ntitle: \(title)\norder: \(order)\n\(stamp)---\n\(title) body.\n"
|
||||
}
|
||||
|
||||
private func untitled(order: String) -> String {
|
||||
@@ -48,10 +51,11 @@ private func makeBoard() throws -> WriterFixture {
|
||||
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"))
|
||||
// Newest-first by `modified`; the ranks disagree on purpose, so nothing here can pass by
|
||||
// accident of the retired arrival-rank rule.
|
||||
try fixture.item(".trash/\(More.cardD)", card(order: "256", title: "Oldest", modified: "2026-05-01T09:00:00Z"))
|
||||
try fixture.item(".trash/\(More.cardE)", card(order: "512", title: "Middle", modified: "2026-05-03T09:00:00Z"))
|
||||
try fixture.item(".trash/\(More.cardF)", card(order: "1024", title: "Newest", modified: "2026-05-05T09:00:00Z"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
@@ -70,16 +74,16 @@ private let cardF = ItemID(rawValue: More.cardF)
|
||||
@Suite("The trash's contents are the container")
|
||||
struct TrashContentsTests {
|
||||
|
||||
@Test("The trash is `snapshot.trash`, newest first by ordinary ranks")
|
||||
@Test("The trash is `snapshot.trash`, newest first by `modified`")
|
||||
func theContainerIsTheList() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try load(fixture)
|
||||
|
||||
#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")
|
||||
"03 ▸ Trash: the trash sorts by `modified` descending, and entry is at the top")
|
||||
#expect(snapshot.trash.allSatisfy { $0.deleted.isMissing },
|
||||
"there is no `deleted:` key and no timestamp sort")
|
||||
"there is no `deleted:` key — the stamp is the position")
|
||||
}
|
||||
|
||||
@Test("Lanes are never in it, whatever a hand-editor nests in there")
|
||||
@@ -306,8 +310,11 @@ struct TrashFreightTests {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(More.laneA, card(order: "1024", title: "Todo"))
|
||||
try fixture.item(".trash/\(More.cardD)", card(order: "1024", title: "A card"))
|
||||
try fixture.item(".trash/\(Ident.lane2)", "---\nschema: 1\ntitle: Doing\norder: 512\nkind: lane\n---\n")
|
||||
try fixture.item(
|
||||
".trash/\(More.cardD)", card(order: "1024", title: "A card", modified: "2026-05-01T09:00:00Z"))
|
||||
try fixture.item(
|
||||
".trash/\(Ident.lane2)",
|
||||
"---\nschema: 1\ntitle: Doing\norder: 512\nmodified: 2026-05-03T09:00:00Z\nkind: lane\n---\n")
|
||||
for index in 0 ..< held {
|
||||
try fixture.item(
|
||||
".trash/\(Ident.lane2)/\(UUID().uuidString.lowercased())",
|
||||
@@ -396,15 +403,15 @@ struct TrashFreightTests {
|
||||
== [.trashLane(ItemID(rawValue: Ident.lane2))])
|
||||
}
|
||||
|
||||
/// The column is one list: `resolve` hands back the trash's paths interleaved by rank, because
|
||||
/// a batch's order is the column's order (03 § Trash).
|
||||
@Test("Resolution interleaves the container's two kinds by rank")
|
||||
/// The column is one list: `resolve` hands back the trash's paths interleaved by `modified`,
|
||||
/// because a batch's order is the column's order (03 § Trash).
|
||||
@Test("Resolution interleaves the container's two kinds by the column's own order")
|
||||
func resolutionInterleaves() throws {
|
||||
let fixture = try makeFreightBoard(held: 0)
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try load(fixture)
|
||||
|
||||
// The lane sits at 512 and the card at 1024, so the lane row comes first.
|
||||
// The lane's stamp is the newer of the two, so the lane row comes first.
|
||||
#expect(ItemPath.resolve(
|
||||
[ItemID(rawValue: Ident.lane2), cardD], in: .trash, snapshot: snapshot
|
||||
) == [.trashLane(ItemID(rawValue: Ident.lane2)), .trashCard(cardD)])
|
||||
|
||||
@@ -57,11 +57,12 @@ private func uuidName() -> String { UUID().uuidString.lowercased() }
|
||||
@Suite("BoardLoader ▸ the .trash container")
|
||||
struct TrashContainerLoadTests {
|
||||
|
||||
/// The container's whole ordering story: ordinary `order` ranks, ascending, sorted exactly as a
|
||||
/// lane's cards are — newest-first falls out of *minting* (each arrival takes a rank above the
|
||||
/// current top), never out of a timestamp sort, so the loader has no trash-specific rule at all.
|
||||
@Test("Trash cards load in rank order, in their own container, carrying no deleted key")
|
||||
func trashCardsLoadInRankOrder() throws {
|
||||
/// The container's whole ordering story: **`modified` descending** (01-storage-format.md
|
||||
/// § Deletion, re-ruled 2026-07-31 — the arrival rank mint retired). `order` rides along
|
||||
/// untouched and is deliberately *not* consulted, which is what this fixture proves by giving the
|
||||
/// three entries ranks that disagree with their stamps in every direction.
|
||||
@Test("Trash cards load newest-first by `modified`, in their own container, carrying no deleted key")
|
||||
func trashCardsLoadNewestFirstByModified() throws {
|
||||
let fixture = try TrashFixture()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
@@ -72,15 +73,23 @@ struct TrashContainerLoadTests {
|
||||
|
||||
try fixture.index("", "schema: 1\n")
|
||||
try fixture.index(lane, "schema: 1\norder: 1024\n")
|
||||
// Written oldest-first on disk; the ranks are what decides.
|
||||
try fixture.index(".trash/\(oldest)", "schema: 1\norder: 1024\ntitle: Oldest\n")
|
||||
try fixture.index(".trash/\(middle)", "schema: 1\norder: 0\ntitle: Middle\n")
|
||||
try fixture.index(".trash/\(newest)", "schema: 1\norder: -1024\ntitle: Newest\n")
|
||||
// The `order` values are scrambled against the stamps on purpose: were a rank still deciding
|
||||
// anything, this fixture would read Newest, Middle, Oldest by accident of the old rule and
|
||||
// the assertion would pass for the wrong reason.
|
||||
try fixture.index(
|
||||
".trash/\(oldest)", "schema: 1\norder: -1024\ntitle: Oldest\nmodified: 2026-07-01T09:00:00Z\n")
|
||||
try fixture.index(
|
||||
".trash/\(middle)", "schema: 1\norder: 1024\ntitle: Middle\nmodified: 2026-07-15T09:00:00Z\n")
|
||||
try fixture.index(
|
||||
".trash/\(newest)", "schema: 1\norder: 0\ntitle: Newest\nmodified: 2026-07-30T09:00:00Z\n")
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
|
||||
#expect(result.model.trash.map(\.id.rawValue) == [newest, middle, oldest])
|
||||
#expect(result.model.trash.map(\.title.value) == ["Newest", "Middle", "Oldest"])
|
||||
// And the ranks really did ride along untouched — the loader read them, it just did not sort
|
||||
// by them.
|
||||
#expect(result.model.trash.map(\.order) == [0, 1024, -1024])
|
||||
// The pivot in one assertion: a trashed card carries no flag, it is simply somewhere else.
|
||||
#expect(result.model.trash.allSatisfy { !$0.isDeleted })
|
||||
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
|
||||
@@ -383,8 +392,7 @@ struct DeleteCardToTrashTests {
|
||||
#expect(!fixture.exists(".trash"))
|
||||
let id = try BoardWriter.deleteCardToTrash(
|
||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||
inBoard: fixture.root,
|
||||
order: -1024
|
||||
inBoard: fixture.root
|
||||
)
|
||||
|
||||
#expect(id == ItemID(rawValue: Ident.card1))
|
||||
@@ -394,8 +402,11 @@ struct DeleteCardToTrashTests {
|
||||
|
||||
/// The move's whole edit: the new rank plus the stamps. `modified` is stamped **on purpose** —
|
||||
/// the one exception to moves-don't-stamp, and what a future age-based auto-purge reads.
|
||||
@Test("Only order and the stamps are rewritten; every other byte survives")
|
||||
func onlyOrderAndStampsChange() throws {
|
||||
/// **The stamps are the *whole* rewrite** (01 § Deletion, re-ruled 2026-07-31): no rank is
|
||||
/// minted on arrival, so `order` is one of the bytes that survives rather than one of the two
|
||||
/// that change.
|
||||
@Test("Only the stamps are rewritten; every other byte — `order` included — survives")
|
||||
func onlyTheStampsChange() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
@@ -405,8 +416,7 @@ struct DeleteCardToTrashTests {
|
||||
|
||||
try BoardWriter.deleteCardToTrash(
|
||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||
inBoard: fixture.root,
|
||||
order: -1024
|
||||
inBoard: fixture.root
|
||||
)
|
||||
|
||||
let text = try fixture.indexText(".trash/\(Ident.card1)")
|
||||
@@ -421,7 +431,7 @@ struct DeleteCardToTrashTests {
|
||||
#expect(!text.contains("deleted:"))
|
||||
|
||||
let document = try FrontmatterDocument.parse(text)
|
||||
#expect(document.order == .valid(-1024))
|
||||
#expect(document.order == .valid(2048), "the rank rode along exactly as the card left its lane")
|
||||
}
|
||||
|
||||
@Test("Attachments and strays travel byte-identical — nothing beneath the card is read")
|
||||
@@ -439,8 +449,7 @@ struct DeleteCardToTrashTests {
|
||||
|
||||
try BoardWriter.deleteCardToTrash(
|
||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||
inBoard: fixture.root,
|
||||
order: -1024
|
||||
inBoard: fixture.root
|
||||
)
|
||||
|
||||
#expect(try fixture.data(".trash/\(Ident.card1)/attachments/shot.png") == png)
|
||||
@@ -462,8 +471,7 @@ struct DeleteCardToTrashTests {
|
||||
let error = writeFailure {
|
||||
_ = try BoardWriter.deleteCardToTrash(
|
||||
at: fixture.url(target),
|
||||
inBoard: fixture.root,
|
||||
order: -1024
|
||||
inBoard: fixture.root
|
||||
)
|
||||
}
|
||||
#expect(error != nil)
|
||||
@@ -486,8 +494,7 @@ struct DeleteCardToTrashTests {
|
||||
let error = writeFailure {
|
||||
_ = try BoardWriter.deleteCardToTrash(
|
||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||
inBoard: fixture.root,
|
||||
order: -2048
|
||||
inBoard: fixture.root
|
||||
)
|
||||
}
|
||||
#expect(error?.operation == .delete(title: "Live"))
|
||||
@@ -510,8 +517,7 @@ struct DeleteCardToTrashTests {
|
||||
let error = writeFailure {
|
||||
_ = try BoardWriter.deleteCardToTrash(
|
||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||
inBoard: fixture.root,
|
||||
order: -1024
|
||||
inBoard: fixture.root
|
||||
)
|
||||
}
|
||||
if case .uneditableFrontmatter = error?.reason {} else {
|
||||
@@ -550,8 +556,7 @@ struct TombstoneMigrationTests {
|
||||
|
||||
try BoardWriter.migrateTombstonedCard(
|
||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||
inBoard: fixture.root,
|
||||
order: -1024
|
||||
inBoard: fixture.root
|
||||
)
|
||||
|
||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
||||
@@ -559,7 +564,7 @@ struct TombstoneMigrationTests {
|
||||
#expect(!text.contains("deleted:"))
|
||||
#expect(text.contains("project: lanework # agent overlay"))
|
||||
#expect(text.contains("Body kept."))
|
||||
#expect(try FrontmatterDocument.parse(text).order == .valid(-1024))
|
||||
#expect(try FrontmatterDocument.parse(text).order == .valid(2048), "no rank is minted")
|
||||
|
||||
// And the board now loads it as an ordinary trash card.
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
@@ -567,6 +572,74 @@ struct TombstoneMigrationTests {
|
||||
#expect(result.model.trash.map(\.id.rawValue) == [Ident.card1])
|
||||
}
|
||||
|
||||
/// **The migration stamps `modified` from the key it is retiring** (01 § Deletion, re-ruled
|
||||
/// 2026-07-31): "the deletion time is when the card entered the trash, so real deletion order
|
||||
/// survives into the `modified`-descending sort". Without it every migrated card would land at
|
||||
/// migration time and the board's deletion history would flatten into one instant.
|
||||
///
|
||||
/// The duplicate key is deliberate and its resolution is 01's own last-wins rule: the *final*
|
||||
/// occurrence is the value, so 02-02 is the stamp and 01-01 is invisible.
|
||||
@Test("The migration stamps `modified` from the legacy `deleted:` timestamp")
|
||||
func migrationStampsFromTheLegacyTimestamp() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", """
|
||||
---
|
||||
schema: 1
|
||||
title: Gone
|
||||
order: 2048
|
||||
modified: 2026-06-06T00:00:00Z
|
||||
deleted: 2026-01-01T00:00:00Z
|
||||
deleted: 2026-02-02T00:00:00Z
|
||||
---
|
||||
""")
|
||||
|
||||
try BoardWriter.migrateTombstonedCard(
|
||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||
inBoard: fixture.root
|
||||
)
|
||||
|
||||
let document = try FrontmatterDocument.parse(fixture.indexText(".trash/\(Ident.card1)"))
|
||||
#expect(document.modified == .valid(Date(timeIntervalSince1970: 1_769_990_400)),
|
||||
"2026-02-02T00:00:00Z — the last `deleted:` occurrence, not migration time")
|
||||
}
|
||||
|
||||
/// The other half of the same sentence: "**and from migration time otherwise**". A stamp that
|
||||
/// cannot be parsed is no evidence of when the card was deleted, so the honest answer is now —
|
||||
/// which lands it among the freshest rather than inventing a date.
|
||||
@Test("An unparseable legacy timestamp falls back to migration time")
|
||||
func migrationFallsBackToNow() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", """
|
||||
---
|
||||
schema: 1
|
||||
title: Gone
|
||||
order: 2048
|
||||
modified: 2020-01-01T00:00:00Z
|
||||
deleted: whenever
|
||||
---
|
||||
""")
|
||||
|
||||
let before = Date()
|
||||
try BoardWriter.migrateTombstonedCard(
|
||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||
inBoard: fixture.root
|
||||
)
|
||||
|
||||
let document = try FrontmatterDocument.parse(fixture.indexText(".trash/\(Ident.card1)"))
|
||||
let stamped = try #require(document.modified.value)
|
||||
#expect(stamped >= before.addingTimeInterval(-1), "migration time, not the old `modified`")
|
||||
// The key still goes — presence is what migrates a card, validity is not (`stillTombstoned`).
|
||||
#expect(!(try fixture.indexText(".trash/\(Ident.card1)").contains("deleted:")))
|
||||
}
|
||||
|
||||
/// The lane half of this migration is **retired** (01 § Deletion, re-ruled 2026-07-29): there is
|
||||
/// no `migrateTombstonedLane` to call, and the loader hands the store no lane work to do — the
|
||||
/// lane simply loads live with the key inert (`LegacyTombstoneDetectionTests`).
|
||||
@@ -618,8 +691,7 @@ struct DeleteLaneToTrashTests {
|
||||
|
||||
let id = try BoardWriter.deleteLaneToTrash(
|
||||
at: fixture.url(Ident.lane1),
|
||||
inBoard: fixture.root,
|
||||
order: -1024
|
||||
inBoard: fixture.root
|
||||
)
|
||||
|
||||
#expect(id == ItemID(rawValue: Ident.lane1), "a delete moves a folder, it does not rename one")
|
||||
@@ -634,8 +706,8 @@ struct DeleteLaneToTrashTests {
|
||||
|
||||
/// The container-changing move stamps both provenance keys (01 § Frontmatter ▸ `modified`'s
|
||||
/// scope, refined 2026-07-30) — the same rule a card's trash move obeys.
|
||||
@Test("The rank is rewritten, modified stamped and modified-by cleared")
|
||||
func theRankRewriteStamps() throws {
|
||||
@Test("`modified` is stamped and `modified-by` cleared, and the rank is left alone")
|
||||
func theArrivalStamps() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
@@ -653,10 +725,10 @@ struct DeleteLaneToTrashTests {
|
||||
|
||||
""")
|
||||
|
||||
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024)
|
||||
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root)
|
||||
|
||||
let document = try FrontmatterDocument.parse(fixture.indexText(".trash/\(Ident.lane1)"))
|
||||
#expect(document.order == .valid(-1024))
|
||||
#expect(document.order == .valid(2048), "the strip rank rides along; no trash rank is minted")
|
||||
#expect(document.modifiedBy.isMissing)
|
||||
#expect(document.modified.value.map { $0 > Date(timeIntervalSince1970: 1_600_000_000) } == true)
|
||||
let text = try fixture.indexText(".trash/\(Ident.lane1)")
|
||||
@@ -664,7 +736,7 @@ struct DeleteLaneToTrashTests {
|
||||
#expect(text.contains("Lane notes."))
|
||||
}
|
||||
|
||||
/// "`kind: lane` … backfilled on touch when absent … the trash move's rank mint included"
|
||||
/// "`kind: lane` … backfilled on touch when absent … the trash move's `modified` stamp included"
|
||||
/// (01 § Deletion). The **empty** lane is the case that needs it: in a flat container it is
|
||||
/// shape-identical to a card, so a derived kind would answer wrongly and the row would come back
|
||||
/// as a card.
|
||||
@@ -676,7 +748,7 @@ struct DeleteLaneToTrashTests {
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Empty\norder: 1024\n---\n")
|
||||
|
||||
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024)
|
||||
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root)
|
||||
|
||||
#expect(try fixture.indexText(".trash/\(Ident.lane1)").contains("kind: lane"))
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
@@ -695,7 +767,7 @@ struct DeleteLaneToTrashTests {
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, "---\nschema: 1\norder: 1024\nkind: lane\n---\n")
|
||||
|
||||
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024)
|
||||
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root)
|
||||
|
||||
let text = try fixture.indexText(".trash/\(Ident.lane1)")
|
||||
#expect(text.components(separatedBy: "kind: lane").count == 2, "written once, not twice")
|
||||
@@ -719,8 +791,7 @@ struct DeleteLaneToTrashTests {
|
||||
writeFailure {
|
||||
try BoardWriter.deleteLaneToTrash(
|
||||
at: fixture.url(target),
|
||||
inBoard: fixture.root,
|
||||
order: -1024
|
||||
inBoard: fixture.root
|
||||
)
|
||||
} != nil
|
||||
)
|
||||
@@ -1038,11 +1109,11 @@ struct TrashKindDiscriminatorTests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The column is one list interleaved by rank (03 § Trash), which the snapshot expresses as two
|
||||
/// arrays carrying the ranks that interleave them — so a consumer merging by `order` gets the
|
||||
/// column, and neither array is "after" the other.
|
||||
@Test("Both kinds carry the ranks that interleave them")
|
||||
func kindsInterleaveByRank() throws {
|
||||
/// The column is one list interleaved by **`modified` descending** (03 § Trash, re-ruled
|
||||
/// 2026-07-31), which the snapshot expresses as two arrays each sorted by the same comparator —
|
||||
/// so `BoardModel.trashEntries`' merge is the column, and neither array is "after" the other.
|
||||
@Test("Both kinds carry the stamps that interleave them")
|
||||
func kindsInterleaveByStamp() throws {
|
||||
let fixture = try TrashFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let newestLane = uuidName()
|
||||
@@ -1050,18 +1121,41 @@ struct TrashKindDiscriminatorTests {
|
||||
let oldestLane = uuidName()
|
||||
|
||||
try fixture.index("", "schema: 1\n")
|
||||
try fixture.index(".trash/\(oldestLane)", "schema: 1\norder: 3072\nkind: lane\n")
|
||||
try fixture.index(".trash/\(middleCard)", "schema: 1\norder: 2048\n")
|
||||
try fixture.index(".trash/\(newestLane)", "schema: 1\norder: 1024\nkind: lane\n")
|
||||
try fixture.index(".trash/\(oldestLane)", "schema: 1\norder: 1024\nmodified: 2026-05-01T09:00:00Z\nkind: lane\n")
|
||||
try fixture.index(".trash/\(middleCard)", "schema: 1\norder: 2048\nmodified: 2026-05-03T09:00:00Z\n")
|
||||
try fixture.index(".trash/\(newestLane)", "schema: 1\norder: 3072\nmodified: 2026-05-05T09:00:00Z\nkind: lane\n")
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
|
||||
#expect(result.model.trashedLanes.map(\.id.rawValue) == [newestLane, oldestLane])
|
||||
#expect(result.model.trash.map(\.id.rawValue) == [middleCard])
|
||||
let column = (result.model.trash.map { (order: $0.order, id: $0.id) }
|
||||
+ result.model.trashedLanes.map { (order: $0.order, id: $0.id) })
|
||||
.sorted { $0.order < $1.order }
|
||||
.map(\.id.rawValue)
|
||||
#expect(column == [newestLane, middleCard, oldestLane])
|
||||
// The one merge, which every consumer of "the row below this one" reads.
|
||||
#expect(result.model.trashEntries.map(\.id.rawValue) == [newestLane, middleCard, oldestLane])
|
||||
}
|
||||
|
||||
/// The comparator's tail, and its undated rung — neither is stated anywhere else, and both are
|
||||
/// what keeps a hand-made or foreign-moved entry from deciding the column's top
|
||||
/// (01 § Deletion: "Ties break by title (case-insensitive), then folder name").
|
||||
@Test("Equal stamps fall to title then folder name, and an undated entry sorts last")
|
||||
func theDeterministicTail() throws {
|
||||
let fixture = try TrashFixture()
|
||||
defer { fixture.tearDown() }
|
||||
// Folder names in a fixed order, so the last rung is observable rather than incidental.
|
||||
let alpha = "11111111-1111-4111-8111-111111111111"
|
||||
let beta = "22222222-2222-4222-8222-222222222222"
|
||||
let gamma = "33333333-3333-4333-8333-333333333333"
|
||||
let undated = "44444444-4444-4444-8444-444444444444"
|
||||
|
||||
try fixture.index("", "schema: 1\n")
|
||||
// Same instant, different titles: "apple" before "Banana" — case-insensitively.
|
||||
try fixture.index(".trash/\(beta)", "schema: 1\norder: 1024\ntitle: Banana\nmodified: 2026-05-05T09:00:00Z\n")
|
||||
try fixture.index(".trash/\(gamma)", "schema: 1\norder: 1024\ntitle: apple\nmodified: 2026-05-05T09:00:00Z\n")
|
||||
// Same instant *and* the same title: the folder name is the last word.
|
||||
try fixture.index(".trash/\(alpha)", "schema: 1\norder: 1024\ntitle: apple\nmodified: 2026-05-05T09:00:00Z\n")
|
||||
// No stamp at all — a foreign mover that skipped the restamp sorts below every dated row.
|
||||
try fixture.index(".trash/\(undated)", "schema: 1\norder: 1024\ntitle: Zulu\n")
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
#expect(result.model.trash.map(\.id.rawValue) == [alpha, gamma, beta, undated])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,12 +23,16 @@ private enum More {
|
||||
|
||||
/// A lane already sitting in `<root>/.trash/` — the opaque unit, `kind: lane` being the only thing
|
||||
/// that tells it from a card in the flat container (01-storage-format.md § Deletion).
|
||||
private func trashedLane(order: String, title: String) -> String {
|
||||
///
|
||||
/// `modified` is the row's **position** since 2026-07-31 (the trash sorts by it, descending), so it
|
||||
/// is a fixture parameter rather than the afterthought it was while ranks did the ordering.
|
||||
private func trashedLane(order: String, title: String, modified: String = "2026-05-01T09:00:00Z") -> String {
|
||||
"""
|
||||
---
|
||||
schema: 1
|
||||
title: \(title)
|
||||
order: \(order)
|
||||
modified: \(modified)
|
||||
kind: lane
|
||||
project: lanework # agent overlay
|
||||
---
|
||||
@@ -39,7 +43,7 @@ private func trashedLane(order: String, title: String) -> String {
|
||||
|
||||
/// A card already sitting in `<root>/.trash/` — an ordinary card in a special place, with an unknown
|
||||
/// key so the verbatim-preservation claims have something to preserve.
|
||||
private func trashResident(order: String, title: String) -> String {
|
||||
private func trashResident(order: String, title: String, modified: String = "2026-05-01T09:00:00Z") -> String {
|
||||
"""
|
||||
---
|
||||
schema: 1
|
||||
@@ -47,6 +51,7 @@ private func trashResident(order: String, title: String) -> String {
|
||||
order: \(order)
|
||||
project: lanework # agent overlay
|
||||
created: 2026-01-01T09:00:00Z
|
||||
modified: \(modified)
|
||||
---
|
||||
\(title) body.
|
||||
|
||||
@@ -83,8 +88,14 @@ private func makeBoard() throws -> WriterFixture {
|
||||
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
|
||||
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
|
||||
try fixture.item("\(Ident.lane3)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
|
||||
try fixture.item(".trash/\(Ident.indexless)", trashResident(order: "1024", title: "Trashed"))
|
||||
try fixture.item(".trash/\(More.newer)", trashResident(order: "512", title: "Newer"))
|
||||
// Their *stamps* are what orders them now — "Newer" is the newer one, and its `order` is the
|
||||
// lane rank it carried in, deliberately disagreeing with the column position.
|
||||
try fixture.item(
|
||||
".trash/\(Ident.indexless)",
|
||||
trashResident(order: "1024", title: "Trashed", modified: "2026-05-01T09:00:00Z"))
|
||||
try fixture.item(
|
||||
".trash/\(More.newer)",
|
||||
trashResident(order: "512", title: "Newer", modified: "2026-05-02T09:00:00Z"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
@@ -172,34 +183,46 @@ struct DeleteCardTests {
|
||||
#expect(try document(fixture, ".trash/\(Ident.card1)").modified.value != nil)
|
||||
}
|
||||
|
||||
@Test("Entry is at the top: the rank is minted above the current topmost")
|
||||
/// **Entry is at the top, and the stamp is what puts it there** (03 ▸ Trash, re-ruled
|
||||
/// 2026-07-31): no rank is minted, so the card's `order` arrives exactly as it left its lane and
|
||||
/// the fresh `modified` does the positioning.
|
||||
@Test("Entry is at the top: the fresh stamp outranks every resident, and `order` is untouched")
|
||||
func entryIsAtTheTop() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
// The trash's current top is `newer` at 512.
|
||||
// The trash's current top is `newer`, by its stamp — its `order` (512) is the smaller of the
|
||||
// two, which under the retired rule would have been the reason and now is a coincidence.
|
||||
#expect(try loaded(fixture).trash.map(\.id) == [newer, trashed])
|
||||
|
||||
store.delete([card1])
|
||||
|
||||
let landed = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value)
|
||||
#expect(landed < 512, "03 ▸ Trash: every arrival mints an `order` rank above the current top")
|
||||
let arrived = try document(fixture, ".trash/\(Ident.card1)")
|
||||
#expect(arrived.order == .valid(1024), "the lane rank rides along; nothing is minted")
|
||||
let stamp = try #require(arrived.modified.value)
|
||||
let residentStamp = try #require(try document(fixture, ".trash/\(More.newer)").modified.value)
|
||||
#expect(stamp > residentStamp)
|
||||
#expect(try loaded(fixture).trash.map(\.id) == [card1, newer, trashed],
|
||||
"newest-first falls out of ordinary ranks — no timestamp sort")
|
||||
"newest-first falls out of the stamp — no rank anywhere in the container")
|
||||
}
|
||||
|
||||
@Test("A multi-card delete is one bracket, each arrival above the one before it")
|
||||
func batchLandsNewestOnTop() throws {
|
||||
/// **A batch shares one instant, so the deterministic tail orders it** (01 § Deletion: "Ties
|
||||
/// break by title (case-insensitive), then folder name — the deterministic tail"). `modified`
|
||||
/// serializes at whole-second granularity, so two cards deleted in one bracket carry the same
|
||||
/// stamp by construction; the run still sorts above every resident, and *within* the run the
|
||||
/// titles decide — "First" before "Second". That is the tail doing exactly its job, and it is the
|
||||
/// honest reading of two deletions that really did happen at the same time.
|
||||
@Test("A multi-card delete is one bracket; the run lands on top and the tail orders it")
|
||||
func batchLandsOnTopOrderedByTheTail() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.delete([card1, card2])
|
||||
|
||||
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)
|
||||
#expect(try loaded(fixture).trash.map(\.id) == [card2, card1, newer, trashed])
|
||||
#expect(try document(fixture, ".trash/\(Ident.card1)").order == .valid(1024))
|
||||
#expect(try document(fixture, ".trash/\(Ident.card2)").order == .valid(2048))
|
||||
#expect(try loaded(fixture).trash.map(\.id) == [card1, card2, newer, trashed])
|
||||
}
|
||||
|
||||
@Test("The selection moves to the successor sibling, immediately")
|
||||
@@ -312,27 +335,30 @@ struct DeleteLaneTests {
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
/// "Every arrival lands at the trash's topmost position … regardless of kind" (03 § Trash): the
|
||||
/// ladder the rank is minted against is the whole container, cards and lane rows alike.
|
||||
/// "Every arrival lands at the trash's topmost position … regardless of kind" (03 § Trash) —
|
||||
/// and since 2026-07-31 that is the **merged `modified` order** doing it, over both kinds at
|
||||
/// once, rather than a ladder the store minted against.
|
||||
@Test("The lane lands at the top of the trash, above every existing entry")
|
||||
func laneLandsOnTop() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let topBefore = try #require(loaded(fixture).trash.map(\.order).min())
|
||||
|
||||
store.delete([lane3])
|
||||
let landed = try #require(loaded(fixture).trashedLanes.first?.order)
|
||||
#expect(landed < topBefore)
|
||||
#expect(try loaded(fixture).trashEntries.first?.id == lane3, "topmost row of the whole column")
|
||||
// The lane's strip rank rode along untouched, which is what its restore reads.
|
||||
#expect(try document(fixture, ".trash/\(Ident.lane3)").order == .valid(3072))
|
||||
|
||||
// And the next card delete mints above *that* — the lane row is in the ladder the store
|
||||
// mints against, which is the whole container rather than one array of it. The reload is
|
||||
// what puts the new row in the snapshot; without it the store is still holding the
|
||||
// pre-delete picture, as it is for two of any deletes in a row.
|
||||
// And a *later* card delete lands above it — the merged order is the whole container, so a
|
||||
// row of the other kind is exactly as sortable as one of its own. The reload is what puts
|
||||
// the new row in the snapshot; without it the store is still holding the pre-delete picture,
|
||||
// as it is for two of any deletes in a row. The one-second sleep is load-bearing: `modified`
|
||||
// serializes at whole-second granularity, so without it the two deletes share an instant and
|
||||
// the deterministic title tail — not recency — would decide.
|
||||
await reload(store)
|
||||
try await Task.sleep(for: .seconds(1.1))
|
||||
store.delete([card1])
|
||||
let card = try #require(loaded(fixture).trash.first { $0.id == card1 }?.order)
|
||||
#expect(card < landed)
|
||||
#expect(try loaded(fixture).trashEntries.map(\.id).prefix(2) == [card1, lane3])
|
||||
}
|
||||
|
||||
/// No dialog: "the move is recoverable, so nothing needs confirming" (03 § Trash).
|
||||
@@ -544,11 +570,19 @@ private func makeTrashedLaneBoard() throws -> 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(".trash/\(More.newer)", trashResident(order: "512", title: "Newer"))
|
||||
try fixture.item(".trash/\(Ident.lane2)", trashedLane(order: "768", title: "Doing"))
|
||||
// The column reads newest-first by `modified`: Newer, Doing (the lane row), Trashed — the two
|
||||
// kinds interleaved by the one stamp, which is the merged order this suite navigates by.
|
||||
try fixture.item(
|
||||
".trash/\(More.newer)",
|
||||
trashResident(order: "512", title: "Newer", modified: "2026-05-03T09:00:00Z"))
|
||||
try fixture.item(
|
||||
".trash/\(Ident.lane2)",
|
||||
trashedLane(order: "768", title: "Doing", modified: "2026-05-02T09:00:00Z"))
|
||||
try fixture.item(".trash/\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
|
||||
try fixture.item(".trash/\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Fourth"))
|
||||
try fixture.item(".trash/\(Ident.indexless)", trashResident(order: "1024", title: "Trashed"))
|
||||
try fixture.item(
|
||||
".trash/\(Ident.indexless)",
|
||||
trashResident(order: "1024", title: "Trashed", modified: "2026-05-01T09:00:00Z"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
@@ -618,10 +652,9 @@ struct PurgeTrashedLaneTests {
|
||||
#expect(!fixture.exists(".trash/\(Ident.lane2)"))
|
||||
}
|
||||
|
||||
/// **The interim successor** (`SelectionGrammar.successor`'s trash branch): 04 settles navigation
|
||||
/// and extension for the column's two kinds but not the successor, so it follows *navigation* —
|
||||
/// the next row down whatever its kind — rather than stranding the selection. Gap card
|
||||
/// 7b5cbc90 tracks the ruling; this test is the interim's marker as much as its cover.
|
||||
/// **The successor is kind-blind** (04-interactions.md ▸ The map, ruled 2026-07-31, ratifying
|
||||
/// what stood here as an interim): "the next row of either kind, in the same all-rows order plain
|
||||
/// arrows walk … repeated ⌘⌫ empties a mixed trash without dead-ends".
|
||||
@Test("The successor after purging a lane row is the next row down, kind notwithstanding")
|
||||
func successorCrossesKinds() throws {
|
||||
let fixture = try makeTrashedLaneBoard()
|
||||
@@ -764,21 +797,30 @@ struct StoreTombstoneMigrationTests {
|
||||
#expect(try loaded(fixture).lanes.map(\.id.rawValue).contains(Ident.lane2))
|
||||
}
|
||||
|
||||
@Test("Cards migrate oldest-first, so the newest deletion ends up on top")
|
||||
func migrationOrderIsOldestFirst() throws {
|
||||
/// **The stamps do the ordering, not the batch** (01 § Deletion, re-ruled 2026-07-31): each
|
||||
/// migrated card takes its own `deleted:` timestamp as its `modified`, so the board's real
|
||||
/// deletion order survives whatever sequence the heal happens to run in.
|
||||
@Test("Migrated cards keep their real deletion order — newest deletion on top")
|
||||
func migrationKeepsRealDeletionOrder() throws {
|
||||
let fixture = try makeLegacyBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.migrateLegacyTombstones()
|
||||
|
||||
// Every arrival mints above the current top, so migrating oldest-first reproduces the
|
||||
// newest-first column the tombstone model's timestamp sort used to render.
|
||||
// 03-05 above 03-01, straight off the retired key — not off the order the batch ran in.
|
||||
#expect(try loaded(fixture).trash.compactMap(\.title.value) == ["Newer", "Older"])
|
||||
let newerStamp = try #require(try document(fixture, ".trash/\(Ident.card3)").modified.value)
|
||||
#expect(newerStamp == Date(timeIntervalSince1970: 1_772_701_200),
|
||||
"2026-03-05T09:00:00Z — the legacy stamp, carried over verbatim")
|
||||
}
|
||||
|
||||
@Test("The order is deterministic when the stamps are missing or unparseable")
|
||||
func undatedSortsOldest() throws {
|
||||
/// An **unparseable** legacy stamp is no evidence of when the card was deleted, so the migration
|
||||
/// stamps it at migration time (01 § Deletion: "and from migration time otherwise") — which
|
||||
/// lands it among the freshest rather than inventing a date for it. Deterministic either way,
|
||||
/// which is what this pins.
|
||||
@Test("A card whose legacy stamp cannot be read is migrated at migration time")
|
||||
func unparseableStampMigratesAtNow() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
@@ -795,9 +837,8 @@ struct StoreTombstoneMigrationTests {
|
||||
|
||||
store.migrateLegacyTombstones()
|
||||
|
||||
// "A corrupt stamp must not outrank fresh deletions for the trash's most prominent rows":
|
||||
// undated sorts oldest, so it migrates first and ends up *below* the dated one.
|
||||
#expect(try loaded(fixture).trash.compactMap(\.title.value) == ["Dated", "Corrupt"])
|
||||
// Migration time is today, which is newer than any legacy stamp a real board carries.
|
||||
#expect(try loaded(fixture).trash.compactMap(\.title.value) == ["Corrupt", "Dated"])
|
||||
}
|
||||
|
||||
@Test("A board-level deleted: is never migrated — it is meaningless, ignored and logged")
|
||||
|
||||
@@ -627,24 +627,30 @@ struct TrashUndoTests {
|
||||
#expect(!fixture.exists(card1Path))
|
||||
}
|
||||
|
||||
@Test("The redo files the card under the rank the delete minted, not a fresh one")
|
||||
func redoUsesTheCapturedTrashRank() throws {
|
||||
/// **There is no rank to capture or replay** (01-storage-format.md § Deletion, re-ruled
|
||||
/// 2026-07-31): a delete is a folder move plus a `modified` stamp, so the card's `order` rides
|
||||
/// along untouched through delete, undo and redo alike, and the redo is just the forward write
|
||||
/// run again.
|
||||
@Test("The redo re-runs the delete, and `order` is untouched at every leg")
|
||||
func redoRerunsTheDelete() 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)")
|
||||
#expect(try document(fixture, ".trash/\(Ident.card1)").order.value == 1024,
|
||||
"the lane rank rode along; nothing was minted")
|
||||
|
||||
history.undo()
|
||||
#expect(try document(fixture, card1Path).order.value == 1024)
|
||||
history.redo()
|
||||
|
||||
#expect(try document(fixture, ".trash/\(Ident.card1)").order.value == minted,
|
||||
"the redo replays the write's own captured rank")
|
||||
#expect(try document(fixture, ".trash/\(Ident.card1)").order.value == 1024)
|
||||
// And the redone delete restamps, which is what puts it back on top of the column.
|
||||
#expect(try loadedTrash(fixture).map(\.id).first == card1)
|
||||
}
|
||||
|
||||
@Test("A multi-card delete is one step with a plural title, and lands newest-last on top")
|
||||
@Test("A multi-card delete is one step with a plural title, and the run lands on top")
|
||||
func batchDeleteIsOneStep() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
@@ -653,9 +659,11 @@ 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")
|
||||
// Both cards keep the ranks they had in their lane — nothing is minted, at either end of the
|
||||
// run — and both land above the board's existing resident by their fresh stamps.
|
||||
#expect(try document(fixture, ".trash/\(Ident.card1)").order.value == 1024)
|
||||
#expect(try document(fixture, ".trash/\(Ident.card2)").order.value == 2048)
|
||||
#expect(try loadedTrash(fixture).map(\.id).suffix(1) == [trashed])
|
||||
|
||||
history.undo()
|
||||
#expect(fixture.exists(card1Path))
|
||||
@@ -683,9 +691,8 @@ struct TrashUndoTests {
|
||||
#expect(try loadedTrash(fixture).map(\.id) == [trashed], "its cards are not trash cards")
|
||||
#expect(try loadedTrashedLanes(fixture).map(\.id) == [lane2])
|
||||
#expect(history.undoActionName == "Delete Lane")
|
||||
let trashRank = try #require(try document(fixture, ".trash/\(Ident.lane2)").order.value)
|
||||
let resident = try #require(try document(fixture, trashedPath).order.value)
|
||||
#expect(trashRank < resident, "entry is at the top — a rank above the current topmost")
|
||||
#expect(try document(fixture, ".trash/\(Ident.lane2)").order.value == laneRank,
|
||||
"the strip rank rides along; no trash rank is minted")
|
||||
|
||||
history.undo()
|
||||
|
||||
@@ -699,8 +706,8 @@ struct TrashUndoTests {
|
||||
|
||||
history.redo()
|
||||
#expect(fixture.exists(".trash/\(Ident.lane2)"))
|
||||
#expect(try document(fixture, ".trash/\(Ident.lane2)").order.value == trashRank,
|
||||
"the redo replays the write's own captured rank")
|
||||
#expect(try document(fixture, ".trash/\(Ident.lane2)").order.value == laneRank,
|
||||
"the redo re-runs the forward write, which has no rank to replay")
|
||||
}
|
||||
|
||||
@Test("A multi-lane delete is one step with a plural title")
|
||||
@@ -712,9 +719,9 @@ struct TrashUndoTests {
|
||||
store.delete([lane1, lane2])
|
||||
|
||||
#expect(history.undoActionName == "Delete 2 Lanes")
|
||||
let first = try #require(try document(fixture, ".trash/\(Ident.lane1)").order.value)
|
||||
let second = try #require(try document(fixture, ".trash/\(Ident.lane2)").order.value)
|
||||
#expect(second < first, "each arrival in the run mints a rank above the one before it")
|
||||
// Their strip ranks, untouched — a lane delete mints nothing either.
|
||||
#expect(try document(fixture, ".trash/\(Ident.lane1)").order.value == 1024)
|
||||
#expect(try document(fixture, ".trash/\(Ident.lane2)").order.value == 2048)
|
||||
|
||||
history.undo()
|
||||
#expect(fixture.exists(Ident.lane1))
|
||||
@@ -728,7 +735,9 @@ struct TrashUndoTests {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
let trashRank = try #require(try document(fixture, trashedPath).order.value)
|
||||
// The `order` the row was *carrying* while trashed — its old lane rank, which the trash move
|
||||
// never rewrote and which this restore is about to overwrite.
|
||||
let carriedOrder = try #require(try document(fixture, trashedPath).order.value)
|
||||
|
||||
store.moveCards([trashed], toLane: lane2, at: 0)
|
||||
|
||||
@@ -740,8 +749,8 @@ struct TrashUndoTests {
|
||||
history.undo()
|
||||
#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")
|
||||
#expect(try document(fixture, trashedPath).order.value == carriedOrder,
|
||||
"the undo puts back the rank the restore overwrote")
|
||||
|
||||
history.redo()
|
||||
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
|
||||
|
||||
@@ -158,7 +158,7 @@ struct WriteFidelityMinimalTouchTests {
|
||||
// included, which is the point of this harness.
|
||||
try step("delete", targeting: [], departed: ["\(Ident.lane2)/\(Ident.card4)"]) {
|
||||
try BoardWriter.deleteCardToTrash(
|
||||
at: fixture.url("\(Ident.lane2)/\(Ident.card4)"), inBoard: fixture.root, order: 1024
|
||||
at: fixture.url("\(Ident.lane2)/\(Ident.card4)"), inBoard: fixture.root
|
||||
)
|
||||
}
|
||||
// The restore is an ordinary move out — "there is no restore-specific machinery and no Put
|
||||
@@ -189,7 +189,7 @@ struct WriteFidelityMinimalTouchTests {
|
||||
departed: [Ident.lane2, "\(Ident.lane2)/\(Ident.card3)", "\(Ident.lane2)/\(Ident.card4)"]
|
||||
) {
|
||||
try BoardWriter.deleteLaneToTrash(
|
||||
at: fixture.url(Ident.lane2), inBoard: fixture.root, order: 512
|
||||
at: fixture.url(Ident.lane2), inBoard: fixture.root
|
||||
)
|
||||
}
|
||||
try step("restore lane", targeting: []) {
|
||||
@@ -370,7 +370,7 @@ struct WriteFidelityCompositeTests {
|
||||
// resettled 2026-07-28), so its unknown keys, its comment and its body must ride along
|
||||
// untouched through both legs.
|
||||
try BoardWriter.deleteCardToTrash(
|
||||
at: lane1Folder.appendingPathComponent(card2.rawValue), inBoard: root, order: 1024
|
||||
at: lane1Folder.appendingPathComponent(card2.rawValue), inBoard: root
|
||||
)
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: BoardWriter.trashFolder(inBoard: root).appendingPathComponent(card2.rawValue),
|
||||
@@ -548,7 +548,7 @@ struct WriteFidelityStampingTests {
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
try BoardWriter.deleteCardToTrash(
|
||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), inBoard: fixture.root, order: 1024
|
||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), inBoard: fixture.root
|
||||
)
|
||||
let trashed = try stamps(fixture, ".trash/\(Ident.card1)")
|
||||
#expect(trashed.modified != Self.priorModified, "into the trash is a container change")
|
||||
@@ -578,7 +578,7 @@ struct WriteFidelityStampingTests {
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
try BoardWriter.deleteLaneToTrash(
|
||||
at: fixture.url(Ident.lane1), inBoard: fixture.root, order: 1024
|
||||
at: fixture.url(Ident.lane1), inBoard: fixture.root
|
||||
)
|
||||
|
||||
let lane = try stamps(fixture, ".trash/\(Ident.lane1)")
|
||||
|
||||
Reference in New Issue
Block a user