The 2026-07-29 integrity design pass, consolidated (DESIGN/01 -
Validation and healing; DESIGN/02 - Components): IntegrityRules
(Storage, pure) is the one home for the identity predicate and
canonical form (BoardWriter.canonicalIdentity deleted, ItemID and the
loader forward to it), the per-field rulebook, uneditable shapes,
per-kind index validation, the reserved-name tables, and the trash
kind discriminator (values trusted - kind: lane/card explicit,
unrecognized falls to shape). LoadResult's ad-hoc channels fold into
one typed Defect stream (looseCardFiles / legacyTombstone /
claimedNameSquatted, per-defect heal signatures); the old accessors
survive as computed views.
HealScheduler (LiveStore) states the six-step heal pattern once -
resting-clear, lock gate, isWritableFile gate (now covering all four
heals), signature memo armed-before-attempt with explicit
clear-on-success, disk re-verify in each write half, one banner-posture
table (BannerCenter keeps all phrasing). The three hand-rolled healers
run on it with behavior preserved - including the
relocation-notice-despite-partial-failure quirk, deliberately. Heals
run at the reload tail AND at registry acquire, closing the
migration-never-fires-at-open asymmetry. Displacement runs first: a
squatted .trash would otherwise fail the migration and arm its memo
against an unchanged picture.
Claimed-name squatters (ruled today, 62c47a2) displace by the shared
Finder-style rename ladder - preserved verbatim, symlinks moved as
links, nothing stamped; AgentGuide's untouchable-skip upgrades to
displace-then-write, the CLAUDE.user.md-taken skip stands. kind stamps
on every create and backfills on any index rewrite via the on-touch
seam (placement resolver stamps nothing when the parent is unknown -
a guessed kind is worse than an absent one; board-root writers declare
theirs). Heal writes mark their EchoLedger receipts (inert in base;
pro-m1's committer will split them into their own commits). The
renumber ask-renumber-ask-again two-step is one shared helper, adopted
at all nine call sites.
69 tests added. 1738 green on both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
945 lines
41 KiB
Swift
945 lines
41 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: 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 {
|
|
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")
|
|
// 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")
|
|
|
|
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"])
|
|
// 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)
|
|
}
|
|
|
|
/// "A lane-shaped nesting inside `.trash` is a stray": the walk stops at a card in the trash
|
|
/// exactly as it does under a lane, so a UUID-shaped folder *inside* a trash card is content,
|
|
/// never a level — invisible, preserved, and not warned about (a card's subfolders never are).
|
|
@Test("A lane-shaped nesting inside .trash renders as one card, its children invisible")
|
|
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.trash.map(\.id.rawValue) == [outer])
|
|
#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"])
|
|
}
|
|
|
|
/// Fail-fast is a property of the card parse, not of the container it ran in.
|
|
@Test("A malformed order inside .trash fails the load, naming its path")
|
|
func malformedOrderInTrashFailsFast() 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")
|
|
|
|
do {
|
|
_ = try BoardLoader.load(boardRoot: fixture.root)
|
|
Issue.record("expected the load to fail")
|
|
} catch let error as BoardLoadError {
|
|
#expect(error.path == ".trash/\(card)/index.md")
|
|
#expect(error.reason == .malformedOrder(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(kind: .card, laneID: ItemID(rawValue: lane), cardID: ItemID(rawValue: card), title: "Gone")
|
|
])
|
|
// Still rendered by the retiring path — read-only detection has moved nothing yet.
|
|
#expect(result.model.lanes[0].cards.map(\.isDeleted) == [true])
|
|
#expect(result.model.trash.isEmpty)
|
|
}
|
|
|
|
@Test("A tombstoned lane is reported with no card, and keeps loading flagged")
|
|
func tombstonedLaneIsReported() throws {
|
|
let fixture = try TrashFixture()
|
|
defer { fixture.tearDown() }
|
|
|
|
let lane = uuidName()
|
|
try fixture.index("", "schema: 1\n")
|
|
try fixture.index(lane, "schema: 1\norder: 1024\ntitle: Old lane\ndeleted: 2026-01-01T00:00:00Z\n")
|
|
|
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
|
|
|
#expect(result.legacyTombstones == [
|
|
LegacyTombstone(kind: .lane, laneID: ItemID(rawValue: lane), cardID: nil, title: "Old lane")
|
|
])
|
|
#expect(result.model.lanes[0].isDeleted)
|
|
}
|
|
|
|
/// Presence, not validity — the same rule the flag has always read by, so a broken timestamp
|
|
/// still migrates rather than being left behind as the one key the pivot forgot.
|
|
@Test("A malformed deleted value is still migration input")
|
|
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(\.kind) == [.card, .lane])
|
|
#expect(result.legacyTombstones.map(\.cardID) == [ItemID(rawValue: card), nil])
|
|
}
|
|
|
|
/// "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,
|
|
order: -1024
|
|
)
|
|
|
|
#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.
|
|
@Test("Only order and the stamps are rewritten; every other byte survives")
|
|
func onlyOrderAndStampsChange() 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,
|
|
order: -1024
|
|
)
|
|
|
|
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(-1024))
|
|
}
|
|
|
|
@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,
|
|
order: -1024
|
|
)
|
|
|
|
#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,
|
|
order: -1024
|
|
)
|
|
}
|
|
#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,
|
|
order: -2048
|
|
)
|
|
}
|
|
#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,
|
|
order: -1024
|
|
)
|
|
}
|
|
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,
|
|
order: -1024
|
|
)
|
|
|
|
#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(-1024))
|
|
|
|
// 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])
|
|
}
|
|
|
|
/// "A lane carrying `deleted:` returns **live** with the key removed" — resurrection is the
|
|
/// safe direction, and the folder does not move, so it returns to its own position.
|
|
@Test("A tombstoned lane keeps its place and returns live")
|
|
func laneMigratesInPlace() throws {
|
|
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 cardBefore = Item.rich(order: "1024", title: "Kept")
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", cardBefore)
|
|
|
|
try BoardWriter.migrateTombstonedLane(at: fixture.url(Ident.lane1))
|
|
|
|
let text = try fixture.indexText(Ident.lane1)
|
|
#expect(!text.contains("deleted:"))
|
|
#expect(text.contains("width: 2"))
|
|
#expect(text.contains("Lane notes."))
|
|
#expect(try FrontmatterDocument.parse(text).order == .valid(2048))
|
|
// Its cards are none of the lane migration's business.
|
|
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == cardBefore)
|
|
|
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
|
#expect(result.legacyTombstones.isEmpty)
|
|
#expect(result.model.lanes.map(\.isDeleted) == [false])
|
|
}
|
|
|
|
@Test("The lane migration refuses a card, whose migration is a move")
|
|
func laneMigrationRefusesACard() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card"))
|
|
|
|
let error = writeFailure {
|
|
try BoardWriter.migrateTombstonedLane(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
|
}
|
|
#expect(error?.operation == .migrateTombstone(title: nil))
|
|
}
|
|
}
|
|
|
|
// MARK: - Writer: lane delete is physical
|
|
|
|
@Suite("BoardWriter ▸ removeLane")
|
|
struct RemoveLaneTests {
|
|
|
|
@Test("The lane folder and everything under it go")
|
|
func laneIsRemovedWhole() 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.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([1]))
|
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Other"))
|
|
|
|
try BoardWriter.removeLane(at: fixture.url(Ident.lane1))
|
|
|
|
#expect(!fixture.exists(Ident.lane1))
|
|
#expect(fixture.exists(Ident.lane2))
|
|
}
|
|
|
|
@Test("A lane that is already gone is success")
|
|
func absentLaneIsSuccess() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", Item.board)
|
|
try BoardWriter.removeLane(at: fixture.url(Ident.lane1))
|
|
}
|
|
|
|
/// The guard has to tell a lane from a card *and* from a trash card, whose parent is `.trash/`
|
|
/// rather than a UUID — otherwise a permanent delete would be reachable through this door.
|
|
@Test("A card, a trash card, a board root and a stray are all refused")
|
|
func onlyLanesAreRemoved() throws {
|
|
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.removeLane(at: fixture.url(target)) } != nil)
|
|
}
|
|
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
|
#expect(fixture.exists(".trash/\(Ident.card2)"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Writer: capture and replay
|
|
|
|
@Suite("BoardWriter ▸ subtree capture and replay")
|
|
struct SubtreeCaptureTests {
|
|
|
|
/// A lane delete's undo in one round trip: capture, remove, recreate, capture again. Equality
|
|
/// of the two captures is the byte-fidelity assertion — names, bytes, nesting and order.
|
|
@Test("Capture → remove → recreate → capture is identical, nested cards and attachments included")
|
|
func roundTripIsByteIdentical() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
|
|
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF, 0x0D, 0x0A])
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "One"))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.uneditable)
|
|
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", png)
|
|
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/sub/deep.bin", Data([7, 8, 9]))
|
|
// The verbatim promise covers what the loader would never render, too.
|
|
try fixture.file("\(Ident.lane1)/\(Ident.card1)/.DS_Store", Data([0, 1]))
|
|
try fixture.file("\(Ident.lane1)/notes/scratch.md", Data("scratch".utf8))
|
|
try FileManager.default.createDirectory(
|
|
at: fixture.url("\(Ident.lane1)/\(Ident.indexless)"),
|
|
withIntermediateDirectories: true
|
|
)
|
|
|
|
let lane = fixture.url(Ident.lane1)
|
|
let captured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: "Lane"))
|
|
try BoardWriter.removeLane(at: lane)
|
|
#expect(!fixture.exists(Ident.lane1))
|
|
|
|
try BoardWriter.recreateSubtree(at: lane, from: captured, operation: .delete(title: "Lane"))
|
|
|
|
let recaptured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: "Lane"))
|
|
#expect(recaptured == captured)
|
|
// …and spot-checked against disk, so the equality is not two identical bugs.
|
|
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)") == Item.uneditable)
|
|
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == png)
|
|
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/.DS_Store") == Data([0, 1]))
|
|
#expect(try fixture.data("\(Ident.lane1)/notes/scratch.md") == Data("scratch".utf8))
|
|
#expect(try fixture.entryNames(Ident.lane1).contains(Ident.indexless))
|
|
}
|
|
|
|
@Test("A symlink is captured as a link and recreated as one, never followed")
|
|
func symlinksAreNotFollowed() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
|
try FileManager.default.createSymbolicLink(
|
|
atPath: fixture.url(Ident.lane1).appendingPathComponent("link").path,
|
|
withDestinationPath: "../nowhere"
|
|
)
|
|
|
|
let lane = fixture.url(Ident.lane1)
|
|
let captured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: nil))
|
|
#expect(captured.entries.contains(.symlink(name: "link", destination: "../nowhere")))
|
|
|
|
try BoardWriter.removeLane(at: lane)
|
|
try BoardWriter.recreateSubtree(at: lane, from: captured, operation: .delete(title: nil))
|
|
|
|
let destination = try FileManager.default.destinationOfSymbolicLink(
|
|
atPath: lane.appendingPathComponent("link").path
|
|
)
|
|
#expect(destination == "../nowhere")
|
|
}
|
|
|
|
@Test("Recreating over something that exists refuses rather than merging")
|
|
func recreateRefusesToClobber() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
|
|
|
let lane = fixture.url(Ident.lane1)
|
|
let captured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: nil))
|
|
let error = writeFailure {
|
|
try BoardWriter.recreateSubtree(at: lane, from: captured, operation: .delete(title: nil))
|
|
}
|
|
#expect(error?.reason == .io(message: "something already exists here"))
|
|
// The refusal left the folder that was there exactly as it was.
|
|
#expect(try fixture.indexText(Ident.lane1).contains("title: Lane"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Writer: permanent removal
|
|
|
|
@Suite("BoardWriter ▸ purging the trash")
|
|
struct TrashContainerPurgeTests {
|
|
|
|
@Test("A trash card purges; a live card is unreachable through this door")
|
|
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.purgeTrashCard(at: fixture.url(".trash/\(Ident.card2)"), inBoard: fixture.root)
|
|
#expect(!fixture.exists(".trash/\(Ident.card2)"))
|
|
|
|
let error = writeFailure {
|
|
try BoardWriter.purgeTrashCard(
|
|
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.purgeTrashCard(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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
/// 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.trash.first?.document.kind == .valid("lane"))
|
|
#expect(after.trashKinds[ItemID(rawValue: entry)] == .lane)
|
|
}
|
|
}
|