Files
lanework/KanbanTests/TrashStorageTests.swift
T
rzen 274ccd9ff5 Realign code with the 2026-07-31 findings-resolution rulings
The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:

- Restore subjects compose the inverse, never nest: crossing "Undo: S"
  emits "Redo: S" and vice versa; parity, not stack depth, reads a
  legacy double prefix (GitHistoryProvider.restoreSubject).
- Git-operation failures join the one-shot failure banner tier:
  BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error
  tone at failure rank merged with write one-shots by recency; the
  postLoss compromise is retired at both AppModel wirings.
- order/schema optional below the board root: append-at-end reading
  (ordered siblings first, folder-name tie-break among the order-less),
  schema reads 1, both coerce-tier logged; the root keeps its
  requirements. Ranks.resolvedOrders materializes finite ranks so
  models and placement math stay untouched; first Writer rewrite
  stamps a real rank on touch, placement against an order-less sibling
  stamps that sibling inline in the same bracket. Agent guide v10
  teaches optional keys and zero-read filing. Hostile-YAML order
  shapes become coercion tests; Fixtures/Valid/optional-keys.kanban
  replaces the four retired Malformed boards.
- .gitignore is the relocation-heal noise gate: GitignoreRules pure
  matcher (standard semantics, board-root file only), loader consults
  it once per walk so matched loose files keep the stray posture;
  seeded (.DS_Store + .*.lanework-*) at board creation and template
  instantiation, healed in when missing at open — repo-nested
  included; empty file honored, existing files never edited; the
  committer's obedience via libgit2 status is pinned by test.
- Comments crash-residue sweep gates on step ownership: HistoryStep
  derives backing from its own undo expectations, backedContent unions
  both stacks, the sweep purges per-entry only what no live step owns.
- Skip-purge decoupled (16ef377): a stale-skipped coarse step strands
  whole in NativeHistoryProvider.strandedSteps — still backing, retired
  only at session end; clean exits purge as before.
- Coarse close step named "Changes to '<card>'"; the fine body-edit
  wording never leaks onto the board menu.
- Branch-switch settle clears every open card window's fine stack on
  Save All and Discard alike; the empty fold registers no coarse step.
- Close flush awaits its covering snapshot (quiesce + one generation
  bump, 1s bound), and an explicit flush now queues behind an
  in-flight one instead of skipping — the audit-caught interleaving
  could lose a close flush permanently when the debounce fired inside
  the close sequence; regression tests force both races.
- Commit comment bullets sort chronologically by created, not UUID.
- The production-unwired CardBodyEditSession.editSessionDidChange seam
  is deleted with its seam-only tests.
- Composition-root pins: beginSession composes the committer with the
  store's own EchoLedger and binds the announcer (the miswire class).
