Phase 3 of the decision surface, completing the card (01 ▸ Malformed input, settled 2026-07-31). An attended open's fail-fast walk transforms the loading window's content into one aggregated surface — never a sheet, never a chain: defects grouped by class, each class stated once with its files listed (Reveal in Finder + Open in Editor per row), a class-level default preselected, per-item override behind a disclosure. Only honest choices: YAML and malformed-schema get Editor + Re-check (Skip below the root); newer-than-app gets Skip alone and blocks the board at the root; the two root repairs — minted index, schema: 1 stamp — are defaults. Repair and Open applies fixes in one store-less write bracket and re-walks: clean proceeds, remainder re-aggregates into the same surface. Cancel and ⌘W retire to welcome's row; restored opens never see the surface at all (OpenOrigin rides the PendingOpen carrier). Skips are per-open consent that rides the session — the store retains the skip set and every reload passes it — and the opened board posts a warning-tone notice naming what was left out, each item's Reveal riding the banner strip's new reveal control. On Pro boards the repair bracket binds its own EchoLedger, heal-marks everything, and the store adopts it before the committer starts, so repairs land as one separate commit authored Lanework Integrity — pinned end to end. Also fixed en route: a retired loading window left its close interception installed and returned false from windowShouldClose forever, blocking quit. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
770 lines
38 KiB
Swift
770 lines
38 KiB
Swift
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<String> = []) 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<String> = ["\(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<String> = ["\(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<String> = ["\(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 <[email protected]>` 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")
|
|
}
|
|
}
|