The lane strip replaces the placeholder board: window width divides
across the lanes' width units (no horizontal scroll, no minimum width,
degenerate compression accepted), a lane of n units flowing its cards
into n round-robin masonry columns via a measurement-cached Layout.
Width has two deliberately opposite controls, both landing here: the
right-edge drag (ported verbatim from the pathfinder's ColumnResize)
freezes the 1x standard at drag start, snaps between integer widths
with the asymmetric shadow-leads tick and 10pt re-entry, grows the
window one standard width per snap so siblings keep their exact
pixels, and rubber-bands at the screen's visible frame — uncapped
otherwise; the Increase/Decrease Lane Width items (new Board menu,
Cmd-Opt-arrows) are the stepper's keyboard face and re-divide the
existing window width instead, never touching the window. Width
changes write through the new .resize WriteOperation ("Couldn't
resize…" in the banner vocabulary, which grows with the surfaces by
design); malformed width values render as one unit and stay untouched
on disk. 27 new tests port the pathfinder's resize-math suite onto
the uncapped range and pin the write path's fidelity.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
205 lines
9.3 KiB
Swift
205 lines
9.3 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 {
|
||
!$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.
|
||
store.setLaneWidth(ItemID(rawValue: Ident.lane2), units: 0)
|
||
#expect(try loadedLane(Ident.lane2, in: fixture).width == .valid(1))
|
||
|
||
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 == .valid(1))
|
||
}
|
||
|
||
@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 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" })
|
||
}
|
||
}
|