Step 7 of strategy/01-git-excision.md, the companions. The agent guide bumps to v11: the Git section teaches repo-resident etiquette alone (stage only your own paths, commit your own changes, leave app-maintained files to the app) — existing boards heal to the new text on next open. README re-anchors: the four git feature bullets out, tiers say the complete Mac experience is free, and one bullet states the format's git-friendliness promise. The changelog drops the never-shipped git entries. DESIGN re-rules: 06 retired with Undo routing migrated to 13 (now the sole substrate's doc, seam kept open), 07 retired as written pending the ops-service workstream, 14 retired as superseded record, 12 carries the second pivot note, the index reflects all of it; the charter gets a pointer note (the anchors' full re-ruling stays with the user). InertGitTests renames to GitAgnosticStorageTests — the excision restores its original claim app-wide. And the sweep: ~70 comment sites across 36 files adjudicated against the keeper list, every present-tense description of the excised machinery made past tense or repointed, keepers untouched. 2,707 tests green. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
260 lines
12 KiB
Swift
260 lines
12 KiB
Swift
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`.
|
||
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" })
|
||
}
|
||
}
|