The loader collects every fail-fast defect and honors per-open skips

Phase 1 of the decision surface (01 ▸ Malformed input, settled
2026-07-31): BoardLoadFailure aggregates the walk's defects in walk
order — stop-at-first retires. Environmental failures (unreadable root,
not-a-directory) stay immediate single-defect throws: there is no walk
to collect from. A defective root index is recorded and the walk
continues into the children (nothing in the walk consults the parsed
root document — verified); a defective lane, card, or trash-entry index
records and skips its subtree, Re-check's whole-walk re-aggregation
being the designed loop for what hides beneath. load(skipping:) is the
per-open skip channel: a skipped path's item is omitted from the model
and surfaces as LoadWarning.userSkipped; root paths are unskippable by
construction. The reload-breakage banner carries the aggregate ("…and
N more"), single-defect sentences byte-identical to before. Two new
multi-defect fixture boards; suite 2591 green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 09:12:49 -04:00
parent 94e60cd444
commit ba1726fa77
35 changed files with 897 additions and 117 deletions
+2 -2
View File
@@ -179,7 +179,7 @@ struct AppModelTests {
let recordID = model.boardRegistry.recordOpen(of: fixture.root)
var failureMessage: String?
do throws(BoardLoadError) {
do throws(BoardLoadFailure) {
_ = try model.storeRegistry.acquire(fixture.root)
Issue.record("expected the load to fail fail-fast")
} catch {
@@ -217,7 +217,7 @@ struct AppModelTests {
let ref = BoardWindowRef(url: fixture.root)
let firstAttempt = model.boardRegistry.recordOpen(of: fixture.root)
do throws(BoardLoadError) {
do throws(BoardLoadFailure) {
_ = try model.storeRegistry.acquire(fixture.root)
Issue.record("expected the load to fail fail-fast")
} catch {
+36 -6
View File
@@ -97,7 +97,7 @@ struct BannerCenterOrderingTests {
let rows = BannerCenter.rows(
lock: .vanishedRoot,
breakage: BoardLoadError(path: "todo/index.md", reason: .missingOrder),
breakage: BoardLoadFailure(BoardLoadError(path: "todo/index.md", reason: .missingOrder)),
oneShots: [attachment, move],
losses: [loss],
suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)),
@@ -322,7 +322,7 @@ struct BannerCenterLifecycleTests {
// heal", 02 § The banner surface), and this is where it is stated as a test.
let rows = BannerCenter.rows(
lock: .bracketedReloadFailed,
breakage: BoardLoadError(path: ".", reason: .boardRootMissingIndex),
breakage: BoardLoadFailure(BoardLoadError(path: ".", reason: .boardRootMissingIndex)),
oneShots: center.oneShots,
losses: [],
suspension: HistorySuspension(reason: "disk full"),
@@ -603,7 +603,7 @@ struct BannerRowControlsTests {
func conditionRowsCarryNoControls() {
let rows: [BannerRow] = [
.readOnlyLock(.vanishedRoot),
.reloadBreakage(BoardLoadError(path: "Todo/index.md", reason: .missingOrder)),
.reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))),
.historySuspended(HistorySuspension(reason: "the repository is corrupt")),
]
@@ -618,7 +618,7 @@ struct BannerRowControlsTests {
func inventoryAgreesWithDismissID() {
let rows: [BannerRow] = [
.readOnlyLock(.vanishedRoot),
.reloadBreakage(BoardLoadError(path: "Todo/index.md", reason: .missingOrder)),
.reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))),
.oneShot(OneShotBanner(error: error(.move(title: "Fix login")))),
.gitFailure(GitFailureBanner(operation: .branchSwitch, reason: "the repository is locked")),
.loss(LossBanner(message: "Pasted 'Fix login' without its 3 attachments")),
@@ -790,7 +790,9 @@ struct BannerCenterPhrasingTests {
@Test("Reload breakage carries fail-fast's specifics — the path and what is wrong with it")
func breakageHeadlineNamesThePath() {
let headline = BannerCenter.headline(
for: BoardLoadError(path: "todo/fix-login/index.md", reason: .unparseableYAML(message: "unexpected end", line: 4))
for: BoardLoadFailure(BoardLoadError(
path: "todo/fix-login/index.md",
reason: .unparseableYAML(message: "unexpected end", line: 4)))
)
#expect(headline.contains("'todo/fix-login/index.md'"))
#expect(headline.contains("line 4"))
@@ -798,11 +800,39 @@ struct BannerCenterPhrasingTests {
// The board's own index.md reports as "." a lone dot in the product's voice would be a
// bug report, not a sentence.
let rootHeadline = BannerCenter.headline(for: BoardLoadError(path: ".", reason: .boardRootMissingIndex))
let rootHeadline = BannerCenter.headline(
for: BoardLoadFailure(BoardLoadError(path: ".", reason: .boardRootMissingIndex)))
#expect(!rootHeadline.contains("'.'"))
#expect(rootHeadline.hasPrefix("This board isn't loading"))
}
/// **One defect named, the rest counted** (01-storage-format.md § Malformed input the loader
/// collects every fail-fast defect in a walk). A banner is one line, so the sentence stays the
/// sentence it always was and the remainder rides as a count; the full list is the decision
/// surface's to show on the next attended open.
///
/// Both spellings are pinned, because the single-defect one is what every existing surface reads
/// and it must not have drifted when the aggregate arrived.
@Test("A multi-defect breakage names the first and counts the rest")
func breakageHeadlineCountsTheRest() {
let first = BoardLoadError(path: "index.md", reason: .missingSchema)
let second = BoardLoadError(path: "todo/index.md", reason: .schemaNewerThanApp(found: 2))
let third = BoardLoadError(path: "done/index.md", reason: .malformedSchema(raw: "one"))
#expect(BannerCenter.headline(for: BoardLoadFailure([first]))
== "'index.md' isn't loading: missing required 'schema' field — showing the last good view")
#expect(BannerCenter.headline(for: BoardLoadFailure([first, second]))
== "'index.md' isn't loading: missing required 'schema' field, and 1 more — showing the last good view")
#expect(BannerCenter.headline(for: BoardLoadFailure([first, second, third]))
== "'index.md' isn't loading: missing required 'schema' field, and 2 more — showing the last good view")
// The row that carries it says the same thing the headline is not re-derived anywhere.
#expect(BannerRow.reloadBreakage(BoardLoadFailure([first, second])).headline
== BannerCenter.headline(for: BoardLoadFailure([first, second])))
}
@Test("The suspended-history line names the consequence, then the diagnosis")
func suspensionHeadlineNamesTheConsequence() {
#expect(BannerCenter.headline(for: HistorySuspension(reason: "the disk is full"))
+2 -2
View File
@@ -316,8 +316,8 @@ struct BoardAnnouncerSpeechTests {
return diff
}
private func breakage() -> BoardLoadError {
BoardLoadError(path: "Todo/index.md", reason: .missingOrder)
private func breakage() -> BoardLoadFailure {
BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))
}
// MARK: Provenance
+175 -17
View File
@@ -57,6 +57,8 @@ 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,
@@ -65,11 +67,10 @@ private func expectFailure(
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 let failure as BoardLoadFailure {
#expect(failure.defects == [BoardLoadError(path: path, reason: expectedReason)])
} catch {
Issue.record("expected a BoardLoadError, got \(error)")
Issue.record("expected a BoardLoadFailure, got \(error)")
}
}
@@ -658,13 +659,16 @@ struct BoardLoaderFailFastTests {
do {
_ = try BoardLoader.load(boardRoot: missing)
Issue.record("expected a BoardLoadError but load succeeded")
Issue.record("expected a BoardLoadFailure but load succeeded")
} catch {
#expect(error.path == ".")
if case .unreadableRoot = error.reason {
// 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.reason)")
Issue.record("expected .unreadableRoot, got \(error.primary.reason)")
}
}
}
@@ -682,13 +686,13 @@ struct BoardLoaderFailFastTests {
do {
_ = try BoardLoader.load(boardRoot: fixture.root)
Issue.record("expected a BoardLoadError but load succeeded")
Issue.record("expected a BoardLoadFailure but load succeeded")
} catch {
#expect(error.path == "index.md")
if case .unparseableYAML = error.reason {
#expect(error.primary.path == "index.md")
if case .unparseableYAML = error.primary.reason {
// expected
} else {
Issue.record("expected .unparseableYAML, got \(error.reason)")
Issue.record("expected .unparseableYAML, got \(error.primary.reason)")
}
}
}
@@ -761,6 +765,160 @@ struct BoardLoaderFailFastTests {
}
}
// 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
@@ -925,9 +1083,9 @@ struct BoardLoaderEncodingTests {
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"
guard let failure = error as? BoardLoadFailure,
case .unparseableYAML = failure.primary.reason else { return false }
return failure.primary.path == "index.md"
}
}
@@ -942,8 +1100,8 @@ struct BoardLoaderEncodingTests {
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 }
guard let failure = error as? BoardLoadFailure,
case let .unparseableYAML(message, _) = failure.primary.reason else { return false }
return message == "file is not UTF-8"
}
}
+7 -7
View File
@@ -204,12 +204,12 @@ struct BoardStoreRegistryTests {
try fixture.item(Ident.lane1, brokenIndex)
let registry = BoardStoreRegistry()
do throws(BoardLoadError) {
do throws(BoardLoadFailure) {
_ = try registry.acquire(fixture.root)
Issue.record("expected the load to fail fast")
} catch {
if case .unparseableYAML = error.reason {} else {
Issue.record("expected unparseable YAML, got \(error.reason)")
if case .unparseableYAML = error.primary.reason {} else {
Issue.record("expected unparseable YAML, got \(error.primary.reason)")
}
}
@@ -265,15 +265,15 @@ struct BoardStoreRegistryTests {
let missing = FileManager.default.temporaryDirectory
.appendingPathComponent("no-such-board-\(UUID().uuidString)", isDirectory: true)
do throws(BoardLoadError) {
do throws(BoardLoadFailure) {
_ = try registry.acquire(missing)
Issue.record("expected a missing root to fail")
} catch {
// The identity read fails first, and the registry deliberately says nothing about that
// it lets `BoardStore`'s load produce the honest reason.
#expect(error.path == ".")
if case .unreadableRoot = error.reason {} else {
Issue.record("expected an unreadable root, got \(error.reason)")
#expect(error.primary.path == ".")
if case .unreadableRoot = error.primary.reason {} else {
Issue.record("expected an unreadable root, got \(error.primary.reason)")
}
}
#expect(registry.openBoardCount == 0)
+7 -7
View File
@@ -167,13 +167,13 @@ struct BoardStoreTests {
// There is nothing to fall back on before the first load, so every later rule the banner,
// the lock, "a failed reload never replaces a good snapshot" has no meaning here.
do throws(BoardLoadError) {
do throws(BoardLoadFailure) {
_ = try BoardStore(rootURL: fixture.root)
Issue.record("expected the initial load to fail")
} catch {
#expect(error.path == indexPath(Ident.lane1))
if case .unparseableYAML = error.reason {} else {
Issue.record("expected unparseable YAML, got \(error.reason)")
#expect(error.primary.path == indexPath(Ident.lane1))
if case .unparseableYAML = error.primary.reason {} else {
Issue.record("expected unparseable YAML, got \(error.primary.reason)")
}
}
}
@@ -208,7 +208,7 @@ struct BoardStoreTests {
await store.awaitQuiescence()
#expect(store.snapshot == lastGood, "a failed reload never replaces a good snapshot")
#expect(store.reloadFailure?.path == indexPath(Ident.lane1, Ident.card1))
#expect(store.reloadFailure?.primary.path == indexPath(Ident.lane1, Ident.card1))
#expect(store.loadWarnings.contains(.nonUUIDFolderIgnored(path: "notes")), "warnings describe the snapshot on screen, so they stay with it")
// Transient breakage self-heals: the watcher kept watching and the fix arrives as an
@@ -347,7 +347,7 @@ struct BoardStoreTests {
#expect(store.readOnlyLock == .bracketedReloadFailed)
#expect(store.isReadOnly)
#expect(store.reloadFailure?.path == indexPath(Ident.lane1, Ident.card1))
#expect(store.reloadFailure?.primary.path == indexPath(Ident.lane1, Ident.card1))
#expect(store.snapshot == lastGood)
// Writes are refused, and refused *before* the bracket opens a refusal must not leave the
@@ -404,7 +404,7 @@ struct BoardStoreTests {
await store.awaitQuiescence()
#expect(store.reloadGeneration == 2, "two walks: the one in flight, then the one the operation owed")
#expect(store.reloadFailure?.path == indexPath(Ident.lane1, Ident.card1))
#expect(store.reloadFailure?.primary.path == indexPath(Ident.lane1, Ident.card1))
#expect(store.readOnlyLock == .bracketedReloadFailed)
}
+223 -2
View File
@@ -64,11 +64,15 @@ private func expectFixtureFailure(
do {
_ = try loadFixture(relativePath)
Issue.record("expected \(relativePath) to fail with \(reasonDescription) at '\(path)', but it loaded")
} catch let error as BoardLoadError {
} catch let failure as BoardLoadFailure {
// Each `Malformed/` board is minimal "one broken thing" so the aggregate holds exactly
// one defect, and asserting the count is what keeps that authoring rule true.
#expect(failure.defects.count == 1, "\(relativePath): expected one defect, got \(failure.defects)")
let error = failure.primary
#expect(error.path == path, "\(relativePath): wrong path in error")
#expect(matches(error.reason), "\(relativePath): expected \(reasonDescription), got \(error.reason)")
} catch {
Issue.record("\(relativePath): expected a BoardLoadError, got \(error)")
Issue.record("\(relativePath): expected a BoardLoadFailure, got \(error)")
}
}
@@ -563,3 +567,220 @@ struct FixtureMalformedTests {
}
}
}
// MARK: - Malformed/many-defects.kanban the collect-all board
/// **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 the decision surface's whole
/// premise: "one aggregated surface presents them all", never a chain of modals).
///
/// Three classes on one board, on real disk: the root's own missing `schema`, a card written by a
/// newer Lanework, and a lane whose frontmatter will not parse.
private enum ManyDefects {
static let board = "Malformed/many-defects.kanban"
static let intactLane = "10000000-0000-4000-8000-000000000001"
static let brokenLane = "30000000-0000-4000-8000-000000000002"
static let tailLane = "50000000-0000-4000-8000-000000000003"
static let intactCard = "20000000-0000-4000-8000-000000000001"
static let newerCard = "20000000-0000-4000-8000-000000000002"
/// Broken, and behind the broken lane never enumerated, so never in the aggregate.
static let cardBehindTheBrokenLane = "40000000-0000-4000-8000-000000000009"
static let tailCard = "60000000-0000-4000-8000-000000000004"
}
struct FixtureManyDefectsTests {
/// The whole list, in walk order, asserted as a list the ordering *is* the contract, because it
/// is what the surface groups top-down and what `primary` reads off.
@Test func everyFailFastDefectIsCollectedInWalkOrder() {
do {
_ = try loadFixture(ManyDefects.board)
Issue.record("a board with three fail-fast defects loaded")
} catch let failure as BoardLoadFailure {
// The paths, as a list, because the *order* is the contract: the root first its own
// `schema` is the this-really-is-a-board gate, and the walk continued past it because
// nothing below reads the root's document then lanes in folder-name order with each
// lane's cards inside it.
#expect(failure.defects.map(\.path) == [
"index.md",
"\(ManyDefects.intactLane)/\(ManyDefects.newerCard)/index.md",
"\(ManyDefects.brokenLane)/index.md",
])
guard failure.defects.count == 3 else { return }
#expect(failure.defects[0].reason == .missingSchema)
#expect(failure.defects[1].reason == .schemaNewerThanApp(found: 2))
// The lane's reason is matched by shape rather than by the parser's exact sentence,
// which is the YAML engine's wording and not this suite's to pin.
if case .unparseableYAML = failure.defects[2].reason {} else {
Issue.record("expected unparseable YAML on the lane, got \(failure.defects[2].reason)")
}
} catch {
Issue.record("expected a BoardLoadFailure, got \(error)")
}
}
/// **A broken lane takes its subtree with it, and that is the designed loop.** The card under the
/// unparseable lane is broken too and is deliberately absent from the aggregate: it was never
/// enumerated. Repair the lane, Re-check "re-runs the whole walk" and the next aggregate
/// carries it. Deeper defects surface one repair at a time, by design, not by omission.
@Test func aBrokenLanesSubtreeIsNeverEnumerated() {
do {
_ = try loadFixture(ManyDefects.board)
Issue.record("a board with three fail-fast defects loaded")
} catch let failure as BoardLoadFailure {
#expect(!failure.defects.contains { $0.path.contains(ManyDefects.cardBehindTheBrokenLane) })
} catch {
Issue.record("expected a BoardLoadFailure, got \(error)")
}
}
/// `primary` is the walk's first defect the outermost problem, which is the one a one-line
/// surface should name.
@Test func primaryIsTheRootsOwnDefect() {
do {
_ = try loadFixture(ManyDefects.board)
Issue.record("a board with three fail-fast defects loaded")
} catch let failure as BoardLoadFailure {
#expect(failure.primary == BoardLoadError(path: "index.md", reason: .missingSchema))
#expect(failure.description.hasSuffix("(and 2 more)"))
} catch {
Issue.record("expected a BoardLoadFailure, got \(error)")
}
}
/// **The root is unskippable** (01-storage-format.md § Malformed input): the surface never offers
/// Skip there the root defects have minted repairs, and a newer root schema is Cancel-only so
/// a set naming the root's `index.md` is ignored rather than obeyed. Policed in the loader, which
/// is what makes a board with no root document impossible to construct.
@Test func aSkipSetNamingTheRootIsIgnored() {
do {
_ = try BoardLoader.load(
boardRoot: fixtureBoard(ManyDefects.board),
skipping: [
"index.md",
".",
"\(ManyDefects.intactLane)/\(ManyDefects.newerCard)/index.md",
"\(ManyDefects.brokenLane)/index.md",
]
)
Issue.record("the root's own defect was skipped — a board with no schema loaded")
} catch let failure as BoardLoadFailure {
// Exactly the root's, and nothing else: the two below-root entries were honoured.
#expect(failure.defects == [BoardLoadError(path: "index.md", reason: .missingSchema)])
} catch {
Issue.record("expected a BoardLoadFailure, got \(error)")
}
}
}
// MARK: - Malformed/skippable-defects.kanban the skip channel
/// **"Skip is user-consented tolerance, loudly marked"** (01-storage-format.md § Malformed input,
/// ruled 2026-07-31): a skipped item loads the board without it, the file stays on disk untouched,
/// and the opened board carries a warning naming what left. Per-open, never persisted which this
/// suite gets for free, because the set is an argument.
private enum SkippableDefects {
static let board = "Malformed/skippable-defects.kanban"
static let intactLane = "10000000-0000-4000-8000-000000000001"
static let brokenLane = "30000000-0000-4000-8000-000000000002"
static let tailLane = "50000000-0000-4000-8000-000000000003"
static let intactCard = "20000000-0000-4000-8000-000000000001"
static let newerCard = "20000000-0000-4000-8000-000000000002"
static let cardBehindTheBrokenLane = "40000000-0000-4000-8000-000000000009"
static let tailCard = "60000000-0000-4000-8000-000000000004"
static let newerCardPath = "\(intactLane)/\(newerCard)/index.md"
static let brokenLanePath = "\(brokenLane)/index.md"
}
struct FixtureSkippableDefectsTests {
/// Unskipped, the board is an ordinary two-defect refusal the baseline the skips are measured
/// against, and the proof the fixture is broken in exactly two places.
@Test func withoutSkipsTheBoardRefusesWithBothDefects() {
do {
_ = try loadFixture(SkippableDefects.board)
Issue.record("a board with two fail-fast defects loaded")
} catch let failure as BoardLoadFailure {
#expect(failure.defects.map(\.path) == [
SkippableDefects.newerCardPath, SkippableDefects.brokenLanePath,
])
} catch {
Issue.record("expected a BoardLoadFailure, got \(error)")
}
}
/// Skip both and the board opens **and each skipped item leaves with its whole subtree**. The
/// broken lane's own card is perfectly valid and is gone too: that is the honest cost of the
/// tolerance, and the reason the notice names what left rather than pretending nothing did.
@Test func skippingEveryDefectLoadsTheBoardWithoutThoseItems() throws {
let result = try BoardLoader.load(
boardRoot: fixtureBoard(SkippableDefects.board),
skipping: [SkippableDefects.newerCardPath, SkippableDefects.brokenLanePath]
)
#expect(result.model.title.value == "Skippable Defects")
#expect(result.model.lanes.map(\.id.rawValue) == [
SkippableDefects.intactLane, SkippableDefects.tailLane,
])
let intact = try #require(result.model.lanes.first { $0.id.rawValue == SkippableDefects.intactLane })
#expect(intact.cards.map(\.id.rawValue) == [SkippableDefects.intactCard])
// The subtree went with the lane, valid card and all.
let everyCard = result.model.lanes.flatMap { $0.cards.map(\.id.rawValue) }
#expect(!everyCard.contains(SkippableDefects.cardBehindTheBrokenLane))
#expect(everyCard == [SkippableDefects.intactCard, SkippableDefects.tailCard])
}
/// The loud mark: one warning per skip, naming the defect's own path which is what the surface
/// row named and what the opened board's notice resolves its Reveal in Finder against.
@Test func everySkipIsWarnedAbout() throws {
let result = try BoardLoader.load(
boardRoot: fixtureBoard(SkippableDefects.board),
skipping: [SkippableDefects.newerCardPath, SkippableDefects.brokenLanePath]
)
#expect(result.warnings == [
.userSkipped(path: SkippableDefects.newerCardPath),
.userSkipped(path: SkippableDefects.brokenLanePath),
])
}
/// A partial skip is an ordinary refusal over what is left the surface's per-item override,
/// and the reason Skip is a set rather than a switch.
@Test func skippingOneDefectStillRefusesForTheOther() {
do {
_ = try BoardLoader.load(
boardRoot: fixtureBoard(SkippableDefects.board),
skipping: [SkippableDefects.newerCardPath]
)
Issue.record("the unskipped lane defect did not refuse the load")
} catch let failure as BoardLoadFailure {
#expect(failure.defects.map(\.path) == [SkippableDefects.brokenLanePath])
} catch {
Issue.record("expected a BoardLoadFailure, got \(error)")
}
}
/// **Skip touches nothing on disk** "the file stays on disk untouched, tolerated-invisible like
/// strays". Asserted on the one tree where a stray write would show up in `git status`.
@Test func aSkippedItemIsLeftExactlyWhereItIs() throws {
let root = fixtureBoard(SkippableDefects.board)
let before = try allIndexMdFiles(under: root).map(\.path).sorted()
_ = try BoardLoader.load(
boardRoot: root,
skipping: [SkippableDefects.newerCardPath, SkippableDefects.brokenLanePath]
)
#expect(try allIndexMdFiles(under: root).map(\.path).sorted() == before)
let skipped = root.appendingPathComponent(SkippableDefects.brokenLanePath)
let text = try String(contentsOf: skipped, encoding: .utf8)
#expect(text.contains("labels: [red, green"), "the skipped file was rewritten")
}
}
+11 -7
View File
@@ -345,7 +345,11 @@ struct UITestMalformedFixtureBoardTests {
do {
_ = try BoardLoader.load(boardRoot: root)
Issue.record("the malformed board loaded — the fail-fast pass would audit a board that opens")
} catch let error as BoardLoadError {
} catch let failure as BoardLoadFailure {
// One broken file, so one defect: the fixture breaks a single card's index, and the
// aggregate is what the welcome row reads its caption off.
#expect(failure.defects.count == 1)
let error = failure.primary
// The path is board-relative and names the *file*, which is what the welcome row's
// failure caption carries and what the UI test asserts against.
#expect(error.path.hasSuffix("/\(BoardLoader.indexFileName)"))
@@ -360,10 +364,10 @@ struct UITestMalformedFixtureBoardTests {
}
// The whole sentence, which is what actually reaches the user: file first, then why.
#expect(error.description.contains(BoardLoader.indexFileName))
#expect(error.description.lowercased().contains("yaml"))
#expect(failure.description.contains(BoardLoader.indexFileName))
#expect(failure.description.lowercased().contains("yaml"))
} catch {
Issue.record("expected a BoardLoadError, got \(error)")
Issue.record("expected a BoardLoadFailure, got \(error)")
}
}
@@ -386,10 +390,10 @@ struct UITestMalformedFixtureBoardTests {
do {
_ = try BoardLoader.load(boardRoot: root)
Issue.record("the malformed board loaded")
} catch let error as BoardLoadError {
#expect(error.path.hasSuffix(BoardLoader.indexFileName))
} catch let failure as BoardLoadFailure {
#expect(failure.primary.path.hasSuffix(BoardLoader.indexFileName))
} catch {
Issue.record("expected a BoardLoadError, got \(error)")
Issue.record("expected a BoardLoadFailure, got \(error)")
}
}