Build the full-visibility lane layout
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
This commit is contained in:
@@ -50,6 +50,7 @@ private let everyOperation: [WriteOperation] = [
|
||||
.restore(title: "Fix login"),
|
||||
.purge(title: "Fix login"),
|
||||
.style(title: "Fix login"),
|
||||
.resize(title: "Fix login"),
|
||||
.importAttachment(filename: "photo.png"),
|
||||
.listAttachments,
|
||||
.renumberChildren,
|
||||
@@ -64,6 +65,7 @@ private let titledOperations: [(with: WriteOperation, without: WriteOperation)]
|
||||
(.restore(title: "Fix login"), .restore(title: nil)),
|
||||
(.purge(title: "Fix login"), .purge(title: nil)),
|
||||
(.style(title: "Fix login"), .style(title: nil)),
|
||||
(.resize(title: "Fix login"), .resize(title: nil)),
|
||||
]
|
||||
|
||||
// MARK: - Ordering
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// Unit tests for the board strip's pure geometry (03-board-ui.md § Layout — full visibility and
|
||||
/// § Lane): the resting division of the window across width units, and the right-edge drag's
|
||||
/// asymmetric "shadow leads" snap.
|
||||
///
|
||||
/// Fixture throughout the snap suites: a frozen standard of 100 and a gap of 12 — so a `step`
|
||||
/// (`standard + gap`) is 112, and the slot widths are:
|
||||
/// 1× = 100 · 2× = 212 · 3× = 324
|
||||
/// The tick-up threshold for shadow `k` is `slotWidth(k) + gap` — the far side of the gap trailing
|
||||
/// that slot — so 1↔2 ticks up at 112 (100 + 12) and 2↔3 at 224 (212 + 12). The tick-down threshold
|
||||
/// back to `k - 1` is `slotWidth(k - 1) + gap - reentry`, 10pt shy of that same boundary — so 2↔1
|
||||
/// ticks down at 102 (112 − 10) and 3↔2 at 214 (224 − 10).
|
||||
///
|
||||
/// Where the pathfinder's twin suite pinned a hard 1…3 width cap, these pin the *screen fit*: in
|
||||
/// Lanework `allowedRange`'s ceiling is only ever how far the window can grow (`maxUnits`), because
|
||||
/// the width field itself has no cap.
|
||||
|
||||
private let standard: CGFloat = 100
|
||||
private let gap: CGFloat = 12
|
||||
private let step: CGFloat = standard + gap // 112
|
||||
private let reentry: CGFloat = 10
|
||||
|
||||
/// A screen that fits three units — the drag's ceiling in most tests below.
|
||||
private let fitsThree = 1...3
|
||||
|
||||
private func slot(_ units: Int) -> CGFloat {
|
||||
LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
|
||||
}
|
||||
|
||||
private func snapped(_ liveWidth: CGFloat, current: Int, range: ClosedRange<Int>? = nil) -> Int {
|
||||
LaneLayoutMath.snappedUnits(liveWidth: liveWidth, currentUnits: current,
|
||||
standard: standard, gap: gap,
|
||||
allowedRange: range ?? fitsThree, reentry: reentry)
|
||||
}
|
||||
|
||||
// MARK: - Lane fixtures
|
||||
|
||||
/// Loads a board whose lanes carry the given `width:` frontmatter values (`nil` writes no key), in
|
||||
/// the order given — `order` keys make the display order the argument order.
|
||||
///
|
||||
/// Built through `BoardLoader` rather than by constructing `Lane` values by hand: `width`'s
|
||||
/// leniency is a *read-side* rule (01-storage-format.md § Frontmatter), so the only honest way to
|
||||
/// ask "what does a malformed width display as" is to put the malformed bytes on disk and load
|
||||
/// them.
|
||||
private func lanes(widths: [String?]) throws -> [Lane] {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("LaneLayoutMathTests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
try write("---\nschema: 1\ntitle: Board\n---\n", to: root)
|
||||
for (index, width) in widths.enumerated() {
|
||||
var frontmatter = "---\nschema: 1\ntitle: Lane \(index)\norder: \(1024 * (index + 1))\n"
|
||||
if let width {
|
||||
frontmatter += "width: \(width)\n"
|
||||
}
|
||||
frontmatter += "---\n"
|
||||
let folder = root.appendingPathComponent(UUID().uuidString.lowercased(), isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
try write(frontmatter, to: folder)
|
||||
}
|
||||
return try BoardLoader.load(boardRoot: root).model.lanes
|
||||
}
|
||||
|
||||
private func write(_ text: String, to folder: URL) throws {
|
||||
try Data(text.utf8).write(to: folder.appendingPathComponent("index.md"))
|
||||
}
|
||||
|
||||
private func lane(width: String?) throws -> Lane {
|
||||
let loaded = try lanes(widths: [width])
|
||||
return try #require(loaded.first)
|
||||
}
|
||||
|
||||
// MARK: - The resting layout
|
||||
|
||||
@Suite("LaneLayoutMath ▸ the resting division")
|
||||
struct LaneLayoutStandardWidthTests {
|
||||
|
||||
@Test("The strip divides its width across the units, counting a gap outside each end")
|
||||
func standardWidthCountsOuterMargins() {
|
||||
// 1000 = 1 lane + 2 outer gaps: (1000 − 24) / 1.
|
||||
#expect(LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 1, gap: 12) == 976)
|
||||
// Three units: 4 gaps (2 interior + 2 outer) → (1000 − 48) / 3.
|
||||
#expect(abs(LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 3, gap: 12) - 952.0 / 3.0) < 0.0001)
|
||||
// A wide lane consumes several units of the same division, and the strip still fills
|
||||
// exactly: 4 units of standard plus the gaps is the whole strip.
|
||||
let standard = LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 4, gap: 12)
|
||||
let filled = 4 * standard + 12 * 5
|
||||
#expect(abs(filled - 1000) < 0.0001)
|
||||
}
|
||||
|
||||
@Test("A slot swallows the interior gaps it spans, so lanes and slots are the same pixels")
|
||||
func slotWidthSwallowsInteriorGaps() {
|
||||
#expect(slot(1) == 100)
|
||||
#expect(slot(2) == 212) // 2·100 + 1·12
|
||||
#expect(slot(3) == 324) // 3·100 + 2·12
|
||||
}
|
||||
|
||||
@Test("Compression is accepted, not floored — only a 1pt floor keeps frames positive")
|
||||
func degenerateStripsCompressWithoutAMinimum() {
|
||||
// Twenty units in a small window: each lane is a sliver, and that is the design's answer
|
||||
// ("the degenerate case is accepted, not floored"), not a scroll bar.
|
||||
let squeezed = LaneLayoutMath.standardWidth(stripWidth: 400, totalUnits: 20, gap: 12)
|
||||
#expect(squeezed > 0)
|
||||
#expect(squeezed < 10)
|
||||
// Narrower than its own gaps: still positive, because a zero or negative frame is a
|
||||
// rendering bug rather than a design outcome.
|
||||
#expect(LaneLayoutMath.standardWidth(stripWidth: 10, totalUnits: 4, gap: 12) == 1)
|
||||
#expect(LaneLayoutMath.standardWidth(stripWidth: 0, totalUnits: 1, gap: 12) == 1)
|
||||
}
|
||||
|
||||
@Test("An empty strip still has a divisor")
|
||||
func zeroUnitsIsTreatedAsOne() {
|
||||
#expect(LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 0, gap: 12) == 976)
|
||||
#expect(LaneLayoutMath.totalUnits(of: []) == 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reading a lane's units
|
||||
|
||||
@Suite("LaneLayoutMath ▸ display units")
|
||||
struct LaneDisplayUnitsTests {
|
||||
|
||||
@Test("A valid width spans that many units")
|
||||
func validWidthSpansItsUnits() throws {
|
||||
#expect(LaneLayoutMath.displayUnits(of: try lane(width: "3")) == 3)
|
||||
#expect(LaneLayoutMath.displayUnits(of: try lane(width: "1")) == 1)
|
||||
// The read side coerces where a sensible reading exists, and the layout follows it.
|
||||
#expect(LaneLayoutMath.displayUnits(of: try lane(width: "\"2\"")) == 2)
|
||||
}
|
||||
|
||||
@Test("A missing, malformed, zero or negative width renders as one unit")
|
||||
func leniencyRendersAsOne() throws {
|
||||
let missing = try lane(width: nil)
|
||||
#expect(missing.width.isMissing)
|
||||
#expect(LaneLayoutMath.displayUnits(of: missing) == 1)
|
||||
|
||||
// Each of these stays `.malformed` on the model — the bytes are preserved, not corrected —
|
||||
// and renders as 1 (01-storage-format.md § Frontmatter's lenient-field rule).
|
||||
for raw in ["wide", "0", "-3", "1.5"] {
|
||||
let lane = try lane(width: raw)
|
||||
#expect(lane.width.isMalformed, "width: \(raw) should stay malformed rather than coerce")
|
||||
#expect(LaneLayoutMath.displayUnits(of: lane) == 1, "width: \(raw) should render as one unit")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("The strip's unit total is the sum over the lanes it is given")
|
||||
func totalUnitsSumsDisplayUnits() throws {
|
||||
let loaded = try lanes(widths: ["2", nil, "3", "banana"])
|
||||
#expect(loaded.map(LaneLayoutMath.displayUnits(of:)) == [2, 1, 3, 1])
|
||||
#expect(LaneLayoutMath.totalUnits(of: loaded) == 7)
|
||||
#expect(LaneLayoutMath.totalUnits(of: Array(loaded.prefix(2))) == 3)
|
||||
}
|
||||
|
||||
@Test("There is no upper cap on a lane's width")
|
||||
func widthIsUncapped() throws {
|
||||
// 03-board-ui.md § Lane: "1×, 2×, 3×, … — no cap". A wide lane simply takes more of the
|
||||
// division; nothing clamps it on the way in.
|
||||
let wide = try lane(width: "40")
|
||||
#expect(LaneLayoutMath.displayUnits(of: wide) == 40)
|
||||
#expect(LaneLayoutMath.totalUnits(of: [wide]) == 40)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The snap
|
||||
|
||||
@Suite("LaneLayoutMath ▸ the drag's snap")
|
||||
struct LaneSnapTests {
|
||||
|
||||
// MARK: Tick up — fires just past the far side of the gap, not inside it
|
||||
|
||||
@Test("Ticks up only past the far side of the trailing gap")
|
||||
func ticksUpOnlyPastTheFarSideOfTheGap() {
|
||||
// Slot 1 is 100; its trailing gap runs to 112 (100 + 12). The shadow must not lead the live
|
||||
// edge while the edge is still IN the gap.
|
||||
#expect(snapped(111.9, current: 1) == 1) // still inside the gap holds
|
||||
#expect(snapped(112, current: 1) == 1) // exactly at the far edge holds (strict >)
|
||||
#expect(snapped(112.1, current: 1) == 2) // clearing the gap ticks up immediately
|
||||
}
|
||||
|
||||
@Test("Ticks up by exactly one per call")
|
||||
func ticksUpByExactlyOne() {
|
||||
// A live width well into 3×'s slot still steps only one unit per call (the continuous drag
|
||||
// calls this on every event, and the session iterates it to a fixed point).
|
||||
#expect(snapped(1000, current: 1) == 2)
|
||||
#expect(snapped(1000, current: 2) == 3)
|
||||
}
|
||||
|
||||
// MARK: Tick down — fires only 10pt back inside the gap just crossed
|
||||
|
||||
@Test("Ticks down only below the 10pt re-entry point")
|
||||
func ticksDownOnlyBelowTheReentryPoint() {
|
||||
// Coming back from 2×, the boundary is slot(1) + gap = 112; tick-down requires retreating a
|
||||
// further 10pt to 102.
|
||||
#expect(snapped(102.1, current: 2) == 2) // just above the re-entry point holds
|
||||
#expect(snapped(102, current: 2) == 2) // exactly at the re-entry point holds (strict <)
|
||||
#expect(snapped(101.9, current: 2) == 1) // clearing the re-entry point ticks down
|
||||
}
|
||||
|
||||
@Test("Re-entering the gap is not enough to tick down")
|
||||
func ticksDownDoesNotFireWhileStillInTheGap() {
|
||||
// Immediately after re-entering the gap (e.g. 110), the edge has NOT yet retreated the full
|
||||
// 10pt, so the shadow must still hold at 2× — this is the asymmetry: growing was instant,
|
||||
// shrinking is not.
|
||||
#expect(snapped(110, current: 2) == 2)
|
||||
#expect(snapped(103, current: 2) == 2)
|
||||
}
|
||||
|
||||
// MARK: Hold-band stability from both directions
|
||||
|
||||
@Test("The hold band is stable from both directions")
|
||||
func holdBandIsStable() {
|
||||
// The hold band for shadow 2× is (102, 224] — everything strictly between the 1↔2 re-entry
|
||||
// point and the 2↔3 tick-up threshold holds at 2× with no oscillation.
|
||||
for width in stride(from: CGFloat(103), through: 223, by: 20) {
|
||||
#expect(snapped(width, current: 2) == 2, "2× holds at \(width)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("A value that just ticked up does not immediately tick back down")
|
||||
func noImmediateReversalAfterTickingUp() {
|
||||
// Crossing 112.1 ticks 1× → 2×. Re-evaluating at that same live width with the NEW unit
|
||||
// count must hold, not bounce back — 112.1 is comfortably above the 2↔1 re-entry point.
|
||||
let justTicked = snapped(112.1, current: 1)
|
||||
#expect(justTicked == 2)
|
||||
#expect(snapped(112.1, current: justTicked) == 2)
|
||||
}
|
||||
|
||||
@Test("A value that just ticked down does not immediately tick back up")
|
||||
func noImmediateReversalAfterTickingDown() {
|
||||
let justTicked = snapped(101.9, current: 2)
|
||||
#expect(justTicked == 1)
|
||||
#expect(snapped(101.9, current: justTicked) == 1)
|
||||
}
|
||||
|
||||
// MARK: Range clamping
|
||||
|
||||
@Test("The snap never steps past the allowed range")
|
||||
func neverTicksPastTheAllowedRange() {
|
||||
#expect(snapped(5000, current: 3) == 3, "the on-screen fit is the ceiling")
|
||||
#expect(snapped(0, current: 1) == 1, "one unit is the floor")
|
||||
#expect(snapped(-500, current: 1) == 1)
|
||||
}
|
||||
|
||||
@Test("The ceiling is the screen fit, and it is the only ceiling")
|
||||
func snapRespectsTheOnScreenFit() {
|
||||
// With the fit capping the range at 2×, no live width ticks to 3×.
|
||||
let fitsTwo = 1...2
|
||||
#expect(snapped(1000, current: 2, range: fitsTwo) == 2)
|
||||
#expect(snapped(5000, current: 2, range: fitsTwo) == 2)
|
||||
// Below the cap it still ticks normally.
|
||||
#expect(snapped(200, current: 1, range: fitsTwo) == 2)
|
||||
// A roomier screen keeps ticking well past the pathfinder's old 3× ceiling — Lanework's
|
||||
// width has no cap of its own (03-board-ui.md § Lane).
|
||||
#expect(snapped(5000, current: 3, range: 1...9) == 4)
|
||||
#expect(snapped(5000, current: 8, range: 1...9) == 9)
|
||||
}
|
||||
|
||||
// MARK: maxUnits
|
||||
|
||||
@Test("maxUnits turns window headroom into whole growable units")
|
||||
func maxUnitsFromHeadroom() {
|
||||
// Two whole steps of headroom (2 × 112 = 224) → grow up to +2.
|
||||
#expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 250, step: step) == 3)
|
||||
// Just over one step → +1.
|
||||
#expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 120, step: step) == 2)
|
||||
// Less than a step → no growth room, but shrinking stays allowed.
|
||||
#expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 50, step: step) == 1)
|
||||
#expect(LaneLayoutMath.maxUnits(currentUnits: 2, headroom: 0, step: step) == 2)
|
||||
// Never below currentUnits even with a negative headroom (a window already past the visible
|
||||
// frame) — shrinking is always allowed.
|
||||
#expect(LaneLayoutMath.maxUnits(currentUnits: 2, headroom: -300, step: step) == 2)
|
||||
// No width ceiling to clamp against: a huge screen means a huge fit. The pathfinder capped
|
||||
// this at 3.
|
||||
#expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 10_000, step: step) == 90)
|
||||
// A degenerate step is not divided by.
|
||||
#expect(LaneLayoutMath.maxUnits(currentUnits: 2, headroom: 500, step: 0) == 2)
|
||||
}
|
||||
|
||||
// MARK: Rubber-band resistance
|
||||
|
||||
@Test("Inside the bounds the live width passes through untouched")
|
||||
func widthPassesThroughInsideBounds() {
|
||||
#expect(LaneLayoutMath.resistedWidth(proposed: 200, minSlot: slot(1),
|
||||
maxSlot: slot(3), resistance: 0.25) == 200)
|
||||
#expect(LaneLayoutMath.resistedWidth(proposed: slot(1), minSlot: slot(1),
|
||||
maxSlot: slot(3), resistance: 0.25) == slot(1))
|
||||
#expect(LaneLayoutMath.resistedWidth(proposed: slot(3), minSlot: slot(1),
|
||||
maxSlot: slot(3), resistance: 0.25) == slot(3))
|
||||
}
|
||||
|
||||
@Test("Past either bound the edge gives only a quarter of the overshoot")
|
||||
func widthResistsPastEitherBound() {
|
||||
// 20 below the 100 floor → 100 − 20·0.25 = 95.
|
||||
#expect(LaneLayoutMath.resistedWidth(proposed: 80, minSlot: slot(1),
|
||||
maxSlot: slot(3), resistance: 0.25) == 95)
|
||||
// 76 above the 324 ceiling → 324 + 76·0.25 = 343.
|
||||
#expect(LaneLayoutMath.resistedWidth(proposed: 400, minSlot: slot(1),
|
||||
maxSlot: slot(3), resistance: 0.25) == 343)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
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" })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user