Files
lanework/KanbanTests/LaneWidthWriteTests.swift
rzen 3a9db2e78b 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
2026-07-29 15:45:48 -04:00

260 lines
12 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Testing
@testable import Kanban
/// `BoardStore.setLaneWidth` — the one commit point both width mechanisms share (03-board-ui.md §
/// Lane): the right-edge drag's release and the ⌥⌘→/⌥⌘← stepper.
///
/// These drive a real store over a real temp board and then read the **raw bytes** back, never the
/// app's own read path, because the interesting claims are about the file: the integer that lands,
/// the stamps that follow it, and everything else surviving byte-for-byte. `WriterFixture`, `Ident`
/// and `Item` come from `WriterTestSupport.swift`, as they do for every other write suite.
// MARK: - Fixtures
/// A lane index carrying an explicit `width:` — `Item.rich` deliberately has none, and half of what
/// is under test here is what happens to a value that is already there (valid or not).
private func laneText(order: String, title: String, width: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
width: \(width)
project: lanework # agent overlay
created: 2026-01-01T09:00:00Z
modified: 2026-02-02T09:00:00Z
modified-by: claude
---
\(title) body.
"""
}
/// A board with one plain lane (no `width`), one already at 3×, and one whose frontmatter is
/// readable but uneditable.
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(Ident.lane2, laneText(order: "2048", title: "Doing", width: "3"))
try fixture.item(Ident.lane3, Item.uneditable)
return fixture
}
/// The file's lines minus the ones an app-mediated write is *supposed* to change — what must come
/// through a resize byte-for-byte, in order.
private func untouchedLines(_ text: String) -> [Substring] {
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
// `kind:` — the on-touch backfill rides every app rewrite of a file that lacks one.
!$0.hasPrefix("modified") && !$0.hasPrefix("width:") && !$0.hasPrefix("kind:")
}
}
private func loadedLane(_ id: String, in fixture: WriterFixture) throws -> Lane {
let model = try BoardLoader.load(boardRoot: fixture.root).model
return try #require(model.lanes.first { $0.id.rawValue == id })
}
// MARK: - Tests
@MainActor
@Suite("BoardStore ▸ lane width")
struct LaneWidthWriteTests {
@Test("A width change writes the integer, stamps modified, clears modified-by, and touches nothing else")
func writesTheIntegerAndStamps() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexText(Ident.lane1)
store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 3)
let after = try fixture.indexText(Ident.lane1)
#expect(after.contains("width: 3"))
#expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
#expect(!after.contains("modified: 2026-02-02T09:00:00Z"), "the stamp is fresh")
// Everything the write does not own survives exactly, in order: the unknown key with its
// inline comment, the reserved `labels`, the original `created`, and the body.
#expect(untouchedLines(after) == untouchedLines(before))
let lane = try loadedLane(Ident.lane1, in: fixture)
#expect(lane.width == .valid(3))
#expect(lane.modifiedBy.isMissing)
let modified = try #require(lane.modified.value)
#expect(abs(modified.timeIntervalSinceNow) < 60, "modified is stamped with the time of the write")
#expect(store.banners.oneShots.isEmpty)
}
@Test("A width change replaces whatever was there — a malformed value included")
func replacesAMalformedValue() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item(Ident.lane1, laneText(order: "1024", title: "Todo", width: "wide"))
let store = try BoardStore(rootURL: fixture.root)
// `width: wide` is lenient on the read side — it renders as one unit with the bytes left
// alone (01-storage-format.md § Frontmatter). An explicit change is the user overwriting
// it, so the Writer puts a plain integer in its place.
#expect(store.snapshot.lanes.first { $0.id.rawValue == Ident.lane1 }?.width == .malformed(raw: "wide"))
store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 2)
let after = try fixture.indexText(Ident.lane1)
#expect(after.contains("width: 2"))
#expect(!after.contains("wide"))
#expect(try loadedLane(Ident.lane1, in: fixture).width == .valid(2))
}
@Test("A count below one clamps to one rather than writing a value the read side would reject")
func clampsBelowOne() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Lane two is at 3×, so a clamped 1 is a real change and actually reaches disk — where the
// remove-at-default rule turns it into an absent key (03-board-ui.md § Lane, settled), which
// the read side displays as one unit.
store.setLaneWidth(ItemID(rawValue: Ident.lane2), units: 0)
#expect(try loadedLane(Ident.lane2, in: fixture).width.isMissing)
try fixture.item(Ident.lane2, laneText(order: "2048", title: "Doing", width: "3"))
let reopened = try BoardStore(rootURL: fixture.root)
reopened.setLaneWidth(ItemID(rawValue: Ident.lane2), units: -7)
#expect(try loadedLane(Ident.lane2, in: fixture).width.isMissing)
}
@Test("A lane that is not in the snapshot is a no-op")
func unknownLaneIsANoOp() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexData(Ident.lane1)
// The lane vanished under the gesture (or never existed): the reload that removed it is the
// authority, and inventing a file here would be the app disagreeing with disk.
store.setLaneWidth(ItemID(rawValue: Ident.indexless), units: 3)
#expect(!fixture.exists(Ident.indexless))
#expect(try fixture.indexData(Ident.lane1) == before)
#expect(store.banners.oneShots.isEmpty)
}
@Test("Setting the width a lane already displays writes nothing at all")
func unchangedWidthIsANoOp() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let atThree = try fixture.indexData(Ident.lane2)
let atOne = try fixture.indexData(Ident.lane1)
// A drag that ends where it started, and a stepper pressed against its floor: neither may
// stamp `modified` or (on a git board) mint a commit.
store.setLaneWidth(ItemID(rawValue: Ident.lane2), units: 3)
#expect(try fixture.indexData(Ident.lane2) == atThree)
// Lane one has no `width` key at all, so it *displays* one unit — setting one unit is the
// same no-op, and must not materialize the key.
store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 1)
#expect(try fixture.indexData(Ident.lane1) == atOne)
#expect(try fixture.entryNames(Ident.lane1) == ["index.md"], "no temp-file residue either")
}
@Test("A width landing on one removes the key — the remove-at-default family")
func landingOnOneRemovesTheKey() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Lane two carries `width: 3`; stepping it down to one must not leave `width: 1` behind —
// "a default lane's frontmatter stays clean, drag, stepper, and menu items alike"
// (03-board-ui.md § Lane, settled). The read side then displays one unit off the absent key.
store.setLaneWidth(ItemID(rawValue: Ident.lane2), units: 1)
let after = try fixture.indexText(Ident.lane2)
#expect(!after.contains("width"))
#expect(try loadedLane(Ident.lane2, in: fixture).width.isMissing)
}
@Test("A hand-written width of one is preserved until the app itself next edits width")
func handWrittenOneIsPreserved() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item(Ident.lane1, laneText(order: "1024", title: "Todo", width: "1"))
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexData(Ident.lane1)
// Displays one unit, asked for one unit: the unchanged-units guard fires before the
// remove-at-default rule can, so the legal hand-written `width: 1` stays byte-for-byte.
store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 1)
#expect(try fixture.indexData(Ident.lane1) == before)
}
@Test("The menu batch steps every selected lane one unit in one bracket, floor members holding")
func batchStepsEverySelectedLane() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let ids: Set<ItemID> = [ItemID(rawValue: Ident.lane1), ItemID(rawValue: Ident.lane2)]
// Lane one displays 1× (no key), lane two 3×: one decrease holds the floor member and
// steps the other — "each selected lane steps one unit, one gesture, one commit"
// (03-board-ui.md § Lane, settled).
store.stepLaneWidths(ids, by: -1)
#expect(try loadedLane(Ident.lane1, in: fixture).width.isMissing, "held at the floor, key never materializes")
#expect(try loadedLane(Ident.lane2, in: fixture).width == .valid(2))
// A fresh store, because the step reads the snapshot and the first write's reload is the
// watcher's (async) — the second gesture in a real session steps off the reloaded board.
let reopened = try BoardStore(rootURL: fixture.root)
reopened.stepLaneWidths(ids, by: 1)
#expect(try loadedLane(Ident.lane1, in: fixture).width == .valid(2))
#expect(try loadedLane(Ident.lane2, in: fixture).width == .valid(3))
}
@Test("A readable-but-uneditable lane refuses the write, banners it, and keeps its bytes")
func uneditableLaneBannersAndChangesNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexData(Ident.lane3)
store.setLaneWidth(ItemID(rawValue: Ident.lane3), units: 2)
// The refusal is the settled readable-but-uneditable rule: the file loads and renders, but
// a surgical edit of it cannot be expressed, so nothing is written.
#expect(try fixture.indexData(Ident.lane3) == before)
#expect(try fixture.indexText(Ident.lane3) == Item.uneditable)
// `performWrite` posts before it rethrows, and `setLaneWidth` swallows the rethrow — the
// banner is the only thing that says the gesture did not happen, so it must be there.
#expect(store.banners.oneShots.count == 1)
let posted = try #require(store.banners.oneShots.first)
#expect(posted.error.operation == .resize(title: "Odd"),
"the title is enriched off the document the write refused")
#expect(posted.error.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
#expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't resize 'Odd' — "))
#expect(store.bannerRows.contains { $0.id == "one-shot:\(posted.id.uuidString)" })
}
@Test("A read-only board refuses the write without a second banner")
func readOnlyBoardRefusesQuietly() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.enterVanishedRootLock()
let before = try fixture.indexData(Ident.lane1)
store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 4)
// The lock row is already standing; a refusal per gesture would bury it under echoes of
// itself (02-architecture.md § Write-failure surfacing).
#expect(try fixture.indexData(Ident.lane1) == before)
#expect(store.banners.oneShots.isEmpty)
#expect(store.bannerRows.contains { $0.id == "read-only-lock" })
}
}