import Foundation import Testing @testable import Kanban // MARK: - Fixture builder /// A tiny in-memory-driven builder for synthetic board trees under a temp directory. Smoke /// coverage only (per 02-architecture.md § Testing) — a comprehensive golden-fixture suite /// over real trees under `Fixtures/` is a separate, later card. private struct BoardFixture { let root: URL init() throws { root = FileManager.default.temporaryDirectory .appendingPathComponent("BoardLoaderTests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) } func tearDown() { try? FileManager.default.removeItem(at: root) } /// Writes `index.md` at `relativePath` (created if needed), with `body` after the /// frontmatter delimiters. @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 } /// Creates a folder with no `index.md` — the "interrupted two-step create" shape. @discardableResult func emptyFolder(_ relativePath: String) throws -> URL { let folder = root.appendingPathComponent(relativePath, isDirectory: true) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) return folder } /// A stray file, not a folder — never a level candidate regardless of its name. func strayFile(_ 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) } } /// A fresh folder name in the app's own emission spelling — lowercase v4. The loader's gate is /// shape-only (hex, `8-4-4-4-12`, any case, any version — 01-storage-format.md § Fractal layout /// ▸ Rules, "Name shape gates level detection"), so this is *a* valid identity rather than the /// only kind; the case tests below cover the rest. Used wherever a test just needs some lane/card /// identity and doesn't care about the exact value; tests that need a specific lexicographic /// ordering use literal UUID-shaped strings instead. private func uuidFolderName() -> String { UUID().uuidString.lowercased() } /// One defect, and **only** one: the whole aggregate is asserted rather than its first entry, so a /// board authored to break in one place cannot quietly start reporting two. private func expectFailure( _ expectedReason: BoardLoadError.Reason, path: String, _ operation: () throws -> Void ) { do { try operation() Issue.record("expected BoardLoadError(\(path), \(expectedReason)) but load succeeded") } catch let failure as BoardLoadFailure { #expect(failure.defects == [BoardLoadError(path: path, reason: expectedReason)]) } catch { Issue.record("expected a BoardLoadFailure, got \(error)") } } // MARK: - Well-formed board struct BoardLoaderWellFormedTests { @Test func loadsCompleteStructureWithOrderingAndTombstoneFlags() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let laneA = uuidFolderName() let laneB = uuidFolderName() let cardFirst = uuidFolderName() let cardSecond = uuidFolderName() let cardDeleted = uuidFolderName() try fixture.index("", "schema: 1\ntitle: Demo Board\ntemplate: {order: 3}\n") try fixture.index(laneB, "schema: 1\norder: 2048\ntitle: B Lane\n") try fixture.index(laneA, "schema: 1\norder: 1024\ntitle: A Lane\n") try fixture.index("\(laneA)/\(cardSecond)", "schema: 1\norder: 2048\ntitle: Second\n") try fixture.index("\(laneA)/\(cardFirst)", "schema: 1\norder: 1024\ntitle: First\n") try fixture.index( "\(laneA)/\(cardDeleted)", "schema: 1\norder: 512\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n" ) let result = try BoardLoader.load(boardRoot: fixture.root) let model = result.model #expect(model.title.value == "Demo Board") #expect(model.schema == 1) if case let .mapping(pairs) = model.template { #expect(pairs.count == 1) } else { Issue.record("expected template to be a mapping, got \(String(describing: model.template))") } #expect(model.lanes.map(\.id.rawValue) == [laneA, laneB]) let lane = try #require(model.lanes.first { $0.id.rawValue == laneA }) #expect(lane.cards.map(\.id.rawValue) == [cardDeleted, cardFirst, cardSecond]) #expect(lane.cards.map(\.isDeleted) == [true, false, false]) #expect(result.warnings.isEmpty) } /// Tombstone semantics key on the `deleted` key's *presence*, not its validity /// (01-storage-format.md § Frontmatter, § Deletion): a `deleted` value with no sensible /// date reading still tombstones — the user's intent to delete outranks the broken date. @Test func malformedDeletedValueStillTombstonesLaneAndCard() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() let card = uuidFolderName() 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) let loadedLane = try #require(result.model.lanes.first { $0.id.rawValue == lane }) #expect(loadedLane.deleted == .malformed(raw: "yesterday")) #expect(loadedLane.isDeleted) let loadedCard = try #require(loadedLane.cards.first { $0.id.rawValue == card }) #expect(loadedCard.deleted == .malformed(raw: "yesterday")) #expect(loadedCard.isDeleted) } @Test func tiesAreBrokenByFolderNameNotTitle() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } // Both UUID-shaped so both are lane candidates; chosen so their lexicographic order // ('0' < 'f') is known ahead of time. let laneFirst = "00000000-0000-4000-8000-000000000000" let laneSecond = "ffffffff-ffff-4fff-8fff-ffffffffffff" try fixture.index("", "schema: 1\n") // Same order; titles are reversed relative to folder-name order, to catch a loader // that accidentally wires title into the tie-break instead of the folder name. try fixture.index(laneSecond, "schema: 1\norder: 1024\ntitle: Should Be Second\n") try fixture.index(laneFirst, "schema: 1\norder: 1024\ntitle: Should Be First\n") let model = try BoardLoader.load(boardRoot: fixture.root).model #expect(model.lanes.map(\.id.rawValue) == [laneFirst, laneSecond]) } } // MARK: - Skip rules (UUID-shaped candidates only) struct BoardLoaderSkipTests { @Test func indexlessUUIDFolderBelowRootIsSkippedWithMissingIndexWarning() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() let orphanLane = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") try fixture.emptyFolder(orphanLane) let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.map(\.id.rawValue) == [lane]) #expect(result.warnings.contains(.missingIndex(path: orphanLane))) } @Test func indexlessUUIDCardFolderIsSkippedWithWarningAndRestOfBoardStillLoads() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() let card = uuidFolderName() let orphanCard = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n") try fixture.emptyFolder("\(lane)/\(orphanCard)") let result = try BoardLoader.load(boardRoot: fixture.root) let loadedLane = try #require(result.model.lanes.first) #expect(loadedLane.cards.map(\.id.rawValue) == [card]) #expect(result.warnings.contains(.missingIndex(path: "\(lane)/\(orphanCard)"))) } } // MARK: - Strays ignored (non-directories, hidden entries, symlinks) struct BoardLoaderStrayTests { @Test func strayFilesAndHiddenEntriesAreIgnoredWithoutWarning() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() let card = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n") try fixture.strayFile("notes.txt") try fixture.strayFile(".DS_Store") try fixture.strayFile("\(lane)/notes.txt") _ = try fixture.emptyFolder(".git") let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.map(\.id.rawValue) == [lane]) #expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card]) #expect(result.warnings.isEmpty) } @Test func directorySymlinkIsTreatedAsStrayNotFollowed() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let realLane = uuidFolderName() try fixture.index("", "schema: 1\n") let realLaneURL = try fixture.index(realLane, "schema: 1\norder: 1024\n") try FileManager.default.createSymbolicLink( at: fixture.root.appendingPathComponent("linked-lane"), withDestinationURL: realLaneURL ) let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.map(\.id.rawValue) == [realLane]) #expect(result.warnings.isEmpty) } /// The identity predicate is shape-only — "identity-shaped name or not" (01-storage-format.md /// § Fractal layout ▸ Rules, "Symlinks are never traversed", settled). This pins the case the /// previous test's non-UUID name doesn't: a symlink whose *name itself* would pass /// `isUUIDShaped` and which points at a real directory must still be excluded before that /// name shape is ever consulted — `directoryCandidates` filters on `isSymbolicLink` ahead of /// `isDirectory`, so a link is never mistaken for the directory it points to, UUID-shaped name /// or not. @Test func uuidShapedSymlinkToADirectoryIsTreatedAsStrayNotFollowed() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let realLane = uuidFolderName() let linkedName = uuidFolderName() try fixture.index("", "schema: 1\n") let realLaneURL = try fixture.index(realLane, "schema: 1\norder: 1024\n") try FileManager.default.createSymbolicLink( at: fixture.root.appendingPathComponent(linkedName), withDestinationURL: realLaneURL ) let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.map(\.id.rawValue) == [realLane]) #expect(!result.model.lanes.map(\.id.rawValue).contains(linkedName)) #expect(result.warnings.isEmpty) } } // MARK: - Non-UUID-shaped folders are strays (01-storage-format.md § Fractal layout ▸ Rules, // "Name shape gates level detection") struct BoardLoaderNonUUIDStrayTests { /// A non-UUID-shaped folder is a stray even when its `index.md` is perfectly valid /// lane-shaped content — the name shape gates candidacy before the file is ever read. @Test func nonUUIDFolderWithValidLaneShapedIndexIsIgnoredWithWarningAndBoardLoads() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let realLane = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(realLane, "schema: 1\norder: 1024\n") try fixture.index("todo", "schema: 1\norder: 2048\ntitle: Hand-authored lane\n") let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.map(\.id.rawValue) == [realLane]) #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "todo"))) } /// The motivating case: before this rule, a non-UUID folder with a broken `index.md` /// (missing required `order`) would fail-fast the whole load. Now the name shape gates it /// out as a stray before the loader ever parses the file, so the rest of the board still /// loads. @Test func nonUUIDFolderWithBrokenIndexIsIgnoredWithWarningAndBoardStillLoads() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let realLane = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(realLane, "schema: 1\norder: 1024\n") // A hand-authored lane with a name that isn't identity-shaped: never a candidate, so its // contents are never read at all. try fixture.index("todo", "schema: 1\ntitle: Broken hand-authored lane\n") let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.map(\.id.rawValue) == [realLane]) #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "todo"))) } /// Same motivating case, one level down: a non-UUID card-depth folder with a broken /// `index.md` is a stray, not a fail-fast, and doesn't stop its lane's other cards loading. @Test func nonUUIDCardFolderWithBrokenIndexIsIgnoredWithWarningAndLaneStillLoads() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() let realCard = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") try fixture.index("\(lane)/\(realCard)", "schema: 1\norder: 1024\n") try fixture.index("\(lane)/scratch", "schema: 1\n") // missing 'order' too let result = try BoardLoader.load(boardRoot: fixture.root) let loadedLane = try #require(result.model.lanes.first) #expect(loadedLane.cards.map(\.id.rawValue) == [realCard]) #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "\(lane)/scratch"))) } /// The identity predicate is **shape-only, any case** (01-storage-format.md § Fractal layout /// ▸ Rules, settled): `uuidgen` and `UUID().uuidString` both print uppercase, so an /// uppercase folder is an ordinary lane — never a silently skipped stray. Its `rawValue` /// keeps the exact spelling: the app accepts liberally and never renames to canonicalize. @Test func uppercaseUUIDFolderIsALaneWithItsSpellingPreserved() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lowercaseLane = uuidFolderName() let uppercaseLane = UUID().uuidString // Foundation renders this uppercase. try fixture.index("", "schema: 1\n") try fixture.index(lowercaseLane, "schema: 1\norder: 1024\n") try fixture.index(uppercaseLane, "schema: 1\norder: 2048\n") let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.map(\.id.rawValue) == [lowercaseLane, uppercaseLane]) #expect(result.warnings.isEmpty) } /// One level down, and mixed case rather than uniform: an agent's `uuidgen`-named card /// folder loads as a card, spelling intact. @Test func mixedCaseUUIDCardFolderLoadsAsACardWithItsSpellingPreserved() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() let mixedCaseCard = "AbCdEf01-2345-6789-aBcD-EF0123456789" try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") try fixture.index("\(lane)/\(mixedCaseCard)", "schema: 1\norder: 1024\ntitle: From an agent\n") let result = try BoardLoader.load(boardRoot: fixture.root) let loadedLane = try #require(result.model.lanes.first) #expect(loadedLane.cards.map(\.id.rawValue) == [mixedCaseCard]) #expect(loadedLane.cards.first?.title.value == "From an agent") #expect(result.warnings.isEmpty) } /// **Any version**, not just v4: the version nibble protects no invariant here — a v7 (or a /// nibble no RFC ever assigned) is exactly as unique as a v4, so it is an identity, not a /// stray. Only the shape is checked. @Test func oddVersionAndVariantNibblesAreStillIdentities() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let v7Lane = "01912d5e-7c00-7000-8000-abcdefabcdef" // version nibble 7 let oddLane = "01912d5e-7c00-c000-f000-abcdefabcdef" // version c, variant f — no RFC's try fixture.index("", "schema: 1\n") try fixture.index(v7Lane, "schema: 1\norder: 1024\n") try fixture.index(oddLane, "schema: 1\norder: 2048\n") let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.map(\.id.rawValue) == [v7Lane, oddLane]) #expect(result.warnings.isEmpty) } /// The shape itself is still strict: near-misses stay strays. Hex only, exact group lengths, /// hyphens exactly where they belong. @Test func nearMissUUIDShapesAreStillStrays() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let realLane = uuidFolderName() let strays = [ "GGGGGGGG-0000-4000-8000-000000000000", // not hex "00000000-0000-4000-8000-00000000000", // one digit short "000000000-000-4000-8000-000000000000", // hyphens misplaced "00000000-0000-4000-8000-000000000000-", // trailing hyphen ] try fixture.index("", "schema: 1\n") try fixture.index(realLane, "schema: 1\norder: 1024\n") for (offset, stray) in strays.enumerated() { try fixture.index(stray, "schema: 1\norder: \(2048 + offset)\n") } let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.map(\.id.rawValue) == [realLane]) for stray in strays { #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: stray))) } } /// Reserved card children are covered "by construction": `attachments/` and `comments/` are /// non-UUID-shaped, and the *level walk* stops at depth 2 — so neither can ever be mistaken /// for an item, and neither may surface a warning. (`attachments/` is read for its file /// names, which is a listing, not a descent — see `CardAttachmentListingTests` below.) @Test func reservedAttachmentsAndCommentsUnderCardProduceNoWarning() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() let card = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index("\(lane)", "schema: 1\norder: 1024\n") try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n") try fixture.strayFile("\(lane)/\(card)/attachments/sketch.png") try fixture.strayFile("\(lane)/\(card)/comments/whatever.md") let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.first?.cards.map(\.id.rawValue) == [card]) #expect(result.warnings.isEmpty) } } // MARK: - Card attachments (01-storage-format.md § Attachments) /// `Card.attachments` — the loader's one read *inside* a card folder. The golden-fixture suite /// pins the everyday shapes on real committed trees (`FixtureBoardTests`); these cover what a git /// fixture can't carry (a symlink) and what only a synthetic tree can arrange (a card folder whose /// `attachments` is a *file*, an empty folder, Finder's numeric ordering). struct CardAttachmentListingTests { /// Builds a one-card board and returns that card, so each test below is one arrangement plus /// one assertion. private func card(in fixture: BoardFixture) throws -> Card { let result = try BoardLoader.load(boardRoot: fixture.root) return try #require(result.model.lanes.first?.cards.first) } private func boardWithOneCard(_ fixture: BoardFixture) throws -> String { let lane = "10000000-0000-4000-8000-000000000001" let cardID = "20000000-0000-4000-8000-000000000002" try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") try fixture.index("\(lane)/\(cardID)", "schema: 1\norder: 1024\n") return "\(lane)/\(cardID)" } /// **Symlinks are not surfaced** — the same never-resolve stance the level walk takes /// (`directoryCandidates`), so a link into another volume or a cycle can't turn a listing /// into a traversal. The link itself stays on disk untouched; it just isn't an attachment. @Test func symlinksInAttachmentsAreNotSurfaced() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let cardPath = try boardWithOneCard(fixture) try fixture.strayFile("\(cardPath)/attachments/real.png") try fixture.strayFile("outside.png") let attachments = fixture.root.appendingPathComponent("\(cardPath)/attachments", isDirectory: true) try FileManager.default.createSymbolicLink( at: attachments.appendingPathComponent("link-to-file.png"), withDestinationURL: fixture.root.appendingPathComponent("outside.png") ) try FileManager.default.createSymbolicLink( at: attachments.appendingPathComponent("link-to-folder"), withDestinationURL: fixture.root ) #expect(try card(in: fixture).attachments == ["real.png"]) } /// The four shapes in one folder — the fixture board's assertion restated synthetically, so /// the rule is pinned even if the bundled fixture tree ever loses a file to a copy phase. @Test func onlyTopLevelNonHiddenRegularFilesAreListed() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let cardPath = try boardWithOneCard(fixture) try fixture.strayFile("\(cardPath)/attachments/sketch.png") try fixture.strayFile("\(cardPath)/attachments/notes.txt") try fixture.strayFile("\(cardPath)/attachments/.DS_Store") try fixture.strayFile("\(cardPath)/attachments/sub/nested.txt") #expect(try card(in: fixture).attachments == ["notes.txt", "sketch.png"]) } /// **Finder order** (`localizedStandardCompare`), not plain lexicographic — digits inside a /// name *count* rather than collate, so the run `importAttachments` itself produces on a /// collision (`shot.png` → `shot 2.png` → `shot 10.png`) sorts 2-before-10, matching the card /// window sidebar's listing, which answers through this same enumeration. (`shot.png` trailing /// its own numbered copies is Finder's own ordering of a space against a dot, not a quirk of /// ours.) @Test func namesSortInFinderOrderSoNumbersCountRatherThanCollate() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let cardPath = try boardWithOneCard(fixture) for name in ["shot 10.png", "shot 2.png", "shot.png", "page-10.txt", "page-2.txt"] { try fixture.strayFile("\(cardPath)/attachments/\(name)") } #expect(try card(in: fixture).attachments == [ "page-2.txt", "page-10.txt", "shot 2.png", "shot 10.png", "shot.png", ]) } @Test func anEmptyAttachmentsFolderListsNothing() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let cardPath = try boardWithOneCard(fixture) try fixture.emptyFolder("\(cardPath)/attachments") #expect(try card(in: fixture).attachments.isEmpty) } /// A listing that *cannot* be made degrades to `[]` — here because a hand-editor left a /// `attachments` **file** where the folder would be. Fail-fast is reserved for structure /// (01-storage-format.md § Malformed input); a cosmetic field must never be why a board /// refuses to open, and the file itself is preserved verbatim like any other stray. @Test func anAttachmentsThatIsNotADirectoryDegradesToAnEmptyListing() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let cardPath = try boardWithOneCard(fixture) try fixture.strayFile("\(cardPath)/attachments", contents: "not a folder") let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.first?.cards.first?.attachments.isEmpty == true) #expect(result.warnings.isEmpty) } /// `BoardWriter.listAttachments` — the card window sidebar's authoritative listing — and /// `Card.attachments` are **one enumeration**, so a sidebar and a face looking at the same /// card can never disagree about its files or their order. @Test func theSnapshotsListingAndTheWritersAreTheSameAnswer() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let cardPath = try boardWithOneCard(fixture) for name in ["shot 10.png", "shot 2.png", "shot.png", ".hidden"] { try fixture.strayFile("\(cardPath)/attachments/\(name)") } try fixture.strayFile("\(cardPath)/attachments/sub/nested.txt") let cardFolder = fixture.root.appendingPathComponent(cardPath, isDirectory: true) #expect(try card(in: fixture).attachments == BoardWriter.listAttachments(ofCard: cardFolder)) } } // MARK: - ItemID value semantics (01-storage-format.md § Fractal layout ▸ Rules, "Identity // comparison is UUID-value equality, never string equality") /// `ItemID` stores the folder's exact spelling — it builds URLs — but *compares* as a UUID /// value: two case-spellings of one UUID are one identity everywhere. struct ItemIDValueSemanticsTests { private static let lower = "abcdef01-2345-6789-abcd-ef0123456789" private static let upper = "ABCDEF01-2345-6789-ABCD-EF0123456789" private static let mixed = "AbCdEf01-2345-6789-aBcD-eF0123456789" @Test func caseSpellingsOfOneUUIDAreEqualAndHashAlike() { let lower = ItemID(rawValue: Self.lower) let upper = ItemID(rawValue: Self.upper) let mixed = ItemID(rawValue: Self.mixed) #expect(lower == upper) #expect(lower == mixed) #expect(upper == mixed) #expect(lower.hashValue == upper.hashValue) #expect(upper.hashValue == mixed.hashValue) } /// Equality is by value, but the spelling is never rewritten — the app accepts liberally and /// emits conservatively, and `rawValue` is what builds the folder's URL. @Test func rawValueKeepsTheExactSpelling() { #expect(ItemID(rawValue: Self.upper).rawValue == Self.upper) #expect(ItemID(rawValue: Self.mixed).description == Self.mixed) } @Test func distinctUUIDsAreUnequalHoweverTheyAreSpelled() { let one = ItemID(rawValue: "abcdef01-2345-6789-abcd-ef0123456789") let other = ItemID(rawValue: "ABCDEF01-2345-6789-ABCD-EF012345678A") #expect(one != other) } /// The consequence every `Set`/`Dictionary` keyed by `ItemID` inherits — selection /// membership included (`ItemReferenceSet`). @Test func aSetCollapsesTheTwoSpellingsToOneMember() { let set: Set = [ItemID(rawValue: Self.lower), ItemID(rawValue: Self.upper)] #expect(set.count == 1) #expect(set.contains(ItemID(rawValue: Self.mixed))) var byID: [ItemID: String] = [:] byID[ItemID(rawValue: Self.upper)] = "written uppercase" #expect(byID[ItemID(rawValue: Self.lower)] == "written uppercase") } } // MARK: - Board-level deleted struct BoardLoaderBoardLevelDeletedTests { @Test func boardLevelDeletedIsIgnoredAndWarned() throws { let fixture = try BoardFixture() 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.model.lanes.isEmpty) #expect(result.warnings.contains(.boardLevelDeletedIgnored)) } } // MARK: - Fail-fast struct BoardLoaderFailFastTests { @Test func rootMissingIndexThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } expectFailure(.boardRootMissingIndex, path: "index.md") { _ = try BoardLoader.load(boardRoot: fixture.root) } } @Test func rootThatIsAFileThrowsNotADirectory() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let fileRoot = fixture.root.appendingPathComponent("not-a-folder") try "hello".write(to: fileRoot, atomically: true, encoding: .utf8) expectFailure(.notADirectory, path: ".") { _ = try BoardLoader.load(boardRoot: fileRoot) } } @Test func unreadableRootThrows() throws { let missing = FileManager.default.temporaryDirectory .appendingPathComponent("BoardLoaderTests-does-not-exist-\(UUID().uuidString)") do { _ = try BoardLoader.load(boardRoot: missing) Issue.record("expected a BoardLoadFailure but load succeeded") } catch { // Environmental, so a single-defect aggregate: there is no walk behind an unreadable // root, and nothing for a second defect to come from. #expect(error.defects.count == 1) #expect(error.primary.path == ".") if case .unreadableRoot = error.primary.reason { // expected } else { Issue.record("expected .unreadableRoot, got \(error.primary.reason)") } } } @Test func unparseableYAMLThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } // No closing delimiter. try "---\nschema: 1\n".write( to: fixture.root.appendingPathComponent("index.md"), atomically: true, encoding: .utf8 ) do { _ = try BoardLoader.load(boardRoot: fixture.root) Issue.record("expected a BoardLoadFailure but load succeeded") } catch { #expect(error.primary.path == "index.md") if case .unparseableYAML = error.primary.reason { // expected } else { Issue.record("expected .unparseableYAML, got \(error.primary.reason)") } } } @Test func missingSchemaThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } try fixture.index("", "title: No Schema\n") expectFailure(.missingSchema, path: "index.md") { _ = try BoardLoader.load(boardRoot: fixture.root) } } @Test func malformedSchemaThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } try fixture.index("", "schema: not-a-number\n") expectFailure(.malformedSchema(raw: "not-a-number"), path: "index.md") { _ = try BoardLoader.load(boardRoot: fixture.root) } } @Test func schemaNewerThanAppThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } try fixture.index("", "schema: 2\n") expectFailure(.schemaNewerThanApp(found: 2), path: "index.md") { _ = try BoardLoader.load(boardRoot: fixture.root) } } /// A schema newer than the app fails fast **below** the root too — the one `schema` rule the /// optional-key ruling left alone (01-storage-format.md § Malformed input, re-ruled 2026-07-31). @Test func schemaNewerThanAppOnALaneThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 2\norder: 1024\n") expectFailure(.schemaNewerThanApp(found: 2), path: "\(lane)/index.md") { _ = try BoardLoader.load(boardRoot: fixture.root) } } /// A malformed `schema` below the root still refuses: the ruling made the *absent* key optional, /// not the unreadable one — reading `schema: one` as 1 would be inventing agreement. @Test func malformedSchemaOnACardThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() let card = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") try fixture.index("\(lane)/\(card)", "schema: one\norder: 1024\n") expectFailure(.malformedSchema(raw: "one"), path: "\(lane)/\(card)/index.md") { _ = try BoardLoader.load(boardRoot: fixture.root) } } } // MARK: - Collect-all, and the skip channel /// **"The loader collects every fail-fast defect in the walk rather than stopping at the first"** /// (01-storage-format.md § Malformed input, settled 2026-07-31), and its other half: "Skip is /// user-consented tolerance … per-open decisions, never persisted". /// /// `Fixtures/Malformed/many-defects.kanban` and `skippable-defects.kanban` are the disk-backed golden /// boards for both; these are the edges a fixture cannot hold — the trash container (no fixture board /// carries a `.trash/`) and the environmental failures. @Suite("BoardLoader ▸ collect-all and skip") struct BoardLoaderCollectAndSkipTests { /// `.trash/` is the walk's last container, so its defects land last — the ordering claim stated /// where a fixture cannot state it. @Test("Trash defects collect after the lanes, in walk order") func trashDefectsCollectLast() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = "10000000-0000-4000-8000-000000000001" let card = "20000000-0000-4000-8000-000000000002" let entry = "30000000-0000-4000-8000-000000000003" try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") try fixture.index("\(lane)/\(card)", "schema: 9\norder: 1024\n") try fixture.index(".trash/\(entry)", "schema: 7\nkind: card\n") do { _ = try BoardLoader.load(boardRoot: fixture.root) Issue.record("a board with two fail-fast defects loaded") } catch { #expect(error.defects == [ BoardLoadError(path: "\(lane)/\(card)/index.md", reason: .schemaNewerThanApp(found: 9)), BoardLoadError(path: ".trash/\(entry)/index.md", reason: .schemaNewerThanApp(found: 7)), ]) } } /// A trash entry is skippable like anything else below the root, and skipping it takes it out of /// the container rather than out of the board. @Test("A skipped trash entry leaves the trash and the board loads") func aSkippedTrashEntryLeavesTheTrash() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = "10000000-0000-4000-8000-000000000001" let kept = "20000000-0000-4000-8000-000000000002" let broken = "30000000-0000-4000-8000-000000000003" try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") try fixture.index(".trash/\(kept)", "schema: 1\nkind: card\ntitle: Kept\n") try fixture.index(".trash/\(broken)", "schema: 7\nkind: card\ntitle: Too New\n") let result = try BoardLoader.load( boardRoot: fixture.root, skipping: [".trash/\(broken)/index.md"]) #expect(result.model.trash.map(\.id.rawValue) == [kept]) #expect(result.warnings == [.userSkipped(path: ".trash/\(broken)/index.md")]) } /// **Environmental failures stay immediate**: there is no walk behind a root that is a file, so /// the aggregate has exactly one defect and no board was ever read. @Test("An environmental failure is a single-defect aggregate") func environmentalFailuresAreSingleDefect() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let fileRoot = fixture.root.appendingPathComponent("not-a-folder") try "hello".write(to: fileRoot, atomically: true, encoding: .utf8) do { _ = try BoardLoader.load(boardRoot: fileRoot) Issue.record("a file loaded as a board") } catch { #expect(error.defects == [BoardLoadError(path: ".", reason: .notADirectory)]) // And the environmental path is unskippable too — a skip set naming it changes nothing. } do { _ = try BoardLoader.load(boardRoot: fileRoot, skipping: ["."]) Issue.record("a skip set talked the loader into loading a file as a board") } catch { #expect(error.defects == [BoardLoadError(path: ".", reason: .notADirectory)]) } } /// **A skip is per-open and nothing else**: the same loader call without the set refuses again, /// which is the ruling's "the next open of a still-broken board presents the surface again" /// stated as an assertion. Nothing is written, so nothing can remember. @Test("A skip persists nowhere — the next walk refuses again") func skipsArePerOpen() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = "10000000-0000-4000-8000-000000000001" try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 2\norder: 1024\n") let skipped = try BoardLoader.load(boardRoot: fixture.root, skipping: ["\(lane)/index.md"]) #expect(skipped.model.lanes.isEmpty) expectFailure(.schemaNewerThanApp(found: 2), path: "\(lane)/index.md") { _ = try BoardLoader.load(boardRoot: fixture.root) } } /// The root's four defect shapes are all collected — including the two the design's class list /// does not name (`malformedSchema` at the root, and its below-root twin, covered above) — and /// the walk still reports what it found underneath. @Test("A malformed root schema is collected, and the walk continues under it") func aMalformedRootSchemaStillWalks() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = "10000000-0000-4000-8000-000000000001" try fixture.index("", "schema: one\n") try fixture.index(lane, "schema: 4\norder: 1024\n") do { _ = try BoardLoader.load(boardRoot: fixture.root) Issue.record("a board with a malformed root schema loaded") } catch { #expect(error.defects == [ BoardLoadError(path: "index.md", reason: .malformedSchema(raw: "one")), BoardLoadError(path: "\(lane)/index.md", reason: .schemaNewerThanApp(found: 4)), ]) } } /// A root with no `index.md` at all does not end the walk either: the lanes below it are found by /// folder shape, so the surface can state the root's minted repair *and* what else is wrong in /// the same pass. @Test("A missing root index does not end the walk") func aMissingRootIndexDoesNotEndTheWalk() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = "10000000-0000-4000-8000-000000000001" try fixture.index(lane, "schema: 3\norder: 1024\n") do { _ = try BoardLoader.load(boardRoot: fixture.root) Issue.record("a board with no root index loaded") } catch { #expect(error.defects == [ BoardLoadError(path: "index.md", reason: .boardRootMissingIndex), BoardLoadError(path: "\(lane)/index.md", reason: .schemaNewerThanApp(found: 3)), ]) } } } // MARK: - `order` and `schema` optional below the board root /// **The append-at-end reading** (01-storage-format.md § Ordering, re-ruled 2026-07-31): below the /// board root a missing, null, non-numeric or non-finite `order` is no longer a fail-fast — it reads /// as a rank past every ordered sibling, tie-broken by folder name, and the reading is coerce-tier /// (logged, bytes preserved). `Fixtures/Valid/optional-keys.kanban` is the disk-backed golden case; /// these are the synthetic edges. struct BoardLoaderOptionalOrderTests { /// The zero-read minimum the ruling exists for: a lane with one ranked card and one card whose /// whole frontmatter is a title. @Test func orderlessCardAppendsAfterEveryRankedSibling() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = "10000000-0000-4000-8000-000000000001" let ranked = "20000000-0000-4000-8000-000000000001" // Deliberately the *lower* folder name, so folder order alone would put it first. let orderless = "10000000-0000-4000-8000-000000000009" try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") try fixture.index("\(lane)/\(ranked)", "schema: 1\norder: 4096\n") try fixture.index("\(lane)/\(orderless)", "title: Minimum\n") let model = try BoardLoader.load(boardRoot: fixture.root).model #expect(model.lanes[0].cards.map(\.id.rawValue) == [ranked, orderless]) #expect(model.lanes[0].cards.map(\.order) == [4096, 5120]) } /// Two order-less siblings: folder name decides, and the ranks they read as are `append`'s own /// ladder — which is what lets the Writer stamp them without anything moving. @Test func twoOrderlessSiblingsSortByFolderName() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = "10000000-0000-4000-8000-000000000001" let second = "30000000-0000-4000-8000-000000000002" let first = "20000000-0000-4000-8000-000000000001" try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") try fixture.index("\(lane)/\(second)", "schema: 1\n") try fixture.index("\(lane)/\(first)", "schema: 1\n") let model = try BoardLoader.load(boardRoot: fixture.root).model #expect(model.lanes[0].cards.map(\.id.rawValue) == [first, second]) // No ranked sibling at all, so the ladder bases at 0 — the empty-container convention. #expect(model.lanes[0].cards.map(\.order) == [1024, 2048]) } /// The four unusable shapes are one reading. Each is a coercion carrying the text as written. @Test func everyUnusableOrderShapeReadsAsAppendAtEnd() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = "10000000-0000-4000-8000-000000000001" let anchor = "20000000-0000-4000-8000-000000000000" let shapes: [(id: String, frontmatter: String, raw: String)] = [ ("20000000-0000-4000-8000-000000000001", "schema: 1\n", ""), ("20000000-0000-4000-8000-000000000002", "schema: 1\norder:\n", ""), ("20000000-0000-4000-8000-000000000003", "schema: 1\norder: null\n", "null"), ("20000000-0000-4000-8000-000000000004", "schema: 1\norder: banana\n", "banana"), ("20000000-0000-4000-8000-000000000005", "schema: 1\norder: .nan\n", ".nan"), ("20000000-0000-4000-8000-000000000006", "schema: 1\norder: .inf\n", ".inf"), ] try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") try fixture.index("\(lane)/\(anchor)", "schema: 1\norder: 2048\n") for shape in shapes { try fixture.index("\(lane)/\(shape.id)", shape.frontmatter) } let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes[0].cards.map(\.id.rawValue) == [anchor] + shapes.map(\.id)) #expect(result.model.lanes[0].cards.map(\.order) == [2048, 3072, 4096, 5120, 6144, 7168, 8192]) let coerced = Dictionary( uniqueKeysWithValues: result.coercedFrontmatter.map { ($0.path, $0.fields) }) for shape in shapes { #expect( coerced["\(lane)/\(shape.id)/index.md"] == [CoercedField(key: "order", raw: shape.raw)], "\(shape.frontmatter) should coerce with raw '\(shape.raw)'" ) } } /// The rule holds one level up: an order-less lane sits right of every ranked one. @Test func orderlessLaneAppendsAtTheEndOfTheStrip() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let ranked = "90000000-0000-4000-8000-000000000001" let orderless = "10000000-0000-4000-8000-000000000002" try fixture.index("", "schema: 1\n") try fixture.index(ranked, "schema: 1\norder: 1024\n") try fixture.index(orderless, "schema: 1\n") let model = try BoardLoader.load(boardRoot: fixture.root).model #expect(model.lanes.map(\.id.rawValue) == [ranked, orderless]) #expect(model.lanes.map(\.order) == [1024, 2048]) } /// A missing `schema` below the root reads as 1 and records a coercion; the **root's** own /// missing `schema` is still the loud rejection (`missingSchemaThrows` above). @Test func missingSchemaBelowRootReadsAsOne() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() let card = uuidFolderName() try fixture.index("", "schema: 1\n") try fixture.index(lane, "order: 1024\ntitle: No Schema Lane\n") try fixture.index("\(lane)/\(card)", "order: 1024\ntitle: No Schema Card\n") let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes[0].schema == 1) #expect(result.model.lanes[0].cards[0].schema == 1) let paths = Set(result.coercedFrontmatter.map(\.path)) #expect(paths == ["\(lane)/index.md", "\(lane)/\(card)/index.md"]) #expect(result.coercedFrontmatter.allSatisfy { $0.fields == [CoercedField(key: "schema", raw: "")] }) } /// A trash entry without a rank reads like every other order-less file. `order` decides nothing /// about where a trash row sits — `modified` does — so this is only about the rank it carries /// back out on a restore. @Test func orderlessTrashEntryReadsAsAppendAtEnd() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let ranked = "20000000-0000-4000-8000-000000000001" let orderless = "10000000-0000-4000-8000-000000000002" try fixture.index("", "schema: 1\n") try fixture.index(".trash/\(ranked)", "schema: 1\nkind: card\norder: 1024\n") try fixture.index(".trash/\(orderless)", "schema: 1\nkind: card\n") let model = try BoardLoader.load(boardRoot: fixture.root).model let byID = Dictionary(uniqueKeysWithValues: model.trash.map { ($0.id.rawValue, $0.order) }) #expect(byID[ranked] == 1024) #expect(byID[orderless] == 2048) } } // MARK: - Encoding strictness /// The loader decodes byte-faithfully (no NSString BOM-stripping) so the settled encoding /// contract (01-storage-format.md § Fractal layout ▸ Rules) actually holds at load time: a /// BOM'd file fails the frontmatter delimiter, a non-UTF-8 file is named as such — the same /// strict decode `BoardWriter` uses, so a file can never load here and then refuse every write. struct BoardLoaderEncodingTests { @Test func bomPrefixedBoardIndexIsRejected() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } var bytes = Data([0xEF, 0xBB, 0xBF]) bytes.append(Data("---\nschema: 1\n---\nbody\n".utf8)) try bytes.write(to: fixture.root.appendingPathComponent("index.md")) #expect { try BoardLoader.load(boardRoot: fixture.root) } throws: { error in guard let failure = error as? BoardLoadFailure, case .unparseableYAML = failure.primary.reason else { return false } return failure.primary.path == "index.md" } } @Test func nonUTF8BoardIndexIsRejected() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } // "café" in ISO-8859-1 — 0xE9 is not valid UTF-8. var bytes = Data("---\nschema: 1\ntitle: caf".utf8) bytes.append(0xE9) bytes.append(Data("\n---\n".utf8)) try bytes.write(to: fixture.root.appendingPathComponent("index.md")) #expect { try BoardLoader.load(boardRoot: fixture.root) } throws: { error in guard let failure = error as? BoardLoadFailure, case let .unparseableYAML(message, _) = failure.primary.reason else { return false } return message == "file is not UTF-8" } } } // MARK: - The coerce tier's trace (01-storage-format.md § Frontmatter, ruled 2026-07-29) /// **"A no-sensible-reading fallback logs"** — field, path, and raw text, carried as coerce-tier /// entries in the integrity service's Defect stream: /// /// > the one place where an observed-in-the-wild shape can later be promoted to a heuristic heal or a /// > notice; no banner, no behavior change. /// /// So these tests assert two things at once, and the second matters as much as the first: the fallback /// is *reported*, and nothing about the board changed because of it — the field still renders its /// default, the bytes on disk are still verbatim, and no heal is scheduled. @Suite("BoardLoader ▸ coerce-tier fallbacks") struct BoardLoaderCoercionTraceTests { /// The pure half first: which lenient fields report, and which deliberately do not. /// /// `schema` and `order` are the **refuse** tier — a malformed one fails the load loudly, so there is /// no silent recovery to leave a trace of — and `deleted`'s rule is presence-not-validity, so /// nothing falls back to a default there either. @Test("The document reports its lenient fallbacks, and only those") func theDocumentReportsItsLenientFallbacks() throws { let document = try FrontmatterDocument.parse(""" --- schema: 1 order: 1024 title: [a, b] width: 1.5 created: not-a-date icon: {a: b} deleted: also-not-a-date --- Body. """) let byKey = Dictionary(uniqueKeysWithValues: document.coercedFields.map { ($0.key, $0.raw) }) #expect(Set(byKey.keys) == ["title", "width", "created", "icon"]) #expect(byKey["width"] == "1.5", "the raw text as written — what a future heuristic would read") #expect(byKey["created"] == "not-a-date") #expect(byKey["deleted"] == nil, "presence, not validity, decides a tombstone") } @Test("A clean document reports nothing") func aCleanDocumentReportsNothing() throws { let document = try FrontmatterDocument.parse(""" --- schema: 1 order: 1024 title: Fine width: 2 --- """) #expect(document.coercedFields.isEmpty) } /// **A scalar of the wrong type is not a fallback** — it coerces to the text the author typed /// (`title: 2048` reads as "2048"), which is a *successful* reading and leaves no trace. Only "no /// sensible reading exists" does. @Test("A coerced scalar leaves no trace — it was read, not defaulted") func aCoercedScalarLeavesNoTrace() throws { let document = try FrontmatterDocument.parse("---\nschema: 1\ntitle: 2048\nwidth: \"3\"\n---\n") #expect(document.title.value == "2048") #expect(document.width.value == 3) #expect(document.coercedFields.isEmpty) } /// The loader's half: the path is attached at every level, because the rule is about fields and /// every level has them. @Test("The loader attaches the path, at every level") func theLoaderAttachesThePath() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() let card = uuidFolderName() let trashed = uuidFolderName() try fixture.index("", "schema: 1\ntitle: [a, b]\n") try fixture.index(lane, "schema: 1\norder: 1024\nwidth: 1.5\n") try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\nicon: {x: y}\n") try fixture.index(".trash/\(trashed)", "schema: 1\norder: 1024\ncreated: nope\n") let reported = try BoardLoader.load(boardRoot: fixture.root).coercedFrontmatter let byPath = Dictionary(uniqueKeysWithValues: reported.map { ($0.path, $0.fields.map(\.key)) }) #expect(byPath["index.md"] == ["title"]) #expect(byPath["\(lane)/index.md"] == ["width"]) #expect(byPath["\(lane)/\(card)/index.md"] == ["icon"]) #expect(byPath[".trash/\(trashed)/index.md"] == ["created"]) } /// **No behavior change** — the whole point of the tier. The fields render their defaults exactly as /// they did before anything was reported, and the bytes are preserved verbatim. @Test("Nothing about the board changes — defaults render, bytes stay") func nothingChanges() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() try fixture.index("", "schema: 1\ntitle: Board\n") try fixture.index(lane, "schema: 1\norder: 1024\ntitle: [a, b]\nwidth: 0.5\n") let before = try Data(contentsOf: fixture.root.appendingPathComponent("\(lane)/index.md")) let result = try BoardLoader.load(boardRoot: fixture.root) let loaded = try #require(result.model.lanes.first) #expect(loaded.title.isMalformed, "the field still reads as malformed") #expect(loaded.title.value == nil, "and renders its default — the untitled placeholder") #expect(loaded.width.value == nil, "width falls back to 1 at the render layer, not here") #expect(try Data(contentsOf: fixture.root.appendingPathComponent("\(lane)/index.md")) == before, "read-side only: the loader never writes") #expect(result.warnings.isEmpty, "a coercion is not a stray warning") } /// **It is not healable work**, which is why it has no class: a class is a memo key and a /// banner-posture row in the engine, and inventing one would arm a memo against a repair nobody /// wrote. The other defects keep theirs. @Test("A coerce-tier defect has no heal class, and signs per field") func itHasNoHealClass() { let defect = IntegrityRules.Defect.coercedFrontmatter(CoercedFrontmatter( path: "lane/card/index.md", fields: [CoercedField(key: "width", raw: "1.5"), CoercedField(key: "icon", raw: "{}")] )) #expect(defect.healClass == nil) #expect(Set(defect.signatures) == [ "coerce:lane/card/index.md:width", "coerce:lane/card/index.md:icon", ]) } } // MARK: - The parse memo private extension BoardFixture { /// A file's modification date, forced. Used wherever a test rewrites bytes without changing the /// byte *count*: the stamp rule is the subject there, and leaving it to the filesystem clock /// would make the assertion a race against timestamp resolution rather than a statement about /// mtime. func setModified(_ relativePath: String, to date: Date) throws { let folder = relativePath.isEmpty ? root : root.appendingPathComponent(relativePath, isDirectory: true) try FileManager.default.setAttributes( [.modificationDate: date], ofItemAtPath: folder.appendingPathComponent("index.md").path ) } } /// A board with every container the walk has: a root, two lanes, two cards in the first, one /// attachment, and one trashed card — so "the whole tree came out of the memo" is a claim about all /// four levels rather than about lanes. private func memoBoard() throws -> (fixture: BoardFixture, lane: String, card: String) { let fixture = try BoardFixture() let lane = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" let card = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" try fixture.index("", "schema: 1\ntitle: Board\n") try fixture.index(lane, "schema: 1\norder: 1024\ntitle: Todo\n") try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\ntitle: First\n") try fixture.index("\(lane)/cccccccc-cccc-4ccc-8ccc-cccccccccccc", "schema: 1\norder: 2048\ntitle: Second\n") try fixture.index(".trash/dddddddd-dddd-4ddd-8ddd-dddddddddddd", "schema: 1\norder: 1024\ntitle: Gone\n") try fixture.strayFile("\(lane)/\(card)/attachments/shot.png", contents: "png") return (fixture, lane, card) } /// Every `index.md` the board above holds — the number a warm walk must answer for without opening /// one of them. private let memoBoardIndexCount = 5 /// A whole-second modification date, forced onto a file wherever a test needs two stamps to compare /// *exactly*. A date read back off the filesystem does not necessarily round-trip through /// `setAttributes` bit for bit (`Date` is a `Double` and the syscall's is a `timespec`), and a test /// about mtime equality must not turn into a test about that conversion. private let pinnedMtime = Date(timeIntervalSince1970: 1_750_000_000) /// **The walk memoizes its parse, never its result** (02-architecture.md § Live-reload resilience, /// blessed 2026-07-31 — `BoardLoader.ParseMemo`). /// /// Two claims that pull in opposite directions, which is why they are pinned side by side. The memo /// has to actually save the reads: a walk over an untouched tree opens no `index.md` at all. And it /// has to be undetectable in the answer, for the files it saved and — harder — for everything it /// deliberately does not cover, which is every directory listing the walk makes. The equivalence half /// is stated again over the golden fixture boards (`FixtureMemoEquivalenceTests`); this suite pins the /// mechanism file by file, where a synthetic tree can be edited between two walks. @Suite("BoardLoader ▸ the parse memo") struct BoardLoaderParseMemoTests { @Test("A cold walk parses every index.md and reuses nothing") func aColdWalkParsesEverything() throws { let (fixture, _, _) = try memoBoard() defer { fixture.tearDown() } let counter = BoardLoader.ParseCounter() let result = try BoardLoader.load(boardRoot: fixture.root, counter: counter) #expect(counter.counts == .init(parsed: memoBoardIndexCount, reused: 0)) #expect(result.memo.count == memoBoardIndexCount) } @Test("A walk over an untouched tree opens no index.md at all") func anUntouchedTreeIsAnsweredEntirelyFromTheMemo() throws { let (fixture, _, _) = try memoBoard() defer { fixture.tearDown() } let cold = try BoardLoader.load(boardRoot: fixture.root) let counter = BoardLoader.ParseCounter() let warm = try BoardLoader.load(boardRoot: fixture.root, memo: cold.memo, counter: counter) #expect(counter.counts == .init(parsed: 0, reused: memoBoardIndexCount)) // The whole of the claim's other half: the cheap walk and the cold one are the same walk. #expect(warm.model == cold.model) #expect(warm.warnings == cold.warnings) #expect(warm.defects == cold.defects) #expect(warm.trashKinds == cold.trashKinds) #expect(warm.memo.count == cold.memo.count) } @Test("A single-file echo re-parses exactly that file") func oneEditedFileIsTheOnlyOneReParsed() throws { let (fixture, lane, card) = try memoBoard() defer { fixture.tearDown() } let cold = try BoardLoader.load(boardRoot: fixture.root) try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\ntitle: Renamed\n") let counter = BoardLoader.ParseCounter() let warm = try BoardLoader.load(boardRoot: fixture.root, memo: cold.memo, counter: counter) #expect(counter.counts == .init(parsed: 1, reused: memoBoardIndexCount - 1)) #expect(warm.model.lanes.first?.cards.first?.title.value == "Renamed") // And the cold answer is still the answer: the memo changed the cost, nothing else. #expect(warm.model == (try BoardLoader.load(boardRoot: fixture.root).model)) } @Test("A same-size rewrite still re-parses, because the mtime moved") func aSameSizeRewriteReParses() throws { let (fixture, lane, card) = try memoBoard() defer { fixture.tearDown() } try fixture.setModified("\(lane)/\(card)", to: pinnedMtime) let cold = try BoardLoader.load(boardRoot: fixture.root) // "First" → "Third": identical byte count, so `size` alone would call this unchanged. try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\ntitle: Third\n") try fixture.setModified("\(lane)/\(card)", to: pinnedMtime.addingTimeInterval(1)) let counter = BoardLoader.ParseCounter() let warm = try BoardLoader.load(boardRoot: fixture.root, memo: cold.memo, counter: counter) #expect(counter.counts == .init(parsed: 1, reused: memoBoardIndexCount - 1)) #expect(warm.model.lanes.first?.cards.first?.title.value == "Third") } /// **The heuristic's stated blind spot, pinned rather than discovered** (02-architecture.md: "The /// mtime+size trust is the git-index heuristic; a writer that defeats it — content changed, mtime /// and size both preserved — is outside the app's care"). /// /// It is here so the boundary is a decision with a test on it: this is the one shape in which a /// memoized walk and a cold walk disagree, and the design says so out loud. @Test("A rewrite that preserves both mtime and size is trusted — the git-index heuristic's edge") func aRewritePreservingTheStampIsTrusted() throws { let (fixture, lane, card) = try memoBoard() defer { fixture.tearDown() } try fixture.setModified("\(lane)/\(card)", to: pinnedMtime) let cold = try BoardLoader.load(boardRoot: fixture.root) try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\ntitle: Third\n") try fixture.setModified("\(lane)/\(card)", to: pinnedMtime) let counter = BoardLoader.ParseCounter() let warm = try BoardLoader.load(boardRoot: fixture.root, memo: cold.memo, counter: counter) #expect(counter.counts == .init(parsed: 0, reused: memoBoardIndexCount)) #expect(warm.model.lanes.first?.cards.first?.title.value == "First") // And the very next walk that *does* see a moved stamp catches up — the window is one write, // not a standing state. try fixture.setModified("\(lane)/\(card)", to: pinnedMtime.addingTimeInterval(1)) let next = try BoardLoader.load(boardRoot: fixture.root, memo: warm.memo) #expect(next.model.lanes.first?.cards.first?.title.value == "Third") } // MARK: Directory enumeration is never memoized @Test("An attachment arriving is seen by a walk that parsed nothing") func attachmentListingsStayFresh() throws { let (fixture, lane, card) = try memoBoard() defer { fixture.tearDown() } let cold = try BoardLoader.load(boardRoot: fixture.root) // An attachment never touches `index.md`, which is exactly why the memo must not cover the // listing that finds it. try fixture.strayFile("\(lane)/\(card)/attachments/second.png", contents: "png") let counter = BoardLoader.ParseCounter() let warm = try BoardLoader.load(boardRoot: fixture.root, memo: cold.memo, counter: counter) #expect(counter.counts == .init(parsed: 0, reused: memoBoardIndexCount)) #expect(warm.model.lanes.first?.cards.first?.attachments == ["second.png", "shot.png"]) } @Test("A loose file arriving beside an untouched index.md is still a defect") func looseFileDetectionStaysFresh() throws { let (fixture, lane, card) = try memoBoard() defer { fixture.tearDown() } let cold = try BoardLoader.load(boardRoot: fixture.root) #expect(cold.looseCardFiles.isEmpty) try fixture.strayFile("\(lane)/\(card)/notes.txt", contents: "loose") let counter = BoardLoader.ParseCounter() let warm = try BoardLoader.load(boardRoot: fixture.root, memo: cold.memo, counter: counter) #expect(counter.counts == .init(parsed: 0, reused: memoBoardIndexCount)) #expect(warm.looseCardFiles.map(\.fileNames) == [["notes.txt"]]) } @Test("A new card folder is discovered, and it is the only file the walk opens") func folderDiscoveryStaysFresh() throws { let (fixture, lane, _) = try memoBoard() defer { fixture.tearDown() } let cold = try BoardLoader.load(boardRoot: fixture.root) let arrival = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" try fixture.index("\(lane)/\(arrival)", "schema: 1\norder: 4096\ntitle: Arrived\n") let counter = BoardLoader.ParseCounter() let warm = try BoardLoader.load(boardRoot: fixture.root, memo: cold.memo, counter: counter) #expect(counter.counts == .init(parsed: 1, reused: memoBoardIndexCount)) #expect(warm.model.lanes.first?.cards.map(\.title.value) == ["First", "Second", "Arrived"]) } @Test("A vanished card leaves the model, and its memo entry goes with it") func aVanishedItemLeavesTheMemo() throws { let (fixture, lane, card) = try memoBoard() defer { fixture.tearDown() } let cold = try BoardLoader.load(boardRoot: fixture.root) try FileManager.default.removeItem(at: fixture.root.appendingPathComponent("\(lane)/\(card)")) let warm = try BoardLoader.load(boardRoot: fixture.root, memo: cold.memo) #expect(warm.model.lanes.first?.cards.map(\.title.value) == ["Second"]) #expect(warm.memo.count == memoBoardIndexCount - 1) } // MARK: Defects can never be answered from it @Test("A defective index.md is never memoized, so a still-broken file is re-read every walk") func aDefectIsNeverMemoized() throws { let (fixture, lane, card) = try memoBoard() defer { fixture.tearDown() } let healthy = try BoardLoader.load(boardRoot: fixture.root) try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\nlabels: [a, b\n") // The file does not change between these two walks, and neither of them can go quiet about // it: a defective `index.md` is never recorded, so there is nothing for a later walk to hit. // The healthy memo still spares the four files that *did* load, which is the point — the // defect costs one read, not a cold walk. for _ in 0..<2 { let counter = BoardLoader.ParseCounter() do throws(BoardLoadFailure) { _ = try BoardLoader.load(boardRoot: fixture.root, memo: healthy.memo, counter: counter) Issue.record("expected the broken card to fail the walk") } catch { #expect(error.defects.map(\.path) == ["\(lane)/\(card)/index.md"]) } #expect(counter.counts == .init(parsed: 1, reused: memoBoardIndexCount - 1)) } // And the aggregate is the cold aggregate, defect for defect. do throws(BoardLoadFailure) { _ = try BoardLoader.load(boardRoot: fixture.root, memo: healthy.memo) Issue.record("expected the broken card to fail the walk") } catch let warm { do throws(BoardLoadFailure) { _ = try BoardLoader.load(boardRoot: fixture.root) Issue.record("expected the broken card to fail the walk") } catch let cold { #expect(warm.defects == cold.defects) } } } @Test("A repaired file re-parses and rejoins the board") func aRepairedFileReParses() throws { let (fixture, lane, card) = try memoBoard() defer { fixture.tearDown() } let healthy = try BoardLoader.load(boardRoot: fixture.root) try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\nlabels: [a, b\n") try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\ntitle: Repaired\n") let warm = try BoardLoader.load(boardRoot: fixture.root, memo: healthy.memo) #expect(warm.model.lanes.first?.cards.first?.title.value == "Repaired") } @Test("A skip is recomputed from a fresh parse, memo or no memo") func aSkipComposesWithTheMemo() throws { let (fixture, lane, card) = try memoBoard() defer { fixture.tearDown() } let healthy = try BoardLoader.load(boardRoot: fixture.root) try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\nlabels: [a, b\n") let skips: Set = ["\(lane)/\(card)/index.md"] let counter = BoardLoader.ParseCounter() let warm = try BoardLoader.load( boardRoot: fixture.root, skipping: skips, memo: healthy.memo, counter: counter) let cold = try BoardLoader.load(boardRoot: fixture.root, skipping: skips) // A skipped path is a defect path, so it was never in the memo and is read on every walk. #expect(counter.counts.parsed == 1) #expect(warm.model == cold.model) #expect(warm.warnings == cold.warnings) #expect(warm.warnings.contains(.userSkipped(path: "\(lane)/\(card)/index.md"))) // The skipped item is out of the board and out of the memo, both walks alike. #expect(warm.memo.count == memoBoardIndexCount - 1) } @Test("A skipped path stays skipped across a memoized reload") func aSkipHoldsAcrossReloads() throws { let (fixture, lane, card) = try memoBoard() defer { fixture.tearDown() } let healthy = try BoardLoader.load(boardRoot: fixture.root) try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\nlabels: [a, b\n") let skips: Set = ["\(lane)/\(card)/index.md"] let first = try BoardLoader.load(boardRoot: fixture.root, skipping: skips, memo: healthy.memo) let second = try BoardLoader.load(boardRoot: fixture.root, skipping: skips, memo: first.memo) #expect(second.model == first.model) #expect(second.warnings == first.warnings) #expect(second.model.lanes.first?.cards.map(\.title.value) == ["Second"]) } }