Files
lanework/KanbanTests/BoardLoaderTests.swift
T
rzen 4418b7f981 Align the identity predicate with the ratified shape-only rule
Accept liberally, emit conservatively (764a4d4): the gate is 8-4-4-4-12
hex in any case and any UUID version — uuidgen and UUID().uuidString
print uppercase, and a strict lowercase gate would silently stray an
agent's standard-tool card. Identity comparison is UUID-value equality
everywhere: ItemID keeps its byte-faithful rawValue but equates and
hashes on the lowercased canonical form, and the writer's
import-boundary collision check canonicalizes, so a same-UUID arrival
spelled in another case remints instead of slipping past. The app still
mints only lowercase v4 and never renames to canonicalize.

Full suite 342 tests in 63 suites green. Two findings filed.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
2026-07-26 20:53:38 -04:00

662 lines
27 KiB
Swift

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()
}
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 error as BoardLoadError {
#expect(error.path == path)
#expect(error.reason == expectedReason)
} catch {
Issue.record("expected a BoardLoadError, 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)
}
}
// 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")
// Missing 'order' — would be a fail-fast .missingOrder if this were UUID-shaped.
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" now: `attachments/` and
/// `comments/` are non-UUID-shaped, and this loader never scans a card folder's contents
/// anyway (cards are leaves) — either way, they must never surface a warning.
@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: - 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 (`BoardStore.Selection`).
@Test func aSetCollapsesTheTwoSpellingsToOneMember() {
let set: Set<ItemID> = [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 BoardLoadError but load succeeded")
} catch {
#expect(error.path == ".")
if case .unreadableRoot = error.reason {
// expected
} else {
Issue.record("expected .unreadableRoot, got \(error.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 BoardLoadError but load succeeded")
} catch {
#expect(error.path == "index.md")
if case .unparseableYAML = error.reason {
// expected
} else {
Issue.record("expected .unparseableYAML, got \(error.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 UUID-shaped folder still fails fast on structurally-bad content — the name shape only
/// gates *candidacy*, never the validity of a folder that qualifies.
@Test func missingOrderOnUUIDLaneThrows() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\n")
expectFailure(.missingOrder, path: "\(lane)/index.md") {
_ = try BoardLoader.load(boardRoot: fixture.root)
}
}
@Test func malformedOrderOnUUIDLaneThrows() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: not-a-number\n")
expectFailure(.malformedOrder(raw: "not-a-number"), path: "\(lane)/index.md") {
_ = try BoardLoader.load(boardRoot: fixture.root)
}
}
@Test func missingOrderOnUUIDCardThrows() 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\n")
expectFailure(.missingOrder, path: "\(lane)/\(card)/index.md") {
_ = try BoardLoader.load(boardRoot: fixture.root)
}
}
}
// 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 loadError = error as? BoardLoadError,
case .unparseableYAML = loadError.reason else { return false }
return loadError.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 loadError = error as? BoardLoadError,
case let .unparseableYAML(message, _) = loadError.reason else { return false }
return message == "file is not UTF-8"
}
}
}