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
259 lines
12 KiB
Swift
259 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 {
|
||
!$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" })
|
||
}
|
||
}
|