import Foundation import SwiftGitX import Testing @testable import Kanban /// **The decision surface** (01-storage-format.md § Malformed input, settled 2026-07-31) — the /// grouping, the vocabulary, the two minted repairs, the skip channel's ride through the session, and /// the attendance branch that decides whether any of it is reached at all. /// /// Everything here is asked of values. The surface's rules live in `BoardDecisionSurfaceModel` and /// its landing rule in a static on the host precisely so a suite with no window can hold the whole /// decision in its hand: what a class offers, what it defaults to, when Repair and Open may be /// pressed, and what a repair actually puts on disk. // MARK: - Fixtures /// A board whose root has no `schema` and one lane that will not parse — the minimal two-class /// aggregate, and the shape most of these cases need: one minted repair, one hand-edit. @MainActor private func makeTwoClassBoard() throws -> WriterFixture { let fixture = try WriterFixture() // No `schema` at the root — the this-really-is-a-board gate, defect #1. try fixture.item("", "---\ntitle: Two Classes\n---\nA board with two kinds of problem.\n") try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Intact\norder: 1024\n---\n") try fixture.item("\(Ident.lane1)/\(Ident.card1)", "---\nschema: 1\ntitle: Card\norder: 1024\n---\n") // Unparseable YAML — an unterminated flow sequence, defect #2. try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: Broken\norder: 2048\nlabels: [a, b\n---\n") return fixture } /// A board whose only defects are **below the root** — the skip channel's golden case. @MainActor private func makeSkippableBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", "---\nschema: 1\ntitle: Skippable\n---\n") try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Intact\norder: 1024\n---\n") try fixture.item("\(Ident.lane1)/\(Ident.card1)", "---\nschema: 1\ntitle: Card\norder: 1024\n---\n") // A card from a newer Lanework: unfixable, so Skip is the only offer. try fixture.item("\(Ident.lane1)/\(Ident.card2)", "---\nschema: 2\ntitle: Newer\norder: 2048\n---\n") // A lane that will not parse: Open in Editor + Re-check, or Skip. try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: Broken\norder: 2048\nlabels: [a, b\n---\n") return fixture } /// The failure a board refuses with — the input every model here is built from. @MainActor private func failure(of fixture: WriterFixture, skipping: Set = []) throws -> BoardLoadFailure { do throws(BoardLoadFailure) { _ = try BoardLoader.load(boardRoot: fixture.root, skipping: skipping) Issue.record("the fixture board loaded — it is supposed to refuse") throw BoardLoadFailure(BoardLoadError(path: ".", reason: .notADirectory)) } catch { return error } } @MainActor private func makeModel(_ fixture: WriterFixture) throws -> BoardDecisionSurfaceModel { BoardDecisionSurfaceModel(failure: try failure(of: fixture), boardRoot: fixture.root) } // MARK: - Grouping and defaults @MainActor @Suite("Decision surface ▸ grouping and defaults") struct BoardDecisionSurfaceGroupingTests { @Test("Defects group by class, and the classes come in walk order") func groupingIsByClassInWalkOrder() throws { let fixture = try makeSkippableBoard() defer { fixture.tearDown() } let model = try makeModel(fixture) // The walk is root, then lanes in folder-name order with their cards inside them — so the // newer-schema card (under lane 1) is met before the unparseable lane 2. #expect(model.sections.map(\.defectClass) == [.newerSchema, .unreadableFrontmatter]) #expect(model.sections.allSatisfy { $0.rows.count == 1 }) } @Test("One class with several files is one section, in walk order") func oneClassIsOneSection() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n") try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\nlabels: [a\n---\n") try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: B\norder: 2048\nlabels: [b\n---\n") let model = try makeModel(fixture) // "each class section states the defect once, lists the affected files". #expect(model.sections.count == 1) let section = try #require(model.sections.first) #expect(section.defectClass == .unreadableFrontmatter) #expect(section.rows.map(\.id) == ["\(Ident.lane1)/index.md", "\(Ident.lane2)/index.md"]) } @Test("Every class preselects its ruled default") func classDefaultsAreTheRuledOnes() throws { // The minted repairs, at the root where they occur. #expect(BoardDefectClass.missingBoardIndex.choices(atPath: "index.md").first == .repair(.mintBoardIndex)) #expect(BoardDefectClass.missingRootSchema.choices(atPath: "index.md").first == .repair(.stampSchema)) // "Open in Editor + Re-check, **or** Skip" — the posture leads, the tolerance follows. #expect( BoardDefectClass.unreadableFrontmatter.choices(atPath: "lane/index.md") == [.editAndRecheck, .skip] ) // "an unfixable row … offering only Skip". #expect(BoardDefectClass.newerSchema.choices(atPath: "lane/index.md") == [.skip]) } @Test("malformedSchema is seated with the YAML family, root restriction and all") func malformedSchemaJoinsTheYAMLFamily() { // Redesign Gap bcdd1942: not one of the ruled four, and the same honest posture — the app // must not guess what `schema: banana` was meant to be. #expect(BoardDefectClass(.malformedSchema(raw: "banana")) == .unreadableFrontmatter) #expect(BoardDefectClass(.unparseableYAML(message: "x", line: 2)) == .unreadableFrontmatter) // The root restriction it inherits: Editor + Re-check at the root, plus Skip below it. #expect(BoardDefectClass.unreadableFrontmatter.choices(atPath: "index.md") == [.editAndRecheck]) } @Test("A root defect is never offered Skip") func theRootIsNeverSkippable() throws { let fixture = try makeTwoClassBoard() defer { fixture.tearDown() } let model = try makeModel(fixture) let rootRow = try #require(model.rows.first { $0.id == BoardLoader.indexFileName }) #expect(!rootRow.choices.contains(.skip), "there is no board without its root") #expect(rootRow.choice == .repair(.stampSchema)) } @Test("Every row states its path and the walk's own reason") func rowsCarryPathAndSpecifics() throws { let fixture = try makeTwoClassBoard() defer { fixture.tearDown() } let model = try makeModel(fixture) let broken = try #require(model.rows.first { $0.id == "\(Ident.lane2)/index.md" }) #expect(broken.defect.reason.description.contains("unparseable YAML")) // Reveal points at the file that is actually there; Open in Editor too. #expect(broken.revealURL.lastPathComponent == BoardLoader.indexFileName) #expect(broken.editURL != nil) } @Test("A root with no index.md has nothing to open, and reveals its folder") func aMissingIndexRevealsTheFolder() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Orphan\norder: 1024\n---\n") let model = try makeModel(fixture) let row = try #require(model.rows.first) #expect(row.defectClass == .missingBoardIndex) #expect(row.editURL == nil, "Finder cannot open a file that is not there") #expect(row.revealURL == fixture.root, "the folder is what the user needs to look at") } } // MARK: - The choices, the override, and the enabling rule @MainActor @Suite("Decision surface ▸ choices and the enabling rule") struct BoardDecisionSurfaceChoiceTests { @Test("A class-level choice sets every row in the class") func theClassChoiceSetsTheClass() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n") try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\nlabels: [a\n---\n") try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: B\norder: 2048\nlabels: [b\n---\n") let model = try makeModel(fixture) #expect(model.sections.first?.classChoice == .editAndRecheck) model.choose(.skip, inClass: .unreadableFrontmatter) #expect(model.sections.first?.classChoice == .skip) #expect(model.skipSet == ["\(Ident.lane1)/index.md", "\(Ident.lane2)/index.md"]) } @Test("A per-item override moves one row and leaves the class mixed") func perItemOverrideLeavesTheClassMixed() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n") try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\nlabels: [a\n---\n") try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: B\norder: 2048\nlabels: [b\n---\n") let model = try makeModel(fixture) model.choose(.skip, forPath: "\(Ident.lane1)/index.md") #expect(model.skipSet == ["\(Ident.lane1)/index.md"], "only the overridden row moved") #expect(model.sections.first?.classChoice == nil, "the class has no single answer any more") #expect(model.sections.first?.offersPerItemOverride == true) } @Test("A class-level choice a row cannot take leaves that row alone") func aRootRowIgnoresAClassLevelSkip() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } // Unparseable YAML at the root *and* below it: one class, two offer sets. try fixture.item("", "---\nschema: 1\ntitle: Board\nlabels: [a\n---\n") try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\nlabels: [b\n---\n") let model = try makeModel(fixture) model.choose(.skip, inClass: .unreadableFrontmatter) #expect(model.skipSet == ["\(Ident.lane1)/index.md"], "the root never joins a skip set") let rootRow = try #require(model.rows.first { $0.id == BoardLoader.indexFileName }) #expect(rootRow.choice == .editAndRecheck) } @Test("Repair and Open enables only when every defect has an actionable resolution") func repairAndOpenNeedsEveryDefectResolved() throws { let fixture = try makeSkippableBoard() defer { fixture.tearDown() } let model = try makeModel(fixture) // Out of the box the unparseable lane defaults to "fix it myself", which is not a resolution. #expect(!model.canRepairAndOpen) model.choose(.skip, inClass: .unreadableFrontmatter) #expect(model.canRepairAndOpen, "a consented Skip is an actionable resolution") #expect(model.skipSet.count == 2, "the newer-schema card defaults to Skip, its only offer") } @Test("Repair and Open enables on a board whose only defect has a minted repair") func aMintedRepairAloneEnablesIt() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", "---\ntitle: No Schema\n---\n") let model = try makeModel(fixture) #expect(model.canRepairAndOpen) #expect(model.plannedRepairs == [ BoardDecisionSurfaceModel.PlannedRepair(path: "index.md", repair: .stampSchema) ]) } @Test("A root blocked by a newer schema disables Repair and Open outright") func aRootBlockerDisablesRepairAndOpen() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } // "on the board root it blocks the whole board (Cancel is the only exit)". try fixture.item("", "---\nschema: 99\ntitle: From The Future\n---\n") try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Fine\norder: 1024\n---\n") let model = try makeModel(fixture) let row = try #require(model.rows.first) #expect(row.isBlocking, "no repair, and no tolerance at the root") #expect(row.choice == nil) #expect(model.isRootBlocked) #expect(!model.canRepairAndOpen) } @Test("The surface still shows everything a blocked board has wrong") func aBlockedSurfaceStillShowsEverything() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", "---\nschema: 99\ntitle: From The Future\n---\n") try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\nlabels: [a\n---\n") let model = try makeModel(fixture) // The user whose root was written by a newer Lanework is owed the whole picture — what they // do next depends on how much else is wrong. #expect(model.sections.map(\.defectClass) == [.newerSchema, .unreadableFrontmatter]) #expect(model.rows.count == 2) } } // MARK: - Re-aggregation @MainActor @Suite("Decision surface ▸ re-aggregation") struct BoardDecisionSurfaceReaggregationTests { @Test("A fresh walk refreshes the same surface and keeps the answers still standing") func reaggregationKeepsStandingAnswers() throws { let fixture = try makeTwoClassBoard() defer { fixture.tearDown() } let model = try makeModel(fixture) // The user overrides the broken lane to Skip, then repairs the root by hand outside the app. model.choose(.skip, inClass: .unreadableFrontmatter) try fixture.item("", "---\nschema: 1\ntitle: Two Classes\n---\n") model.reaggregate(try failure(of: fixture)) #expect(model.sections.map(\.defectClass) == [.unreadableFrontmatter], "the root's defect is gone") #expect(model.skipSet == ["\(Ident.lane2)/index.md"], "the standing consent survived the re-walk") #expect(model.canRepairAndOpen) } @Test("A defect the walk newly reveals arrives at its class default") func newlyRevealedDefectsTakeTheirDefault() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n") try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\nlabels: [a\n---\n") // Hidden behind the broken lane: a broken lane takes its subtree with it. try fixture.item("\(Ident.lane1)/\(Ident.card1)", "---\nschema: 2\ntitle: Newer\norder: 1024\n---\n") let model = try makeModel(fixture) #expect(model.rows.count == 1, "the card under the broken lane is never enumerated") // The lane is fixed by hand; Re-check reveals what it was hiding — in the *same* surface. model.choose(.skip, forPath: "\(Ident.lane1)/index.md") try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\n---\n") model.reaggregate(try failure(of: fixture)) #expect(model.rows.count == 1) let revealed = try #require(model.rows.first) #expect(revealed.id == "\(Ident.lane1)/\(Ident.card1)/index.md") #expect(revealed.choice == .skip, "the newer-schema class's only offer, preselected") } } // MARK: - The repairs @MainActor @Suite("Decision surface ▸ the minted repairs") struct BoardDecisionSurfaceRepairTests { @Test("Stamping schema writes schema: 1, stamps modified, and clears modified-by") func stampingSchemaIsAnOrdinaryAppWrite() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", """ --- title: No Schema created: 2026-01-01T09:00:00Z modified: 2026-01-01T09:00:00Z modified-by: claude project: lanework --- Body text. """) let model = try makeModel(fixture) let outcome = BoardRepairRun.apply(model.plannedRepairs, boardRoot: fixture.root) #expect(outcome.failure == nil) let text = try fixture.indexText("") #expect(text.contains("schema: 1")) // "each repaired `index.md` is an ordinary app write (stamps `modified`, clears // `modified-by`)". #expect(!text.contains("modified-by")) #expect(!text.contains("modified: 2026-01-01")) // And the round trip is the Writer's own: unknown keys and the body survive. #expect(text.contains("project: lanework")) #expect(text.contains("Body text.")) // The board loads now, which is the whole test of a repair. #expect(throws: Never.self) { try BoardLoader.load(boardRoot: fixture.root) } } @Test("Minting a board index creates one titled after the folder, at schema 1") func mintingABoardIndexUsesTheFolderName() throws { let outer = FileManager.default.temporaryDirectory .appendingPathComponent("DecisionMint-\(UUID().uuidString)", isDirectory: true) let root = outer.appendingPathComponent("Roadmap.kanban", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: outer) } try Data("---\nschema: 1\ntitle: Todo\norder: 1024\n---\n".utf8) .write(to: { let lane = root.appendingPathComponent(Ident.lane1, isDirectory: true) try? FileManager.default.createDirectory(at: lane, withIntermediateDirectories: true) return lane.appendingPathComponent(BoardLoader.indexFileName) }()) let aggregate: BoardLoadFailure do throws(BoardLoadFailure) { _ = try BoardLoader.load(boardRoot: root) Issue.record("a folder with no index.md is not a board") return } catch { aggregate = error } let model = BoardDecisionSurfaceModel(failure: aggregate, boardRoot: root) #expect(model.plannedRepairs.map(\.repair) == [.mintBoardIndex]) let outcome = BoardRepairRun.apply(model.plannedRepairs, boardRoot: root) #expect(outcome.failure == nil) // "(folder-name title, `schema: 1`)" — and the extension is not part of the name // (01 § Board naming). let loaded = try BoardLoader.load(boardRoot: root) #expect(loaded.model.title.value == "Roadmap") #expect(loaded.model.schema == 1) #expect(loaded.model.lanes.count == 1, "the board that was already there is still there") } @Test("The repair bracket's receipts are all heal-marked") func repairReceiptsAreHealMarked() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", "---\ntitle: No Schema\n---\n") let model = try makeModel(fixture) let outcome = BoardRepairRun.apply(model.plannedRepairs, boardRoot: fixture.root) let index = fixture.root.appendingPathComponent(BoardLoader.indexFileName) #expect(outcome.ledger.receipt(at: index) != nil, "the write dropped a receipt") #expect( outcome.ledger.isHeal(at: index), "01: repairs drop heal-marked receipts and commit separately as one repair commit" ) } @Test("A repair pass with nothing to repair writes nothing and fails nothing") func anEmptyRepairPassIsANoOp() throws { let fixture = try makeSkippableBoard() defer { fixture.tearDown() } let model = try makeModel(fixture) model.choose(.skip, inClass: .unreadableFrontmatter) // Every resolution here is a Skip: Repair and Open is a re-walk and nothing else. #expect(model.plannedRepairs.isEmpty) let outcome = BoardRepairRun.apply(model.plannedRepairs, boardRoot: fixture.root) #expect(outcome.failure == nil) #expect(outcome.ledger.outstandingReceipts == 0) } @Test("A repaired board walks clean, which is the loop Repair and Open runs") func repairThenWalkIsTheLoop() throws { let fixture = try makeTwoClassBoard() defer { fixture.tearDown() } let model = try makeModel(fixture) model.choose(.skip, inClass: .unreadableFrontmatter) BoardRepairRun.apply(model.plannedRepairs, boardRoot: fixture.root) let result = try BoardLoader.load(boardRoot: fixture.root, skipping: model.skipSet) #expect(result.model.schema == 1) // The skipped lane left with its whole subtree; the intact one stayed. #expect(result.model.lanes.map { $0.id.rawValue } == [Ident.lane1]) #expect(result.warnings.contains(.userSkipped(path: "\(Ident.lane2)/index.md"))) } @Test("An interrupted batch is accepted: what landed stays, and the rest re-aggregates") func aPartialRepairReAggregates() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", "---\ntitle: No Schema\n---\n") // A repair aimed at a file that is not there — the shape of a disk that moved underneath. let planned = [ BoardDecisionSurfaceModel.PlannedRepair(path: "index.md", repair: .stampSchema), BoardDecisionSurfaceModel.PlannedRepair( path: "\(Ident.lane1)/index.md", repair: .stampSchema) ] let outcome = BoardRepairRun.apply(planned, boardRoot: fixture.root) #expect(outcome.failure != nil, "the second repair could not run") #expect(try fixture.indexText("").contains("schema: 1"), "the first one still landed") // "every intermediate state valid, and a partial repair simply re-aggregates on the next walk". #expect(throws: Never.self) { try BoardLoader.load(boardRoot: fixture.root) } } } // MARK: - The skip channel through the session @MainActor @Suite("Decision surface ▸ the skip set rides the session") struct BoardDecisionSurfaceSkipTests { @Test("A skip-carrying open builds a store that retains the set") func theStoreRetainsTheSkipSet() async throws { let fixture = try makeSkippableBoard() defer { fixture.tearDown() } let registry = BoardStoreRegistry() let skips: Set = ["\(Ident.lane2)/index.md", "\(Ident.lane1)/\(Ident.card2)/index.md"] let store = try #require(try await registry.acquireOffMain(fixture.root, skipping: skips)) defer { registry.release(store) } #expect(store.skippedPaths == skips) #expect(store.snapshot.lanes.map { $0.id.rawValue } == [Ident.lane1]) #expect(store.snapshot.lanes.first?.cards.count == 1, "the newer-schema card left too") } @Test("Every reload of that session passes the same set") func reloadsCarryTheSkipSet() async throws { let fixture = try makeSkippableBoard() defer { fixture.tearDown() } let registry = BoardStoreRegistry() let skips: Set = ["\(Ident.lane2)/index.md", "\(Ident.lane1)/\(Ident.card2)/index.md"] let store = try #require(try await registry.acquireOffMain(fixture.root, skipping: skips)) defer { registry.release(store) } // A foreign filesystem event. Without the retained set this reload walks the still-broken // board, fails, and the window carries a breakage banner over the board the user just chose // to open. store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() #expect(store.reloadFailure == nil, "the open's consent stands for the session") #expect(store.snapshot.lanes.map { $0.id.rawValue } == [Ident.lane1]) } @Test("The walk marks every skip loudly, and the notice is written from those marks") func skipsAreLoudlyMarked() throws { let fixture = try makeSkippableBoard() defer { fixture.tearDown() } // Every defect on this board has to be answered before it opens at all — a skip omits the item // it names, never its siblings' defects. let skips: Set = ["\(Ident.lane1)/\(Ident.card2)/index.md", "\(Ident.lane2)/index.md"] let result = try BoardLoader.load(boardRoot: fixture.root, skipping: skips) let skipped = result.warnings.compactMap { warning -> String? in if case let .userSkipped(path) = warning { path } else { nil } } // Walk order, like every other list the aggregate produces. #expect(skipped == ["\(Ident.lane1)/\(Ident.card2)/index.md", "\(Ident.lane2)/index.md"]) // The row the opened board carries, values and all. let items = skipped.map { RevealTarget(path: $0, url: fixture.root.appendingPathComponent($0)) } let center = BannerCenter() center.postSkippedOnOpen(items) let loss = try #require(center.losses.first) #expect(loss.message == "Opened without 2 items — you chose to skip them") #expect(loss.reveals.map(\.path) == skipped) // Each reveal points at the file the surface's row named, still on disk and untouched. #expect(loss.reveals.allSatisfy { FileManager.default.fileExists(atPath: $0.url.path) }) // "each with Reveal in Finder", as a control the strip can render and Tab can reach. let row = BannerRow.loss(loss) #expect(row.tone == .warning, "a warning-tone loss row — nothing failed") #expect(row.controls.map(\.label) == ["Reveal in Finder", "Dismiss"]) } @Test("A sole skip is named rather than counted") func aSoleSkipIsNamed() throws { let center = BannerCenter() center.postSkippedOnOpen([ RevealTarget(path: "lane/index.md", url: URL(fileURLWithPath: "/b/lane/index.md")) ]) #expect(center.losses.first?.message == "Opened without 'lane/index.md' — you chose to skip it") } @Test("Several skips fold to a count, with a reveal for each") func severalSkipsFold() throws { let items = [ RevealTarget(path: "a/index.md", url: URL(fileURLWithPath: "/b/a/index.md")), RevealTarget(path: "b/index.md", url: URL(fileURLWithPath: "/b/b/index.md")), RevealTarget(path: "c/index.md", url: URL(fileURLWithPath: "/b/c/index.md")) ] let center = BannerCenter() center.postSkippedOnOpen(items) let loss = try #require(center.losses.first) #expect(loss.message == "Opened without 3 items — you chose to skip them") // The fold is only safe because "which ones" survives on the row. #expect(loss.reveals.count == 3) } @Test("An open that skipped nothing says nothing") func noSkipsNoNotice() { let center = BannerCenter() center.postSkippedOnOpen([]) #expect(center.losses.isEmpty) } @Test("An ordinary loss row still carries no reveal control") func ordinaryLossRowsAreUnchanged() throws { let center = BannerCenter() center.postSkippedFolders(count: 2) let loss = try #require(center.losses.first) #expect(loss.reveals.isEmpty) #expect(BannerRow.loss(loss).controls.map(\.label) == ["Dismiss"]) } } // MARK: - The attendance branch @MainActor @Suite("Decision surface ▸ the attendance branch") struct BoardDecisionSurfaceAttendanceTests { private func aggregate(_ reason: BoardLoadError.Reason, path: String = "index.md") -> BoardLoadFailure { BoardLoadFailure(BoardLoadError(path: path, reason: reason)) } @Test("A restored open never reaches the surface, whatever is wrong") func restoredAlwaysRetires() { // "restoration failures keep the retire-to-welcome-row landing … launch never chains dialogs". let repairable = aggregate(.missingSchema) let handEdit = aggregate(.unparseableYAML(message: "x", line: 1), path: "lane/index.md") #expect(BoardWindowHost.landing(for: repairable, origin: .restored) == .retire) #expect(BoardWindowHost.landing(for: handEdit, origin: .restored) == .retire) } @Test("An attended open reaches the surface for anything a repair could touch") func attendedDecides() { #expect(BoardWindowHost.landing(for: aggregate(.missingSchema), origin: .attended) == .decide) #expect(BoardWindowHost.landing(for: aggregate(.boardRootMissingIndex), origin: .attended) == .decide) #expect(BoardWindowHost.landing(for: aggregate(.schemaNewerThanApp(found: 9)), origin: .attended) == .decide) } @Test("An environmental failure retires even when attended") func theEnvironmentalCarveOut() { // Redesign Gap 87cd782a: there is nothing on disk to repair, so a surface would offer a // decision with no choices in it. Welcome's row says the same thing in one line. let gone = BoardLoadFailure( BoardLoadError(path: ".", reason: .unreadableRoot(message: "no such file or directory"))) let file = BoardLoadFailure(BoardLoadError(path: ".", reason: .notADirectory)) #expect(BoardWindowHost.landing(for: gone, origin: .attended) == .retire) #expect(BoardWindowHost.landing(for: file, origin: .attended) == .retire) } @Test("An environmental defect among repairable ones still shows the surface") func aMixedAggregateStillDecides() { // The carve-out is "nothing to repair", not "one of these is environmental": the rest of the // aggregate is still actionable. let mixed = BoardLoadFailure([ BoardLoadError(path: "index.md", reason: .missingSchema), BoardLoadError(path: ".", reason: .notADirectory) ]) #expect(BoardWindowHost.landing(for: mixed, origin: .attended) == .decide) } @Test("The origin rides the pending open, attended by default") func theOriginRidesThePendingOpen() throws { let folder = FileManager.default.temporaryDirectory .appendingPathComponent("DecisionOrigin-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: folder) } let model = AppModel( registryStorageURL: folder.appendingPathComponent("board-registry.json"), clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true) ) let attended = folder.appendingPathComponent("Attended.kanban", isDirectory: true) let restored = folder.appendingPathComponent("Restored.kanban", isDirectory: true) // The stash `openBoard(at:origin:)` makes — reached directly, because that method needs a // SwiftUI `OpenWindowAction` no test can build (`AppModel.stashPendingOpen`). model.stashPendingOpen(for: BoardWindowRef(url: attended), url: attended, origin: .attended) model.stashPendingOpen(for: BoardWindowRef(url: restored), url: restored, origin: .restored) let attendedClaim = model.claimPendingOpen(for: BoardWindowRef(url: attended)) #expect(attendedClaim.origin == .attended) #expect(attendedClaim.access != nil, "the scope rides the same stash") #expect(model.claimPendingOpen(for: BoardWindowRef(url: restored)).origin == .restored) // Claiming removes it, and a window that arrived by some other route reads attended with no // scope — the safe direction (the worst it can do is offer a repair nobody asked for). let second = model.claimPendingOpen(for: BoardWindowRef(url: attended)) #expect(second.origin == .attended) #expect(second.access == nil) } } // MARK: - The Pro repair commit /// HEAD's first-parent ancestry, newest first — read through SwiftGitX, never through the committer /// that made the commits (`AutoCommitTests`' rule, kept: nothing here shells out to `git`). private func repairHistory(at boardRoot: URL, limit: Int = 8) throws -> [(subject: String, authorName: String, authorEmail: String, committerName: String)] { let repository = try Repository.open(at: boardRoot) guard !repository.isHEADUnborn, let tip = try repository.HEAD.target as? Commit else { return [] } var records: [(String, String, String, String)] = [] var current: Commit? = tip while let commit = current, records.count < limit { records.append((commit.summary, commit.author.name, commit.author.email, commit.committer.name)) current = (try? commit.parents)?.first } return records } @MainActor @Suite("Decision surface ▸ the Pro repair commit") struct BoardDecisionSurfaceRepairCommitTests { /// An `AppModel` whose app-side state lives in temp rather than in the app's real Application /// Support home, and which reads as Pro — `AutoCommitCompositionRootTests`' fixture. private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) { let folder = FileManager.default.temporaryDirectory .appendingPathComponent("DecisionRepairCommit-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) let model = AppModel( registryStorageURL: folder.appendingPathComponent("board-registry.json"), clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true) ) model.currentTier = { .pro } return (model, { try? FileManager.default.removeItem(at: folder) }) } /// **A repaired Pro board's first flush is one heal commit, authored by the integrity identity** /// (01-storage-format.md § Malformed input: "On Pro boards the repairs drop heal-marked receipts /// and commit separately as one repair commit, never folded into anyone else's work"; /// 06-history-undo.md ▸ Commit messages ▸ Healing mutations commit separately). /// /// The whole chain is exercised end to end, because every link in it can fail silently and the /// symptom is identical each time — a commit blaming the outside world for the app's own repair: /// /// 1. The surface's default resolution is the minted repair. /// 2. `BoardRepairRun` writes it store-lessly and marks every receipt in its own ledger. /// 3. The store built by the following walk **adopts** that ledger (`EchoLedger.adopt`) — /// before `beginSession`, which is where Pro's committer is composed and started. /// 4. `GitAutoCommitter.start()` harvests, so the debounce it arms can see receipts that were /// dropped before any write bracket of this session existed. /// 5. `CommitAttribution.split` sorts the repaired path into the heal class, and the heal class /// is authored `Lanework Integrity ` with the user as committer. @Test("A repaired board under Pro + git produces a separate heal commit") func aRepairCommitsAsTheIntegrityIdentity() async throws { let fixture = try WriterFixture() defer { fixture.tearDown() } // A board whose root is missing `schema` — the minted stamp's own case. The lane is here so // the repository has an ordinary tree around the file being repaired. try fixture.item("", "---\ntitle: Needs A Stamp\ncreated: 2026-01-01T09:00:00Z\n---\nBoard.\n") try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\n---\n") let (model, tearDown) = try makeModel() defer { tearDown() } // The repository, with everything as it stands committed — including the broken root, which is // what makes the repair a real change rather than a fresh file. let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: EchoLedger())) #expect(await git.addGit()) let commitsBefore = try repairHistory(at: fixture.root).count git.stopAutoCommit() // — The surface, exactly as the window builds it. let surface = BoardDecisionSurfaceModel(failure: try failure(of: fixture), boardRoot: fixture.root) #expect(surface.canRepairAndOpen, "a lone missing root schema is a minted repair, preselected") // — Repair and Open's write half. let outcome = BoardRepairRun.apply(surface.plannedRepairs, boardRoot: fixture.root) #expect(outcome.failure == nil) // — The walk that follows, and the store it builds. Built directly rather than through the // registry so this case is about the repair's commit and not about the open's *other* heals // (the agent guide, the `.gitignore` seed), which the registry's acquire also fires. let result = try BoardLoader.load(boardRoot: fixture.root, skipping: surface.skipSet) let store = BoardStore(rootURL: fixture.root, loaded: result, skipping: surface.skipSet) // — The adoption, before the session composes the committer. store.echoes.adopt(outcome.ledger) let ref = BoardWindowRef(url: fixture.root) let recordID = model.boardRegistry.recordOpen(of: fixture.root) model.beginSession(ref: ref, store: store, recordID: recordID, access: nil) let committer = try #require(model.session(for: ref)?.git?.committer) // The debounce `start()` armed is not what this asserts; the explicit flush is. committer.stop() committer.debounceInterval = .seconds(30) committer.coveringSnapshotDeadline = .milliseconds(50) committer.coveringSnapshotPollInterval = .milliseconds(5) await committer.flushNow() let log = try repairHistory(at: fixture.root) #expect(log.count == commitsBefore + 1, "one repair commit, never folded and never split further") let head = try #require(log.first) #expect(head.authorName == CommitAttribution.integrityAuthorName) #expect(head.authorEmail == CommitAttribution.integrityAuthorEmail) #expect( head.authorEmail != CommitAttribution.externalAuthorEmail, "the app's own repair must never be blamed on the outside world" ) // "the committer stays the user (the recorded-by convention)". #expect(head.committerName == GitCommitOperation.userIdentity(at: fixture.root).name) #expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty, "the flush leaves nothing dirty") } }