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