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:
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user