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): `/.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) } }