Files
lanework/KanbanTests/LaneWidthWriteTests.swift
T
rzen edc094a5f4 Realign the width and lane-move commands — three fresh settlements
DESIGN edits landing from the parallel session, honored:

- Caret chords yield to any focused text control (04 ▸ Grammar): Move
  Left/Right ⌘←/⌘→ and the width pair ⌥⌘←/⌥⌘→ now disable while the board
  popover is open — its fields are the one non-inline text surface a board
  window has today; the search field and card-window fields extend the rule
  with their own cards.
- The width pair batches over a multi-lane selection (03 ▸ Lane, the styling
  precedent): each selected lane steps one unit through one bracket
  (stepLaneWidths); floor members hold on a decrease; the context-menu
  stepper stays single-lane by nature.
- A width write landing on 1 removes the width key (03 ▸ Lane, the
  remove-at-default family), every mechanism alike — writeLaneWidths is the
  one commit point; a hand-written width: 1 is preserved by the unchanged
  guard until the app itself next edits width.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 20:10:59 -04:00

259 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 {
!$0.hasPrefix("modified") && !$0.hasPrefix("width:")
}
}
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" })
}
}