Files
lanework/KanbanTests/LaneWidthWriteTests.swift
T
rzen 445d035a83 The paper agrees with the code — guide v11, README, DESIGN re-rulings, and the adjudicated sweep
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
2026-08-08 12:31:27 -04:00

260 lines
12 KiB
Swift
Raw 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`.
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" })
}
}