Build the integrity service - IntegrityRules and the HealScheduler
The 2026-07-29 integrity design pass, consolidated (DESIGN/01 -
Validation and healing; DESIGN/02 - Components): IntegrityRules
(Storage, pure) is the one home for the identity predicate and
canonical form (BoardWriter.canonicalIdentity deleted, ItemID and the
loader forward to it), the per-field rulebook, uneditable shapes,
per-kind index validation, the reserved-name tables, and the trash
kind discriminator (values trusted - kind: lane/card explicit,
unrecognized falls to shape). LoadResult's ad-hoc channels fold into
one typed Defect stream (looseCardFiles / legacyTombstone /
claimedNameSquatted, per-defect heal signatures); the old accessors
survive as computed views.
HealScheduler (LiveStore) states the six-step heal pattern once -
resting-clear, lock gate, isWritableFile gate (now covering all four
heals), signature memo armed-before-attempt with explicit
clear-on-success, disk re-verify in each write half, one banner-posture
table (BannerCenter keeps all phrasing). The three hand-rolled healers
run on it with behavior preserved - including the
relocation-notice-despite-partial-failure quirk, deliberately. Heals
run at the reload tail AND at registry acquire, closing the
migration-never-fires-at-open asymmetry. Displacement runs first: a
squatted .trash would otherwise fail the migration and arm its memo
against an unchanged picture.
Claimed-name squatters (ruled today, 62c47a2) displace by the shared
Finder-style rename ladder - preserved verbatim, symlinks moved as
links, nothing stamped; AgentGuide's untouchable-skip upgrades to
displace-then-write, the CLAUDE.user.md-taken skip stands. kind stamps
on every create and backfills on any index rewrite via the on-touch
seam (placement resolver stamps nothing when the parent is unknown -
a guessed kind is worse than an absent one; board-root writers declare
theirs). Heal writes mark their EchoLedger receipts (inert in base;
pro-m1's committer will split them into their own commits). The
renumber ask-renumber-ask-again two-step is one shared helper, adopted
at all nine call sites.
69 tests added. 1738 green on both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,500 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **The integrity service's rules** (01-storage-format.md § Validation and healing, settled
|
||||
/// 2026-07-29; 02-architecture.md ▸ Components ▸ IntegrityRules): the one vocabulary of object
|
||||
/// validity, pinned as the pure functions it is.
|
||||
///
|
||||
/// The *scheduling* of heals lives next door (`HealSchedulerTests`), and each heal's own end-to-end
|
||||
/// behavior stays in the suite that always owned it (`LooseFileRelocationTests`, `TrashStorageTests`,
|
||||
/// `AgentGuideTests`). What is here is the consolidation itself: that the identity predicate, the
|
||||
/// canonical form, the reserved-name tables, per-kind validation, the trash discriminator, the
|
||||
/// on-touch heals and the defect vocabulary each have exactly one implementation and behave as the
|
||||
/// design states them.
|
||||
|
||||
// MARK: - The identity predicate and its canonical form
|
||||
|
||||
@Suite("IntegrityRules ▸ identity")
|
||||
struct IntegrityIdentityTests {
|
||||
|
||||
/// Shape-only: hex, `8-4-4-4-12`, **any case, any version** — lowercase v4 is the app's emission
|
||||
/// rule, not the gate.
|
||||
@Test("The predicate is shape-only — any case, any version")
|
||||
func predicateIsShapeOnly() {
|
||||
#expect(IntegrityRules.isIdentityShaped("11111111-1111-4111-8111-111111111111"))
|
||||
#expect(IntegrityRules.isIdentityShaped("11111111-1111-4111-8111-111111111111".uppercased()))
|
||||
// v7's version nibble, and a variant nibble RFC 4122 would reject — both accepted.
|
||||
#expect(IntegrityRules.isIdentityShaped("0195a3f0-0000-7000-0000-000000000000"))
|
||||
#expect(!IntegrityRules.isIdentityShaped("notes"))
|
||||
#expect(!IntegrityRules.isIdentityShaped("11111111-1111-4111-8111-11111111111"))
|
||||
#expect(!IntegrityRules.isIdentityShaped("11111111111141118111111111111111"))
|
||||
#expect(!IntegrityRules.isIdentityShaped("gggggggg-1111-4111-8111-111111111111"))
|
||||
}
|
||||
|
||||
/// **One predicate, one implementation** — the loader's spelling forwards to it, which is what
|
||||
/// "no parallel derivation" means in practice.
|
||||
@Test("The loader's gate is this predicate")
|
||||
func loaderSharesThePredicate() {
|
||||
for name in ["11111111-1111-4111-8111-111111111111", "NOTES", "", ".trash"] {
|
||||
#expect(BoardLoader.isUUIDShaped(name) == IntegrityRules.isIdentityShaped(name))
|
||||
}
|
||||
}
|
||||
|
||||
/// **The fold** (settled 2026-07-29): `ItemID`'s equality and the Writer's string-level checks
|
||||
/// canonicalize through the same function. Before it, the Writer carried a private copy — one
|
||||
/// line, and one line too many for a rule that decides whether two folders are the same item.
|
||||
@Test("ItemID's equality is this canonical form")
|
||||
func itemIDSharesTheCanonicalForm() {
|
||||
let lower = "55555555-5555-4555-8555-555555555555"
|
||||
#expect(IntegrityRules.canonicalIdentity(lower.uppercased()) == lower)
|
||||
#expect(ItemID(rawValue: lower) == ItemID(rawValue: lower.uppercased()))
|
||||
#expect(ItemID(rawValue: lower).hashValue == ItemID(rawValue: lower.uppercased()).hashValue)
|
||||
// rawValue still round-trips byte-perfect — canonicalization is for *comparing*, never for
|
||||
// storing.
|
||||
#expect(ItemID(rawValue: lower.uppercased()).rawValue == lower.uppercased())
|
||||
}
|
||||
|
||||
/// Total on anything: an off-shape name compares by its own lowercasing, the harmless reading.
|
||||
@Test("The canonical form is total")
|
||||
func canonicalFormIsTotal() {
|
||||
#expect(IntegrityRules.canonicalIdentity("Notes") == "notes")
|
||||
#expect(IntegrityRules.canonicalIdentity("") == "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The reserved-name tables
|
||||
|
||||
@Suite("IntegrityRules ▸ reserved names")
|
||||
struct IntegrityReservedNameTests {
|
||||
|
||||
/// One table, read by the loader under its own names.
|
||||
@Test("The tables are the loader's")
|
||||
func tablesAreShared() {
|
||||
#expect(BoardLoader.reservedCardChildNames == IntegrityRules.reservedCardChildNames)
|
||||
#expect(BoardLoader.reservedRootNames == IntegrityRules.claimedRootNameSet)
|
||||
#expect(BoardLoader.trashFolderName == IntegrityRules.trashFolderName)
|
||||
#expect(BoardWriter.attachmentsFolderName == IntegrityRules.attachmentsFolderName)
|
||||
}
|
||||
|
||||
/// The claimed names carry **what kind of node each may be** — the fact the displacement heal
|
||||
/// turns on, and the reason the table is a list of values rather than a `Set<String>`.
|
||||
@Test("Each claimed name declares its node kind and whether it displaces")
|
||||
func claimedNamesDeclareTheirKind() throws {
|
||||
let trash = try #require(IntegrityRules.claimedRootNames.first { $0.name == ".trash" })
|
||||
#expect(trash.expected == .directory)
|
||||
#expect(trash.displacesSquatters)
|
||||
|
||||
let guide = try #require(IntegrityRules.claimedRootNames.first { $0.name == "CLAUDE.md" })
|
||||
#expect(guide.expected == .file)
|
||||
#expect(guide.displacesSquatters)
|
||||
|
||||
// The standing exception: a rescue *destination* is never itself freed by a second
|
||||
// displacement, which would cascade renames (08-agent-integration.md ▸ Ownership).
|
||||
let userFile = try #require(IntegrityRules.claimedRootNames.first { $0.name == "CLAUDE.user.md" })
|
||||
#expect(!userFile.displacesSquatters)
|
||||
// Seeded once, then the user's to edit (06-history-undo.md ▸ Repository hygiene).
|
||||
let gitignore = try #require(IntegrityRules.claimedRootNames.first { $0.name == ".gitignore" })
|
||||
#expect(!gitignore.displacesSquatters)
|
||||
}
|
||||
|
||||
/// `lstat` semantics: a **dangling** symlink is a node that is there.
|
||||
@Test("The node probe never follows a link")
|
||||
func nodeProbeUsesLstat() throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("IntegrityRulesTests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
try Data("x".utf8).write(to: root.appendingPathComponent("file.txt"))
|
||||
try FileManager.default.createDirectory(at: root.appendingPathComponent("dir"), withIntermediateDirectories: true)
|
||||
try FileManager.default.createSymbolicLink(
|
||||
atPath: root.appendingPathComponent("dangling").path,
|
||||
withDestinationPath: "nowhere"
|
||||
)
|
||||
try FileManager.default.createSymbolicLink(
|
||||
atPath: root.appendingPathComponent("toFile").path,
|
||||
withDestinationPath: "file.txt"
|
||||
)
|
||||
|
||||
#expect(IntegrityRules.node(at: root.appendingPathComponent("file.txt")) == .file)
|
||||
#expect(IntegrityRules.node(at: root.appendingPathComponent("dir")) == .directory)
|
||||
#expect(IntegrityRules.node(at: root.appendingPathComponent("dangling")) == .symlink)
|
||||
#expect(IntegrityRules.node(at: root.appendingPathComponent("toFile")) == .symlink)
|
||||
#expect(IntegrityRules.node(at: root.appendingPathComponent("absent")) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Placement and the trash discriminator
|
||||
|
||||
@Suite("IntegrityRules ▸ kind")
|
||||
struct IntegrityKindTests {
|
||||
|
||||
/// "Level is position", as two names and nothing else.
|
||||
@Test("Placement reads position")
|
||||
func placementReadsPosition() {
|
||||
let uuid = "11111111-1111-4111-8111-111111111111"
|
||||
#expect(IntegrityRules.placement(ofFolderNamed: uuid, inParentNamed: uuid) == .card)
|
||||
#expect(IntegrityRules.placement(ofFolderNamed: uuid, inParentNamed: "MyBoard.kanban") == .lane)
|
||||
#expect(IntegrityRules.placement(ofFolderNamed: uuid, inParentNamed: ".trash") == .insideTrash)
|
||||
// A board root's folder name is a Finder document name, so position cannot answer for it —
|
||||
// and neither can it for a hand-named folder. "Unknown" rather than "board" is what keeps a
|
||||
// guessed kind off disk.
|
||||
#expect(IntegrityRules.placement(ofFolderNamed: "MyBoard.kanban", inParentNamed: "Documents") == .unknown)
|
||||
#expect(IntegrityRules.placement(ofFolderNamed: "notes", inParentNamed: "MyBoard.kanban") == .unknown)
|
||||
}
|
||||
|
||||
/// **The value is trusted outright** — no corroboration, no policing (re-ruled 2026-07-29).
|
||||
@Test("The trash discriminator trusts the value")
|
||||
func trashDiscriminatorTrustsTheValue() {
|
||||
// Honored even against the shape: a card-shaped folder saying `lane` is a lane.
|
||||
#expect(IntegrityRules.trashKind(kindValue: "lane", hasIdentityShapedChildIndex: false) == .lane)
|
||||
// And a lane-shaped folder saying `card` is a card.
|
||||
#expect(IntegrityRules.trashKind(kindValue: "card", hasIdentityShapedChildIndex: true) == .card)
|
||||
}
|
||||
|
||||
/// An unrecognized value, or none, falls through to **shape**.
|
||||
@Test("An unrecognized value falls through to shape")
|
||||
func unrecognizedValueFallsToShape() {
|
||||
#expect(IntegrityRules.trashKind(kindValue: nil, hasIdentityShapedChildIndex: true) == .lane)
|
||||
#expect(IntegrityRules.trashKind(kindValue: nil, hasIdentityShapedChildIndex: false) == .card)
|
||||
#expect(IntegrityRules.trashKind(kindValue: "widget", hasIdentityShapedChildIndex: true) == .lane)
|
||||
#expect(IntegrityRules.trashKind(kindValue: "", hasIdentityShapedChildIndex: false) == .card)
|
||||
// `board` is not a third answer in the trash — a board cannot be trashed, so shape decides.
|
||||
#expect(IntegrityRules.trashKind(kindValue: "board", hasIdentityShapedChildIndex: true) == .lane)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Per-kind validation
|
||||
|
||||
@Suite("IntegrityRules ▸ validation")
|
||||
struct IntegrityValidationTests {
|
||||
|
||||
private func bytes(_ text: String) -> Data { Data(text.utf8) }
|
||||
|
||||
/// The per-kind field table: `order` is required on lanes and cards, **never** on the board.
|
||||
@Test("Order is required per kind")
|
||||
func orderIsRequiredPerKind() {
|
||||
#expect(!IntegrityRules.requiresOrder(.board))
|
||||
#expect(IntegrityRules.requiresOrder(.lane))
|
||||
#expect(IntegrityRules.requiresOrder(.card))
|
||||
}
|
||||
|
||||
@Test("A board index validates without an order")
|
||||
func boardValidatesWithoutOrder() throws {
|
||||
let document = try IntegrityRules.validateIndex(
|
||||
bytes("---\nschema: 1\ntitle: Board\n---\nbody\n"),
|
||||
path: "index.md",
|
||||
kind: .board,
|
||||
supportedSchema: 1
|
||||
)
|
||||
#expect(document.title == .valid("Board"))
|
||||
}
|
||||
|
||||
@Test("A lane or card index without an order is refused")
|
||||
func laneAndCardRequireOrder() {
|
||||
for kind in [IntegrityRules.ObjectKind.lane, .card] {
|
||||
#expect(throws: BoardLoadError(path: "index.md", reason: .missingOrder)) {
|
||||
try IntegrityRules.validateIndex(
|
||||
bytes("---\nschema: 1\n---\nbody\n"),
|
||||
path: "index.md",
|
||||
kind: kind,
|
||||
supportedSchema: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The card window's gate is this rule at `kind: .card` — one function, not a copy.
|
||||
@Test("validateCardIndex is validateIndex at card")
|
||||
func cardValidatorIsTheGeneralOne() {
|
||||
let missingOrder = bytes("---\nschema: 1\n---\nbody\n")
|
||||
#expect(throws: BoardLoadError(path: "index.md", reason: .missingOrder)) {
|
||||
try BoardLoader.validateCardIndex(missingOrder, path: "index.md")
|
||||
}
|
||||
let newer = bytes("---\nschema: 99\norder: 1\n---\n")
|
||||
#expect(throws: BoardLoadError(path: "index.md", reason: .schemaNewerThanApp(found: 99))) {
|
||||
try BoardLoader.validateCardIndex(newer, path: "index.md")
|
||||
}
|
||||
}
|
||||
|
||||
/// The refuse-writes verdict's rule, named in the vocabulary rather than left as a property one
|
||||
/// call site happens to read.
|
||||
@Test("The uneditable shape is the document's, named here")
|
||||
func uneditableShapeIsNamed() throws {
|
||||
let flow = try FrontmatterDocument.parse("---\n{schema: 1, order: 1024}\n---\nbody\n")
|
||||
#expect(IntegrityRules.uneditableShape(of: flow) == .keyWithoutOwnLine)
|
||||
let ordinary = try FrontmatterDocument.parse("---\nschema: 1\norder: 1024\n---\nbody\n")
|
||||
#expect(IntegrityRules.uneditableShape(of: ordinary) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - On-touch heals
|
||||
|
||||
@Suite("IntegrityRules ▸ on-touch heals")
|
||||
struct IntegrityOnTouchTests {
|
||||
|
||||
@Test("A missing kind backfills with the object's own kind")
|
||||
func missingKindBackfills() throws {
|
||||
var document = try FrontmatterDocument.parse("---\nschema: 1\norder: 1024\n---\nbody\n")
|
||||
let heals = IntegrityRules.healOnTouch(&document, kind: .card)
|
||||
#expect(heals == [.kindBackfilled(.card)])
|
||||
#expect(document.kind == .valid("card"))
|
||||
// Appended before the closing delimiter, last — where the common table puts it.
|
||||
#expect(document.keys == ["schema", "order", "kind"])
|
||||
#expect(document.body == "body\n")
|
||||
}
|
||||
|
||||
/// **The value is never rewritten, never corroborated, never stripped** — consumers trust it.
|
||||
@Test("A present kind is left exactly as written")
|
||||
func presentKindIsLeftAlone() throws {
|
||||
var document = try FrontmatterDocument.parse("---\nschema: 1\norder: 1\nkind: widget\n---\n")
|
||||
#expect(IntegrityRules.healOnTouch(&document, kind: .card).isEmpty)
|
||||
#expect(document.kind == .valid("widget"))
|
||||
// Even a value that contradicts position: the trash reader honors it, so the writer must not
|
||||
// "correct" it out from under whoever wrote it.
|
||||
var lane = try FrontmatterDocument.parse("---\nschema: 1\norder: 1\nkind: lane\n---\n")
|
||||
#expect(IntegrityRules.healOnTouch(&lane, kind: .card).isEmpty)
|
||||
#expect(lane.kind == .valid("lane"))
|
||||
}
|
||||
|
||||
/// Null-as-missing, the engine's own rule, applied here: a key started and never given a value
|
||||
/// is absent, and so backfills.
|
||||
@Test("An explicit null backfills")
|
||||
func explicitNullBackfills() throws {
|
||||
var document = try FrontmatterDocument.parse("---\nschema: 1\norder: 1\nkind:\n---\n")
|
||||
#expect(IntegrityRules.healOnTouch(&document, kind: .lane) == [.kindBackfilled(.lane)])
|
||||
#expect(document.kind == .valid("lane"))
|
||||
}
|
||||
|
||||
/// **A guessed kind is worse than an absent one**: where position cannot answer, nothing is
|
||||
/// stamped.
|
||||
@Test("No kind, no stamp")
|
||||
func unknownKindStampsNothing() throws {
|
||||
var document = try FrontmatterDocument.parse("---\nschema: 1\norder: 1\n---\n")
|
||||
#expect(IntegrityRules.healOnTouch(&document, kind: nil).isEmpty)
|
||||
#expect(document.kind == .missing)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The defect vocabulary
|
||||
|
||||
@Suite("IntegrityRules ▸ defects")
|
||||
struct IntegrityDefectTests {
|
||||
|
||||
private let lane = ItemID(rawValue: "11111111-1111-4111-8111-111111111111")
|
||||
private let card = ItemID(rawValue: "55555555-5555-4555-8555-555555555555")
|
||||
|
||||
/// Each defect knows its heal class — the memo key and the banner-posture row.
|
||||
@Test("Every defect names its class")
|
||||
func defectsNameTheirClass() {
|
||||
#expect(IntegrityRules.Defect.looseCardFiles(
|
||||
LooseCardFiles(laneID: lane, cardID: card, title: nil, fileNames: ["a.txt"])
|
||||
).healClass == .looseCardFiles)
|
||||
#expect(IntegrityRules.Defect.legacyTombstone(
|
||||
LegacyTombstone(kind: .lane, laneID: lane, cardID: nil, title: nil)
|
||||
).healClass == .legacyTombstone)
|
||||
#expect(IntegrityRules.Defect.claimedNameSquatted(
|
||||
ClaimedNameSquatter(name: ".trash", found: .file, expected: .directory)
|
||||
).healClass == .claimedNameSquatted)
|
||||
}
|
||||
|
||||
/// **One signature per file for the loose-file defect**, so a card that gains or loses a single
|
||||
/// loose file is a different picture and earns a fresh attempt.
|
||||
@Test("A loose-file defect signs per file")
|
||||
func looseFilesSignPerFile() {
|
||||
let defect = IntegrityRules.Defect.looseCardFiles(
|
||||
LooseCardFiles(laneID: lane, cardID: card, title: "T", fileNames: ["a.txt", "b.txt"])
|
||||
)
|
||||
#expect(defect.signatures.count == 2)
|
||||
#expect(Set(defect.signatures) == [
|
||||
"loose:\(lane.rawValue)/\(card.rawValue)/a.txt",
|
||||
"loose:\(lane.rawValue)/\(card.rawValue)/b.txt",
|
||||
])
|
||||
}
|
||||
|
||||
/// The title is *not* in the signature: renaming a card does not make its pending relocation a
|
||||
/// new defect to retry.
|
||||
@Test("Signatures identify the work, not its display")
|
||||
func signaturesIdentifyTheWork() {
|
||||
let one = IntegrityRules.Defect.legacyTombstone(
|
||||
LegacyTombstone(kind: .card, laneID: lane, cardID: card, title: "Before")
|
||||
)
|
||||
let two = IntegrityRules.Defect.legacyTombstone(
|
||||
LegacyTombstone(kind: .card, laneID: lane, cardID: card, title: "After")
|
||||
)
|
||||
#expect(one.signatures == two.signatures)
|
||||
}
|
||||
|
||||
/// The classes are disjoint by construction — a memo for one heal can never collide with
|
||||
/// another's picture.
|
||||
@Test("Signatures are namespaced per class")
|
||||
func signaturesAreNamespaced() {
|
||||
let loose = IntegrityRules.Defect.looseCardFiles(
|
||||
LooseCardFiles(laneID: lane, cardID: card, title: nil, fileNames: ["x"])
|
||||
)
|
||||
let tombstone = IntegrityRules.Defect.legacyTombstone(
|
||||
LegacyTombstone(kind: .card, laneID: lane, cardID: card, title: nil)
|
||||
)
|
||||
let squatter = IntegrityRules.Defect.claimedNameSquatted(
|
||||
ClaimedNameSquatter(name: ".trash", found: .symlink, expected: .directory)
|
||||
)
|
||||
let all = Set(loose.signatures + tombstone.signatures + squatter.signatures)
|
||||
#expect(all.count == 3)
|
||||
#expect(squatter.signatures == ["claimed:.trash:symlink"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The `kind` key, through the Writer
|
||||
|
||||
/// **`kind` is written at creation of every object and backfills on touch** (01-storage-format.md
|
||||
/// § Frontmatter, re-ruled 2026-07-29) — the two halves of the rule, at the seam that implements
|
||||
/// them (`BoardWriter.createBoard`/`createLane`/`createCard`, and `BoardWriter.updateIndex`).
|
||||
///
|
||||
/// **On-touch only, never a scheduled sweep**: nothing in the app walks a board adding this key, and
|
||||
/// these tests are written so that a future sweep would fail them (an untouched sibling keeps no
|
||||
/// `kind` at all).
|
||||
@Suite("The kind key ▸ stamping and backfill")
|
||||
struct ObjectKindWriteTests {
|
||||
|
||||
private func kind(of relativePath: String, in fixture: WriterFixture) throws -> FieldValue<String> {
|
||||
try FrontmatterDocument.parse(fixture.indexText(relativePath)).kind
|
||||
}
|
||||
|
||||
@Test("Every create path stamps its own kind")
|
||||
func createPathsStamp() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let root = fixture.url("MyBoard.kanban")
|
||||
|
||||
try BoardWriter.createBoard(at: root, title: "Board")
|
||||
let lane = try BoardWriter.createLane(inBoard: root, title: "Todo")
|
||||
let card = try BoardWriter.createCard(
|
||||
inLane: root.appendingPathComponent(lane.rawValue, isDirectory: true),
|
||||
title: "Fix login"
|
||||
)
|
||||
|
||||
#expect(try kind(of: "MyBoard.kanban", in: fixture) == .valid("board"))
|
||||
#expect(try kind(of: "MyBoard.kanban/\(lane.rawValue)", in: fixture) == .valid("lane"))
|
||||
#expect(try kind(of: "MyBoard.kanban/\(lane.rawValue)/\(card.rawValue)", in: fixture) == .valid("card"))
|
||||
}
|
||||
|
||||
/// The backfill reads **position** — a lane's child is a card, a board root's child is a lane —
|
||||
/// so an older file gains the *right* value without the caller being asked for one.
|
||||
@Test("A rewrite backfills a missing kind from position")
|
||||
func rewriteBackfillsFromPosition() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "A"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "B"))
|
||||
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||
operation: .style(title: nil)
|
||||
) { $0.set(FrontmatterKeys.background, to: .string("fern")) }
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: fixture.url(Ident.lane1),
|
||||
operation: .style(title: nil)
|
||||
) { $0.set(FrontmatterKeys.background, to: .string("fern")) }
|
||||
|
||||
#expect(try kind(of: "\(Ident.lane1)/\(Ident.card1)", in: fixture) == .valid("card"))
|
||||
#expect(try kind(of: Ident.lane1, in: fixture) == .valid("lane"))
|
||||
// **On touch only**: the sibling nobody wrote to still has no `kind`, which is exactly what
|
||||
// "never a scheduled backfill sweep" means on disk.
|
||||
#expect(try kind(of: "\(Ident.lane1)/\(Ident.card2)", in: fixture) == .missing)
|
||||
#expect(try kind(of: "", in: fixture) == .missing)
|
||||
}
|
||||
|
||||
/// The board root is the one file whose kind position cannot answer, so its writers **declare**
|
||||
/// it — and the declaration is what the backfill uses.
|
||||
@Test("The board root's kind is declared by its writers")
|
||||
func boardRootKindIsDeclared() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
|
||||
try BoardWriter.updateIndex(inItemFolder: fixture.root, kind: .board, operation: .rename(title: nil)) {
|
||||
$0.set(FrontmatterKeys.title, to: .string("Renamed"))
|
||||
}
|
||||
|
||||
#expect(try kind(of: "", in: fixture) == .valid("board"))
|
||||
}
|
||||
|
||||
/// Undeclared and unanswerable by position → **nothing is stamped**. A guessed kind on disk
|
||||
/// would be worse than an absent one, because the trash's discriminator trusts what it finds.
|
||||
@Test("A hand-named folder is stamped with nothing")
|
||||
func unknownPositionStampsNothing() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let folder = try fixture.item("notes", Item.rich(order: "1024", title: "Hand-made"))
|
||||
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) {
|
||||
$0.set(FrontmatterKeys.background, to: .string("fern"))
|
||||
}
|
||||
|
||||
#expect(try kind(of: "notes", in: fixture) == .missing)
|
||||
}
|
||||
|
||||
/// Inside `.trash/` position cannot answer either, so **shape** does — the same rule the trash
|
||||
/// reader uses, so a backfilled value and a read value can never disagree.
|
||||
@Test("A trashed folder backfills by shape")
|
||||
func trashBackfillsByShape() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "1024", title: "A trashed card"))
|
||||
try fixture.item(".trash/\(Ident.lane2)", Item.rich(order: "2048", title: "A trashed lane"))
|
||||
try fixture.item(".trash/\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Its card"))
|
||||
|
||||
for path in [".trash/\(Ident.card1)", ".trash/\(Ident.lane2)"] {
|
||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(path), operation: .reorder(title: nil)) {
|
||||
$0.set(FrontmatterKeys.order, to: .double(4096))
|
||||
}
|
||||
}
|
||||
|
||||
#expect(try kind(of: ".trash/\(Ident.card1)", in: fixture) == .valid("card"))
|
||||
#expect(try kind(of: ".trash/\(Ident.lane2)", in: fixture) == .valid("lane"))
|
||||
}
|
||||
|
||||
/// **The trash move's own rank mint is a touch** — 01's own example of where the key earns its
|
||||
/// keep ("any Writer rewrite of that lane's `index.md`, the trash move's rank mint included").
|
||||
@Test("Deleting a card backfills its kind on the way into the trash")
|
||||
func deleteBackfillsOnTheWayIn() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "A"))
|
||||
|
||||
try BoardWriter.deleteCardToTrash(
|
||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||
inBoard: fixture.root,
|
||||
order: 1024
|
||||
)
|
||||
|
||||
#expect(try kind(of: ".trash/\(Ident.card1)", in: fixture) == .valid("card"))
|
||||
}
|
||||
|
||||
/// Templates gain the key **lazily**, through this same backfill — there is no template
|
||||
/// migration, by design.
|
||||
@Test("A copy carries what the source had, and heals what it did not")
|
||||
func copiesInheritAndHeal() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "A"))
|
||||
|
||||
let copy = try BoardWriter.copyItem(
|
||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||
toParent: fixture.url(Ident.lane1),
|
||||
order: 2048,
|
||||
stamps: .fork
|
||||
)
|
||||
|
||||
// The copy's own `index.md` is rewritten by the copy (stamps, order), so it heals in flight.
|
||||
#expect(try kind(of: "\(Ident.lane1)/\(copy.rawValue)", in: fixture) == .valid("card"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user