TransientBoardState, one per store, holds state by how a reload treats it: item-referencing sets (selection, drag membership, pending cut) share one shape and one constraint rule — members must exist in the current universe — applied in two directions by one primitive, so the search filter's hidden-cards rule and reload survival are one rule expressed once; derived state is stored as its inputs only (the query, never its result set); and the new-card placeholder is a lane-anchored overlay with no UUID until commit, discarded when its lane vanishes or tombstones, handed off when the created card's UUID appears. Trash visibility rides along per-open, never persisted. The decision is written back into DESIGN/02 § Changes from Kanban — the TBD is closed. Full suite 352 tests in 64 suites green. Two findings filed. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
662 lines
27 KiB
Swift
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 (`ItemReferenceSet`).
|
|
@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"
|
|
}
|
|
}
|
|
}
|