- Deliberate 06 conformance pass over every 2026-07-31-tagged
  sentence: fixed Change-custom-key subjects (the retired named
  generic was the only producer), the unbuilt Replace attachment
  vocabulary, heal commits now authored Lanework Integrity, the config
  reader scopes identity to plain [user] sections, add-git re-runs
  detection at create (a stale mode-none could initialize inside the
  user's repo), and add-git failures answer at the form or the banner.
  Structural residue filed on the Redesign board.

2554 tests / 439 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 07:43:45 -04:00

1165 lines
54 KiB
Swift

import Foundation
import Testing
@testable import Kanban
/// The storage layer of the **materialized trash** (01-storage-format.md § Deletion, resettled
/// 2026-07-28; 03-board-ui.md § Trash): `<board-root>/.trash/` as a second card container beside
/// the lanes, the Writer primitives that move folders into and out of it, and the read-only
/// detection channel that migrates the retired `deleted:` key away.
///
/// Everything here writes and reads raw bytes on disk rather than going through the store, so
/// every assertion is about what is actually in the tree.
// MARK: - Loader fixture
/// A board tree under a temp directory — `BoardLoaderTests`' own builder plus the two shapes this
/// suite needs (a `.trash/` entry, and a plain folder anywhere).
private struct TrashFixture {
let root: URL
init() throws {
root = FileManager.default.temporaryDirectory
.appendingPathComponent("TrashStorageTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
func tearDown() {
try? FileManager.default.removeItem(at: root)
}
@discardableResult
func index(_ relativePath: String, _ frontmatter: String, body: String = "") throws -> URL {
let folder = relativePath.isEmpty ? root : root.appendingPathComponent(relativePath, isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let text = "---\n\(frontmatter)---\n\(body)"
try text.write(to: folder.appendingPathComponent("index.md"), atomically: true, encoding: .utf8)
return folder
}
@discardableResult
func folder(_ relativePath: String) throws -> URL {
let url = root.appendingPathComponent(relativePath, isDirectory: true)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
func file(_ relativePath: String, _ contents: String = "stray") throws {
let url = root.appendingPathComponent(relativePath)
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try contents.write(to: url, atomically: true, encoding: .utf8)
}
}
private func uuidName() -> String { UUID().uuidString.lowercased() }
// MARK: - The trash container, read
@Suite("BoardLoader ▸ the .trash container")
struct TrashContainerLoadTests {
/// 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() }
let lane = uuidName()
let newest = uuidName()
let middle = uuidName()
let oldest = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\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])
#expect(result.model.lanes[0].cards.isEmpty)
#expect(result.warnings.isEmpty)
#expect(result.legacyTombstones.isEmpty)
}
@Test("A board with no .trash folder has an empty trash, and says nothing about it")
func absentTrashIsEmpty() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
try fixture.index("", "schema: 1\n")
try fixture.index(uuidName(), "schema: 1\norder: 1024\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trash.isEmpty)
#expect(result.warnings.isEmpty)
}
@Test("An empty .trash folder is an empty trash, and is never a stray at board root")
func emptyTrashIsNotAStray() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
try fixture.index("", "schema: 1\n")
try fixture.folder(".trash")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trash.isEmpty)
// The claimed-name rule: `.trash` never earns the stray warning a `notes/` folder would.
#expect(result.warnings.isEmpty)
}
/// Stray tolerance inside the container is the same tolerance every other container gets.
@Test("Strays inside .trash are tolerated with the ordinary warnings")
func straysInsideTrash() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let card = uuidName()
let indexless = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(card)", "schema: 1\norder: 1024\n")
try fixture.folder(".trash/\(indexless)")
try fixture.folder(".trash/notes")
try fixture.file(".trash/loose.txt")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trash.map(\.id.rawValue) == [card])
#expect(result.warnings.contains(.missingIndex(path: ".trash/\(indexless)")))
#expect(result.warnings.contains(.nonUUIDFolderIgnored(path: ".trash/notes")))
// A stray *file* is silent everywhere, here included.
#expect(result.warnings.count == 2)
}
/// The walk stops at a trash entry exactly as it stops at a card under a lane, so a UUID-shaped
/// folder *inside* one is never a level. What that folder makes of its parent changed with the
/// lanes-in-trash ruling: a kindless entry holding identity-shaped children with their own
/// `index.md` reads as a **lane** by shape (01 § Deletion), one opaque row whose children are
/// counted rather than surfaced — invisible, preserved, and not warned about.
@Test("A lane-shaped nesting inside .trash reads as one opaque lane row, its children counted")
func laneShapedNestingInsideTrash() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let outer = uuidName()
let nested = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(outer)", "schema: 1\norder: 1024\ntitle: Outer\n")
try fixture.index(".trash/\(outer)/\(nested)", "schema: 1\norder: 1024\ntitle: Nested\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trashedLanes.map(\.id.rawValue) == [outer])
#expect(result.model.trashedLanes.first?.heldCards == 1)
#expect(result.model.trash.isEmpty)
#expect(result.warnings.isEmpty)
// Nowhere in the snapshot, and still on disk.
#expect(!result.model.lanes.contains { $0.cards.contains { $0.id.rawValue == nested } })
#expect(FileManager.default.fileExists(
atPath: fixture.root.appendingPathComponent(".trash/\(outer)/\(nested)/index.md").path
))
}
@Test("A whole lane folder dropped into .trash is skipped, not descended into")
func indexlessLaneFolderInsideTrash() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let lane = uuidName()
let card = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.folder(".trash/\(lane)")
try fixture.index(".trash/\(lane)/\(card)", "schema: 1\norder: 1024\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trash.isEmpty)
#expect(result.warnings == [.missingIndex(path: ".trash/\(lane)")])
}
/// The card parse is *one* parse: a trashed card gets the same attachment listing, the same
/// verbatim document, and the same identity leniency as a card under a lane.
@Test("A trash card is parsed exactly like a lane card — attachments, body, spelling")
func trashCardIsAnOrdinaryCard() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let card = "AABBCCDD-1111-7111-8111-111111111111"
try fixture.index("", "schema: 1\n")
try fixture.index(
".trash/\(card)",
"schema: 1\norder: 1024\ntitle: Trashed\nproject: lanework\n",
body: "Body *survives*.\n"
)
try fixture.file(".trash/\(card)/attachments/shot 10.png", "png")
try fixture.file(".trash/\(card)/attachments/shot 2.png", "png")
let result = try BoardLoader.load(boardRoot: fixture.root)
let trashed = try #require(result.model.trash.first)
// Uppercase and a v7 nibble: shape-only identity holds in the trash too, spelling preserved.
#expect(trashed.id.rawValue == card)
#expect(trashed.body == "Body *survives*.\n")
#expect(trashed.attachments == ["shot 2.png", "shot 10.png"])
#expect(trashed.document.unknownFields.map(\.key) == ["project"])
}
/// **The `order` reading is a property of the entry, not of the container it sits in**
/// (01-storage-format.md § Ordering, re-ruled 2026-07-31): an unusable rank in `.trash/` is the
/// same coercion it is on the live board — append-at-end over the container's own entries, with
/// the text as written recorded. It decides nothing here anyway, since the trash sorts by
/// `modified`; the rank is what the entry carries back out on a restore.
@Test("An unusable order inside .trash coerces, like anywhere else")
func malformedOrderInTrashCoerces() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let card = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(card)", "schema: 1\norder: soon\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trash.map(\.id.rawValue) == [card])
#expect(result.model.trash[0].order == 1024)
#expect(result.coercedFrontmatter.map(\.path) == [".trash/\(card)/index.md"])
#expect(result.coercedFrontmatter[0].fields == [CoercedField(key: "order", raw: "soon")])
}
/// Symlinks are never traversed — a symlinked container would render bytes living outside the
/// board that FSEvents never reports.
@Test("A symlinked .trash is treated as an empty trash, never followed")
func symlinkedTrashIsNotFollowed() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let elsewhere = try fixture.folder("elsewhere")
try fixture.index("elsewhere/\(uuidName())", "schema: 1\norder: 1024\n")
try fixture.index("", "schema: 1\n")
try FileManager.default.createSymbolicLink(
at: fixture.root.appendingPathComponent(".trash"),
withDestinationURL: elsewhere
)
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trash.isEmpty)
}
}
// MARK: - The migration channel
@Suite("BoardLoader ▸ legacy tombstone detection")
struct LegacyTombstoneDetectionTests {
/// The intermediate this phase deliberately chose: the key is *reported* for migration and the
/// item still loads through the retiring tombstone path, so nothing vanishes from view before
/// its folder has actually moved (the fix is deferred under any read-only lock).
@Test("A tombstoned card is reported for migration and still loads flagged under its lane")
func tombstonedCardIsReported() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let lane = uuidName()
let card = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index(
"\(lane)/\(card)",
"schema: 1\norder: 1024\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n"
)
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.legacyTombstones == [
LegacyTombstone(laneID: ItemID(rawValue: lane), cardID: ItemID(rawValue: card), title: "Gone")
])
// Still rendered by the retiring path — read-only detection has moved nothing yet.
#expect(result.model.lanes[0].cards.map(\.isDeleted) == [true])
#expect(result.model.trash.isEmpty)
}
/// "A lane carrying `deleted:` simply loads **live** with the key ignored — no migration
/// machinery, no key-strip write, no notice" (01 § Deletion, lane clause re-ruled 2026-07-29):
/// the tolerate tier's whole verdict, and the bytes are never touched.
@Test("A tombstoned lane loads live, is warned rather than migrated, and keeps its bytes")
func tombstonedLaneIsTolerated() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let lane = uuidName()
let text = "schema: 1\norder: 1024\ntitle: Old lane\ndeleted: 2026-01-01T00:00:00Z\n"
try fixture.index("", "schema: 1\n")
try fixture.index(lane, text)
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.legacyTombstones.isEmpty, "the lane half of the migration is retired")
#expect(result.warnings == [.laneLevelDeletedIgnored(path: lane)])
#expect(result.model.lanes.map(\.title.value) == ["Old lane"], "it loads live")
#expect(result.model.lanes[0].isDeleted, "the key is still on disk — it just decides nothing")
#expect(
try String(contentsOf: fixture.root.appendingPathComponent("\(lane)/index.md"), encoding: .utf8)
== "---\n" + text + "---\n",
"preserved verbatim, like any unhandled key"
)
}
/// Presence, not validity — the same rule the flag has always read by, so a broken timestamp
/// still migrates rather than being left behind as the one key the pivot forgot. The lane beside
/// it takes the tolerate posture whatever its value's condition.
@Test("A malformed deleted value is still migration input on a card, still inert on a lane")
func malformedDeletedIsStillReported() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let lane = uuidName()
let card = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\ndeleted: yesterday\n")
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\ndeleted: yesterday\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.legacyTombstones.map(\.cardID) == [ItemID(rawValue: card)])
#expect(result.warnings == [.laneLevelDeletedIgnored(path: lane)])
}
/// "A `deleted:` key at board level remains meaningless — ignored and logged, preserved
/// verbatim": a warning, never migration input, because there is no item to relocate and no
/// key the app has any business removing.
@Test("A board-level deleted key is warned, never migrated")
func boardLevelDeletedIsNotMigrated() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
try fixture.index("", "schema: 1\ndeleted: 2026-01-01T00:00:00Z\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.warnings == [.boardLevelDeletedIgnored])
#expect(result.legacyTombstones.isEmpty)
#expect(try fixture.root.appendingPathComponent("index.md").checkResourceIsReachable())
}
@Test("A board that has already migrated reports nothing")
func migratedBoardReportsNothing() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let lane = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(uuidName())", "schema: 1\norder: 1024\n")
try fixture.index(".trash/\(uuidName())", "schema: 1\norder: 1024\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.legacyTombstones.isEmpty)
}
}
// MARK: - Writer: delete is a move
@Suite("BoardWriter ▸ deleteCardToTrash")
struct DeleteCardToTrashTests {
@Test("The card folder moves into .trash, which is created on first delete")
func moveCreatesTheContainer() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card"))
#expect(!fixture.exists(".trash"))
let id = try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root
)
#expect(id == ItemID(rawValue: Ident.card1))
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
#expect(fixture.exists(".trash/\(Ident.card1)"))
}
/// 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.
/// **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() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "2048", title: "Card"))
try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root
)
let text = try fixture.indexText(".trash/\(Ident.card1)")
#expect(text.contains("project: lanework # agent overlay"))
#expect(text.contains("labels: [a, b, c]"))
#expect(text.contains("created: 2026-01-01T09:00:00Z"))
#expect(text.contains("Card body — with *markdown*."))
// The stamp is the point, and the external attribution goes as it does on every app write.
#expect(!text.contains("modified: 2026-02-02T09:00:00Z"))
#expect(!text.contains("modified-by:"))
// No key is written and none is left behind — the tombstone is retired.
#expect(!text.contains("deleted:"))
let document = try FrontmatterDocument.parse(text)
#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")
func subtreeTravelsVerbatim() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF])
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card"))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", png)
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/sub/nested.bin", Data([1, 2, 3]))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/notes.txt", Data("loose".utf8))
try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root
)
#expect(try fixture.data(".trash/\(Ident.card1)/attachments/shot.png") == png)
#expect(try fixture.data(".trash/\(Ident.card1)/attachments/sub/nested.bin") == Data([1, 2, 3]))
#expect(try fixture.data(".trash/\(Ident.card1)/notes.txt") == Data("loose".utf8))
}
/// "Cards only. Lanes are never trashed" — structural here, not a policy the caller remembers.
@Test("A lane, a board root and a stray are all refused")
func onlyCardsAreTrashed() 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("notes", Item.rich(order: "1024", title: "Stray"))
for target in [Ident.lane1, "", "notes"] {
let error = writeFailure {
_ = try BoardWriter.deleteCardToTrash(
at: fixture.url(target),
inBoard: fixture.root
)
}
#expect(error != nil)
}
#expect(!fixture.exists(".trash"))
}
/// Board-wide uniqueness spans both containers, so this cannot happen through the app — and if
/// a hand copy makes it happen anyway, the move fails loudly rather than clobbering the twin.
@Test("A name already in the trash fails the move rather than overwriting it")
func collisionInTrashFailsLoudly() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Live"))
try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "1024", title: "Impostor"))
let error = writeFailure {
_ = try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root
)
}
#expect(error?.operation == .delete(title: "Live"))
// Both folders still there, neither touched.
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
#expect(try fixture.indexText(".trash/\(Ident.card1)").contains("title: Impostor"))
}
/// Discover-before-you-write: the card's `index.md` must be rewritable at the destination, so
/// an uneditable one refuses while the folder is still where the user can see it.
@Test("An uneditable card refuses before the folder moves")
func uneditableCardRefusesBeforeMoving() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.uneditable)
let error = writeFailure {
_ = try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root
)
}
if case .uneditableFrontmatter = error?.reason {} else {
Issue.record("expected an uneditable-frontmatter refusal, got \(String(describing: error?.reason))")
}
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
#expect(!fixture.exists(".trash/\(Ident.card1)"))
}
}
// MARK: - Writer: the legacy migration
@Suite("BoardWriter ▸ legacy tombstone migration")
struct TombstoneMigrationTests {
@Test("A tombstoned card relocates into .trash with every deleted occurrence removed")
func cardMigrates() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
// Two occurrences: the surgical removal must take both, or the next load re-migrates it.
try fixture.item("\(Ident.lane1)/\(Ident.card1)", """
---
schema: 1
title: Gone
order: 2048
deleted: 2026-01-01T00:00:00Z
project: lanework # agent overlay
deleted: 2026-02-02T00:00:00Z
---
Body kept.
""")
try BoardWriter.migrateTombstonedCard(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root
)
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
let text = try fixture.indexText(".trash/\(Ident.card1)")
#expect(!text.contains("deleted:"))
#expect(text.contains("project: lanework # agent overlay"))
#expect(text.contains("Body kept."))
#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)
#expect(result.legacyTombstones.isEmpty)
#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`).
@Test("A tombstoned lane is no longer migration input at all")
func laneIsNotMigrationInput() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, """
---
schema: 1
title: Old lane
order: 2048
deleted: 2026-01-01T00:00:00Z
width: 2
---
Lane notes.
""")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.legacyTombstones.isEmpty)
#expect(result.model.lanes.map(\.title.value) == ["Old lane"])
}
}
// MARK: - Writer: a lane delete is the same move
@Suite("BoardWriter ▸ deleteLaneToTrash")
struct DeleteLaneToTrashTests {
/// "Deleting a lane moves its folder — subtree intact — into `.trash/`, exactly as a card moves"
/// (03 § Trash, re-ruled 2026-07-29). Byte fidelity of the freight is the assertion: nothing
/// under the lane is read or rewritten, so a restore is an ordinary move back.
@Test("The lane folder moves whole, its subtree byte-identical")
func laneMovesWithItsSubtree() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF])
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Doing"))
let cardBefore = Item.rich(order: "1024", title: "Kept")
try fixture.item("\(Ident.lane1)/\(Ident.card1)", cardBefore)
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.uneditable)
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", png)
try fixture.file("\(Ident.lane1)/notes/scratch.md", Data("scratch".utf8))
let id = try BoardWriter.deleteLaneToTrash(
at: fixture.url(Ident.lane1),
inBoard: fixture.root
)
#expect(id == ItemID(rawValue: Ident.lane1), "a delete moves a folder, it does not rename one")
#expect(!fixture.exists(Ident.lane1))
#expect(try fixture.indexText(".trash/\(Ident.lane1)/\(Ident.card1)") == cardBefore)
#expect(try fixture.indexText(".trash/\(Ident.lane1)/\(Ident.card2)") == Item.uneditable,
"even a card whose own index.md refuses writes — nothing below the root is touched")
#expect(try fixture.data(".trash/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == png)
#expect(try fixture.data(".trash/\(Ident.lane1)/notes/scratch.md") == Data("scratch".utf8),
"strays ride along like everything else")
}
/// 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("`modified` is stamped and `modified-by` cleared, and the rank is left alone")
func theArrivalStamps() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, """
---
schema: 1
title: Doing
order: 2048
modified: 2020-01-01T00:00:00Z
modified-by: claude
width: 3
---
Lane notes.
""")
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root)
let document = try FrontmatterDocument.parse(fixture.indexText(".trash/\(Ident.lane1)"))
#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)")
#expect(text.contains("width: 3"), "everything else round-trips")
#expect(text.contains("Lane notes."))
}
/// "`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.
@Test("The move stamps kind: lane, even on an empty lane where shape would say card")
func kindIsStampedNotGuessed() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Empty\norder: 1024\n---\n")
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root)
#expect(try fixture.indexText(".trash/\(Ident.lane1)").contains("kind: lane"))
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.trashKinds[ItemID(rawValue: Ident.lane1)] == .lane)
#expect(result.model.trashedLanes.map(\.id.rawValue) == [Ident.lane1])
#expect(result.model.trash.isEmpty)
}
/// A present `kind` is never rewritten and never corroborated — the value names the kind
/// (01 § Frontmatter, the `kind` row).
@Test("An existing kind is left exactly as it was")
func existingKindIsLeftAlone() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, "---\nschema: 1\norder: 1024\nkind: lane\n---\n")
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root)
let text = try fixture.indexText(".trash/\(Ident.lane1)")
#expect(text.components(separatedBy: "kind: lane").count == 2, "written once, not twice")
}
/// The guard is the mirror of `deleteCardToTrash`'s: a lane is `<root>/<lane>`, so a card, a
/// board root, a stray, and an entry already in `.trash/` are all refused.
@Test("A card, a trash entry, a board root and a stray are all refused")
func onlyLanesTakeThisDoor() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card"))
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Trashed"))
try fixture.item("notes", Item.rich(order: "1024", title: "Stray"))
for target in ["\(Ident.lane1)/\(Ident.card1)", ".trash/\(Ident.card2)", "", "notes"] {
#expect(
writeFailure {
try BoardWriter.deleteLaneToTrash(
at: fixture.url(target),
inBoard: fixture.root
)
} != nil
)
}
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
#expect(fixture.exists(".trash/\(Ident.card2)"))
}
}
// MARK: - Writer: permanent removal
@Suite("BoardWriter ▸ purging the trash")
struct TrashContainerPurgeTests {
@Test("A trash entry purges; a live card is unreachable through this door")
func purgeIsScopedToTheTrash() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Live"))
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Trashed"))
try BoardWriter.purgeTrashEntry(at: fixture.url(".trash/\(Ident.card2)"), inBoard: fixture.root)
#expect(!fixture.exists(".trash/\(Ident.card2)"))
let error = writeFailure {
try BoardWriter.purgeTrashEntry(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root
)
}
#expect(error?.operation == .purge(title: nil))
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
// Already gone is success, as everywhere else on the purge path.
try BoardWriter.purgeTrashEntry(at: fixture.url(".trash/\(Ident.card2)"), inBoard: fixture.root)
}
@Test("Empty Trash takes every card and leaves a hand-editor's strays standing")
func emptyTrashTakesCardsOnly() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "1024", title: "One"))
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "2048", title: "Two"))
try fixture.file(".trash/readme.txt", Data("mine".utf8))
let purged = try BoardWriter.emptyTrash(inBoard: fixture.root)
#expect(Set(purged) == [ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)])
#expect(!fixture.exists(".trash/\(Ident.card1)"))
#expect(!fixture.exists(".trash/\(Ident.card2)"))
#expect(try fixture.data(".trash/readme.txt") == Data("mine".utf8))
}
@Test("Empty Trash on a board with no trash removes nothing")
func emptyTrashWithNoTrash() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
#expect(try BoardWriter.emptyTrash(inBoard: fixture.root).isEmpty)
}
/// "Permanent deletion … walks lane subtrees" (03 § Trash): the trashed lane's freight goes with
/// it, through the same recursive removal a card takes.
@Test("Purging a trashed lane takes its whole subtree")
func purgingALaneTakesItsFreight() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(".trash/\(Ident.lane1)", "---\nschema: 1\norder: 1024\nkind: lane\n---\n")
try fixture.item(".trash/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Freight"))
try fixture.file(".trash/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([1]))
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "2048", title: "Beside it"))
try BoardWriter.purgeTrashEntry(at: fixture.url(".trash/\(Ident.lane1)"), inBoard: fixture.root)
#expect(!fixture.exists(".trash/\(Ident.lane1)"))
#expect(fixture.exists(".trash/\(Ident.card2)"), "its neighbour is untouched")
}
@Test("Empty Trash walks lane subtrees too")
func emptyTrashWalksLanes() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(".trash/\(Ident.lane1)", "---\nschema: 1\norder: 1024\nkind: lane\n---\n")
try fixture.item(".trash/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Freight"))
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "2048", title: "Card"))
try fixture.file(".trash/readme.txt", Data("mine".utf8))
let purged = try BoardWriter.emptyTrash(inBoard: fixture.root)
#expect(Set(purged) == [ItemID(rawValue: Ident.lane1), ItemID(rawValue: Ident.card2)])
#expect(!fixture.exists(".trash/\(Ident.lane1)"))
#expect(!fixture.exists(".trash/\(Ident.card2)"))
#expect(try fixture.data(".trash/readme.txt") == Data("mine".utf8), "strays still stand")
}
}
// MARK: - Board-wide uniqueness spans the trash
@Suite("BoardWriter ▸ the import boundary sees the trash")
struct TrashIdentityTests {
/// The dedupe walk includes `.trash/`: an arriving card whose UUID is sitting in the
/// destination's trash is reminted, because the two would otherwise become one identity in two
/// folders the moment the trashed one was dragged back out.
@Test("An import colliding with a trashed card is degraded to a copy")
func importCollidesWithTrash() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("source", Item.board)
try fixture.item("source/\(Ident.lane1)", Item.rich(order: "1024", title: "Source lane"))
try fixture.item("source/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Arriving"))
try fixture.item("destination", Item.board)
try fixture.item("destination/\(Ident.lane2)", Item.rich(order: "1024", title: "Destination lane"))
// The colliding twin lives only in the trash — nowhere among the lanes.
try fixture.item("destination/.trash/\(Ident.card1)", Item.rich(order: "1024", title: "Trashed twin"))
let result = try BoardWriter.moveItem(
at: fixture.url("source/\(Ident.lane1)/\(Ident.card1)"),
toParent: fixture.url("destination/\(Ident.lane2)"),
sourceBoardRoot: fixture.url("source"),
destinationBoardRoot: fixture.url("destination"),
order: nil
)
#expect(result.reminted.map(\.from) == [ItemID(rawValue: Ident.card1)])
#expect(result.id != ItemID(rawValue: Ident.card1))
#expect(fixture.exists("destination/\(Ident.lane2)/\(result.id.rawValue)"))
// The trashed twin is untouched, and the board holds one folder per identity.
#expect(try fixture.indexText("destination/.trash/\(Ident.card1)").contains("title: Trashed twin"))
}
}
// MARK: - The trash's kind discriminator
/// **`kind:` discriminates inside `.trash/`** (01-storage-format.md § Deletion, re-ruled
/// 2026-07-29): depth defines meaning on the live board, but the trash is flat, and an empty lane
/// folder is shape-identical to a card folder. The reader **trusts the value**, and only an
/// unrecognized value or no key at all falls through to shape.
///
/// The verdict rides `LoadResult.trashKinds` — a *reading*, not a rendering: every entry still
/// parses through the one card parse (a trashed card is "an ordinary card in a special place"), and
/// nothing is hidden or dropped on account of its kind.
@Suite("BoardLoader ▸ the trash's kind discriminator")
struct TrashKindDiscriminatorTests {
@Test("kind: card is honored even against the shape")
func cardValueBeatsShape() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let entry = uuidName()
let child = uuidName()
try fixture.index("", "schema: 1\n")
// Lane-shaped on disk — a UUID-named child with its own index.md — and yet it says card.
try fixture.index(".trash/\(entry)", "schema: 1\norder: 1024\nkind: card\n")
try fixture.index(".trash/\(entry)/\(child)", "schema: 1\norder: 1024\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.trashKinds[ItemID(rawValue: entry)] == .card)
// Trusting the value is not policing it: the entry still loads, and its child is still not a
// level (the walk stops at a trash entry exactly as it stops at a card).
#expect(result.model.trash.map(\.id.rawValue) == [entry])
}
@Test("kind: lane is honored even against the shape")
func laneValueBeatsShape() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let entry = uuidName()
try fixture.index("", "schema: 1\n")
// Card-shaped on disk — no children at all — and yet it says lane. An external writer's
// `kind: lane` is honored, never policed.
try fixture.index(".trash/\(entry)", "schema: 1\norder: 1024\nkind: lane\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.trashKinds[ItemID(rawValue: entry)] == .lane)
#expect(result.model.trashedLanes.map(\.id.rawValue) == [entry])
#expect(result.model.trashedLanes.first?.heldCards == 0, "an honored lane can be empty")
#expect(result.model.trash.isEmpty, "and it is not a card anywhere")
}
/// No key, or a value outside the schema's three, falls through to shape — UUID-shaped children
/// with their own `index.md` → lane, else card.
@Test("An unrecognized value or no key falls through to shape")
func unrecognizedFallsToShape() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let bare = uuidName()
let laneShaped = uuidName()
let child = uuidName()
let odd = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(bare)", "schema: 1\norder: 1024\n")
try fixture.index(".trash/\(laneShaped)", "schema: 1\norder: 2048\n")
try fixture.index(".trash/\(laneShaped)/\(child)", "schema: 1\norder: 1024\n")
try fixture.index(".trash/\(odd)", "schema: 1\norder: 3072\nkind: widget\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.trashKinds[ItemID(rawValue: bare)] == .card)
#expect(result.trashKinds[ItemID(rawValue: laneShaped)] == .lane)
#expect(result.trashKinds[ItemID(rawValue: odd)] == .card, "unrecognized, and card-shaped")
// The unrecognized value is preserved verbatim — never corrected, never stripped.
#expect(result.model.trash.first { $0.id.rawValue == odd }?.document.kind == .valid("widget"))
}
/// A folder whose `kind` is *shape-derived* today keeps that reading only until it is touched —
/// at which point the backfill writes the same answer down. The two rules are one function, so
/// the read and the write can never disagree.
@Test("The reading a shape produces is the value the backfill writes")
func shapeReadingMatchesTheBackfill() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let entry = uuidName()
let child = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(entry)", "schema: 1\norder: 1024\n")
try fixture.index(".trash/\(entry)/\(child)", "schema: 1\norder: 1024\n")
let before = try BoardLoader.load(boardRoot: fixture.root)
#expect(before.trashKinds[ItemID(rawValue: entry)] == .lane)
try BoardWriter.updateIndex(
inItemFolder: fixture.root.appendingPathComponent(".trash/\(entry)"),
operation: .reorder(title: nil)
) { $0.set(FrontmatterKeys.order, to: .double(4096)) }
let after = try BoardLoader.load(boardRoot: fixture.root)
#expect(after.model.trashedLanes.first?.document.kind == .valid("lane"))
#expect(after.trashKinds[ItemID(rawValue: entry)] == .lane)
}
/// **The opaque unit** (03 § Trash): a trashed lane is one row with a title, a rank and a count.
/// Its cards are not in the snapshot at all — not as trash cards, not as anybody's lane's cards
/// — and the count is what the loader would have rendered as cards, so the row's number and a
/// restore's outcome agree.
@Test("A trashed lane surfaces as one opaque row: title, rank, held-card count")
func trashedLaneIsOpaque() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let lane = uuidName()
let held = [uuidName(), uuidName(), uuidName()]
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(lane)", "schema: 1\norder: 1024\ntitle: Doing\nkind: lane\n")
for (index, card) in held.enumerated() {
try fixture.index(".trash/\(lane)/\(card)", "schema: 1\norder: \((index + 1) * 1024)\n")
}
// Not a card, so not counted: a stray folder and an identity-shaped one with no index.md.
try fixture.index(".trash/\(lane)/attachments", "schema: 1\norder: 1024\n")
try fixture.folder(".trash/\(lane)/\(uuidName())")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trashedLanes.count == 1)
let row = try #require(result.model.trashedLanes.first)
#expect(row.id == ItemID(rawValue: lane))
#expect(row.title.value == "Doing")
#expect(row.order == 1024)
#expect(row.heldCards == 3)
#expect(result.model.trash.isEmpty, "the freight is not surfaced as trash cards")
#expect(result.model.lanes.isEmpty)
#expect(result.warnings.isEmpty, "nothing inside an opaque entry is walked, so nothing strays")
}
/// The ruling's own **honest limit**, pinned so it stays a decision rather than a surprise: "a
/// kind-less trashed lane emptied of its children before any touch becomes indistinguishable
/// from a card" (01 § Deletion). The app never produces one — `deleteLaneToTrash` stamps the
/// key — so this is only reachable by a hand-editor.
@Test("A kindless empty lane folder reads as a card — the on-touch-only limit")
func kindlessEmptyLaneReadsAsACard() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let entry = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(entry)", "schema: 1\norder: 1024\ntitle: Was a lane\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.trashKinds[ItemID(rawValue: entry)] == .card)
#expect(result.model.trash.map(\.id.rawValue) == [entry])
#expect(result.model.trashedLanes.isEmpty)
}
/// Both kinds are read by the one rulebook: a trashed lane's absent `order` reads as
/// append-at-end exactly as a trashed card's does (re-ruled 2026-07-31), and the entry loads
/// whichever kind the discriminator calls it.
@Test("A trashed lane missing order reads like any other entry")
func trashedLaneWithoutOrderReadsAsAppendAtEnd() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let lane = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(lane)", "schema: 1\nkind: lane\n")
try fixture.index(".trash/\(lane)/\(uuidName())", "schema: 1\norder: 1024\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trashedLanes.map(\.id.rawValue) == [lane])
#expect(result.model.trashedLanes[0].order == 1024)
#expect(result.model.trashedLanes[0].heldCards == 1)
}
/// 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()
let middleCard = uuidName()
let oldestLane = uuidName()
try fixture.index("", "schema: 1\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])
// 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])
}
}