Files
lanework/KanbanTests/LaneResizeHoldTests.swift
T
rzen 9a52b795b2 The drag learns the stepper's trick — past the screen's edge, lane growth re-divides instead of stopping
The right-edge drag's growth was capped at the screen's visible frame,
because each snap tick grows the window; on a window near the screen edge
that left a lane stuck at a tick or two of headroom. Settled 2026-08-08
(03-board-ui.md § Lane, superseding the pathfinder's hard stop): at the
screen the window pins and each further tick re-divides the fixed strip
width across one more unit — siblings compress, the stepper's mechanism
arriving under the drag's fingers. The regimes meet with no pixel jump
(the re-divided standard at the fit IS the frozen standard, by the
exact-fill identity), shrinking mirrors the way back, the rubber band
moves to the strip's own capacity, and a window with no headroom at all —
full screen included — re-divides from the very first snap.

New pure arithmetic in LaneLayoutMath (pinnedStripWidth, resizeStandard,
resizeMaxUnits, resizeWindowDelta, snappedUnits over per-count slots);
LaneResizeSession splits the tick across the regimes and derives its
standard from the live count; the handle and BoardView hand the session
the strip's whole divide. 2709 unit tests green (+11).

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 21:36:56 -04:00

436 lines
20 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 CoreGraphics
import Foundation
import Testing
@testable import Kanban
/// **The lane-resize release hold** (`LaneWidthHold`, `LaneResizeSession.end`): the bridge across
/// the one-way flow's round trip between a release and the reload that carries its write.
///
/// The bug these pin is a visible two-step. The drag grows the *window* one standard-plus-gap per
/// tick and freezes the strip's standard so the siblings never move; the release then writes the new
/// unit count, which takes a Writer → disk → watcher → reload round trip to reach the snapshot. A
/// session that cleared at the release handed the strip back to
/// `LaneLayoutMath.standardWidth(stripWidth:totalUnits:gap:)` for that whole stretch — the *grown*
/// window divided by the *stale* unit total — so every lane took a proportionally wrong width and
/// the board jumped a second time when the echo landed.
///
/// Fixture geometry is `LaneLayoutMathTests`': a frozen standard of 100 and a gap of 12, so the slot
/// widths are 1× = 100 · 2× = 212 · 3× = 324, and the tick-down thresholds are 214 (3→2) and 102
/// (2→1). Every drag here **shrinks**, which keeps the arithmetic the frozen standard's throughout:
/// with no `NSWindow` to measure, the on-screen fit is the count the drag started from
/// (`LaneResizeSession.fittingMaxUnits`), so growth would land in the re-divide regime — that is the
/// suite at the foot of this file, and direction is nothing to the hold's state machine, which is
/// what is under test here.
private let standard: CGFloat = 100
private let gap: CGFloat = 12
/// The fixture strip's whole divide — the 1× lane plus the 3× one, trash hidden.
private let boardUnits = 4
/// Enough leftward translation from a 3× start to land on 2×, and on 1×: 324 120 = 204, under the
/// 214 threshold and over the 102 one; 324 240 rubber-bands to 96, under both.
private let toTwoUnits: CGFloat = -120
private let toOneUnit: CGFloat = -240
private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
private func laneText(order: String, title: String, width: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
width: \(width)
created: 2026-01-01T09:00:00Z
---
\(title) body.
"""
}
/// One plain lane (no `width`, so 1×) and one at 3× — the one every drag below shrinks.
@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"))
return fixture
}
@MainActor
@Suite("The lane-resize release hold")
struct LaneResizeHoldTests {
// MARK: - Harness
/// One walk through the store's inbound door — the echo, as the watcher delivers it.
private func reload(_ store: BoardStore, _ origin: WatchOrigin = .appMediated) async {
store.handleWatcherEvent(.treeChanged(origin))
await store.awaitQuiescence()
}
/// A session mid-drag on lane two, `translation` points to the left of its 3× start.
private func dragging(_ translation: CGFloat) -> LaneResizeSession {
let session = LaneResizeSession()
session.begin(laneID: lane2, units: 3, standard: standard, gap: gap,
totalUnits: boardUnits, window: nil)
session.update(translation: translation)
return session
}
/// A session that has released onto `store` and is holding the width it wrote.
private func settled(on store: BoardStore, translation: CGFloat = toTwoUnits) -> LaneResizeSession {
let session = dragging(translation)
session.end { id, units in store.setLaneWidth(id, units: units) }
return session
}
private func lane(_ id: String, in store: BoardStore) throws -> Lane {
try #require(store.snapshot.lanes.first { $0.id.rawValue == id })
}
/// Polls for `condition`, because the deadline's dissolve is a `Task` on this very actor: the
/// test has to yield for it to run at all. Bounded, so a dissolve that never comes fails rather
/// than hangs.
private func settles(_ condition: () -> Bool) async -> Bool {
for _ in 0..<200 {
if condition() { return true }
try? await Task.sleep(for: .milliseconds(5))
}
return condition()
}
// MARK: - Entering the hold
@Test("The release holds instead of clearing — the frozen standard and the written units keep governing")
func releaseEntersTheHold() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = settled(on: store)
#expect(session.isSettled)
#expect(session.hold == LaneWidthHold(laneID: lane2, units: 2))
// Still governing — which is the whole fix. `isActive` is what `BoardView.standardWidth`
// reads to keep the frozen standard in force, and `governs` is what the lane slot reads.
#expect(session.isActive)
#expect(session.governs(lane2))
#expect(session.standard == standard)
// …but no longer *dragging*: no mouse is holding this.
#expect(!session.isDragging)
#expect(!session.isDragging(lane2))
// The live width comes to rest on the written count's slot, measured against the frozen
// standard: the last sub-tick of overflow animates away and nothing else moves.
#expect(session.liveWidth == LaneLayoutMath.slotWidth(units: 2, standard: standard, gap: gap))
// The snapshot is a round trip behind, and this is precisely the window in which reading it
// would draw the pre-drag layout.
let stale = try lane(Ident.lane2, in: store)
#expect(LaneLayoutMath.displayUnits(of: stale) == 3)
#expect(session.displayUnits(of: stale) == 2, "the written count answers, not the stale one")
#expect(session.displayUnits(of: try lane(Ident.lane1, in: store)) == 1,
"a lane the session does not govern still reads from the snapshot")
}
@Test("A drag that ends where it started writes nothing and holds nothing")
func anUnchangedReleaseNeverArms() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// 3× released at 3×: `setLaneWidth`'s unchanged-units guard refuses it, so there is no write,
// no reload and therefore no echo — an overlay armed here would only ever time out.
let session = dragging(0)
session.end { id, units in store.setLaneWidth(id, units: units) }
#expect(!session.isSettled)
#expect(!session.isActive)
#expect(session.liveWidth == 0)
}
@Test("A refused write dissolves the session outright — the hold must not outlive it")
func aRefusedWriteNeverArms() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.enterVanishedRootLock()
let session = settled(on: store)
// Nothing reached disk, so nothing is coming back: the snapshot takes the layout straight
// back and the lane returns to its pre-drag width, which is the truth (and the banner row is
// already saying why).
#expect(!session.isSettled)
#expect(!session.isActive)
#expect(try lane(Ident.lane2, in: store).width == .valid(3))
}
// MARK: - The hand-off
@Test("Only a snapshot carrying the written width releases the hold")
func theEchoReleasesIt() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = settled(on: store)
session.handOff(against: store.snapshot.lanes)
#expect(session.isSettled, "a snapshot that still says the old width is not the echo")
await reload(store)
#expect(try lane(Ident.lane2, in: store).width == .valid(2))
#expect(store.snapshotGeneration == 1)
session.handOff(against: store.snapshot.lanes)
#expect(!session.isSettled)
#expect(!session.isActive)
#expect(session.liveWidth == 0)
}
@Test("A reload that does not carry the written width leaves the hold standing, whatever else moved")
func anUnrelatedReloadDoesNotRelease() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = settled(on: store)
// Somebody else got there first: another lane renamed, and this lane's width put back where
// it was. The walk lands and moves the generation — which is why this hold is retired by a
// *value* and not by a counter, unlike the drag's `CommittedHold`. Handing the strip back on
// the bump alone would divide the already-grown window by the unit total the board still
// has, which is the two-step the hold exists to remove.
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Renamed"))
try fixture.item(Ident.lane2, laneText(order: "2048", title: "Doing", width: "3"))
await reload(store, .foreign)
#expect(store.snapshotGeneration == 1)
#expect(try lane(Ident.lane2, in: store).width == .valid(3))
session.handOff(against: store.snapshot.lanes)
#expect(session.isSettled)
#expect(session.governs(lane2))
// And the deadline is what ends it, since the width it named is never coming.
session.expire(try #require(session.hold))
#expect(!session.isActive)
}
@Test("A landed walk that assigns nothing still releases a hold the current snapshot satisfies")
func aValueEqualWalkReleasesIt() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = settled(on: store)
// The echo lands and is applied, but nobody asks the hold about it yet; then a second walk
// lands over the same tree. That one comes back value-equal, so it skips the assignment and
// moves no `snapshotGeneration` — a hold watching for a *bump* would sit out its whole
// deadline over a snapshot that already agrees with it.
await reload(store)
await reload(store)
#expect(store.landedReloads == 2)
#expect(store.snapshotGeneration == 1, "the second walk had nothing to assign")
session.handOff(against: store.snapshot.lanes)
#expect(!session.isSettled)
#expect(!session.isActive)
}
@Test("A width landing on one is echoed by an ABSENT key, and that releases the hold")
func theOneUnitEchoIsAnAbsentKey() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = settled(on: store, translation: toOneUnit)
#expect(session.hold == LaneWidthHold(laneID: lane2, units: 1))
await reload(store)
// The remove-at-default rule: there is no `width: 1` on disk to compare against, so the
// condition can only ever be the *display* reading of an absent key.
#expect(try lane(Ident.lane2, in: store).width.isMissing)
session.handOff(against: store.snapshot.lanes)
#expect(!session.isSettled)
#expect(!session.isActive)
}
@Test("A lane that vanished under the gesture retires the hold — the snapshot is the authority")
func aVanishedLaneRetiresIt() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = settled(on: store)
session.handOff(against: store.snapshot.lanes.filter { $0.id != lane2 })
#expect(!session.isSettled)
#expect(!session.isActive)
}
/// The condition as a value, away from the session — both traps in one place.
@Test("The hold's condition reads the width exactly as the board does")
func theConditionIsTheDisplayReading() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let atThree = try lane(Ident.lane2, in: store)
let keyless = try lane(Ident.lane1, in: store)
#expect(!LaneWidthHold(laneID: lane2, units: 2).isRetired(by: [atThree]))
#expect(LaneWidthHold(laneID: lane2, units: 3).isRetired(by: [atThree]))
// A lane with no `width` key at all displays one unit, which is what a 1× write leaves
// behind — and is therefore the echo of one.
#expect(LaneWidthHold(laneID: lane1, units: 1).isRetired(by: [keyless]))
#expect(!LaneWidthHold(laneID: lane1, units: 2).isRetired(by: [keyless]))
#expect(LaneWidthHold(laneID: lane2, units: 2).isRetired(by: []))
}
// MARK: - The deadline
@Test("A hold with no echo coming times out, and the snapshot takes the layout back")
func theDeadlineDissolvesIt() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = dragging(toTwoUnits)
// The seam: the real figure is `LaneWidthHold.timeout`, and waiting it out would be 1.5 s of
// wall clock in the suite for a claim about the dissolve rather than about the clock.
session.holdTimeout = .milliseconds(20)
session.end { id, units in store.setLaneWidth(id, units: units) }
#expect(session.isSettled)
#expect(await settles { !session.isSettled }, "a hold with no hand-off coming must dissolve")
#expect(!session.isActive)
#expect(session.liveWidth == 0)
}
@Test("The dissolve ends the hold it was armed for, and no other")
func theDissolveEndsOnlyItsOwnHold() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = settled(on: store)
let hold = try #require(session.hold)
session.expire(LaneWidthHold(laneID: lane1, units: 9))
#expect(session.isSettled, "a hold this session is not holding is not this session's to end")
session.expire(hold)
#expect(!session.isSettled)
#expect(!session.isActive)
}
@Test("A second drag supersedes a standing hold, and the retired deadline never reaches it")
func aSecondDragSupersedesTheHold() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = dragging(toTwoUnits)
session.holdTimeout = .milliseconds(20)
session.end { id, units in store.setLaneWidth(id, units: units) }
// The user grabs the same edge again before the echo. The anchor is what is on screen — the
// width the release wrote — not the snapshot's, which is still a round trip behind.
let onScreen = session.displayUnits(of: try lane(Ident.lane2, in: store))
#expect(onScreen == 2)
session.begin(laneID: lane2, units: onScreen, standard: standard, gap: gap,
totalUnits: boardUnits, window: nil)
#expect(!session.isSettled)
#expect(session.isDragging(lane2))
#expect(session.units == 2)
try? await Task.sleep(for: .milliseconds(80))
#expect(session.isDragging(lane2), "the retired hold's deadline must not end the drag that followed it")
}
@Test("A hold ignores drag updates — no mouse is driving it")
func theHoldTakesNoTranslations() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = settled(on: store)
session.update(translation: toOneUnit)
#expect(session.units == 2)
#expect(session.liveWidth == LaneLayoutMath.slotWidth(units: 2, standard: standard, gap: gap))
}
}
// MARK: - The drag past the screen
/// **The re-divide at the session level** (03-board-ui.md § Lane, settled 2026-08-08). A window with
/// no headroom — one already flush against the screen's edge, or full screen, and in a fixture one
/// with no `NSWindow` at all — has an on-screen fit equal to the count the drag started from, so its
/// very first tick is a re-divide: the strip's width is pinned, the dragged lane takes one more unit
/// of it, and the siblings compress. The clamp that used to sit at the fit is what made a lane on a
/// maximised window refuse to widen at all.
///
/// Same fixture as above — the 1× lane, the 3× lane and no trash, so a four-unit strip 460 points
/// wide (100·4 + 12·5) — dragged from **one** unit, which with no window makes the fit 1× and puts
/// every tick in the second regime.
@MainActor
@Suite("The lane resize past the screen's edge")
struct LaneResizePastTheScreenTests {
/// A drag of the 1× lane, `translation` points to the right of its start.
private func dragging(_ translation: CGFloat) -> LaneResizeSession {
let session = LaneResizeSession()
session.begin(laneID: lane1, units: 1, standard: standard, gap: gap,
totalUnits: boardUnits, window: nil)
session.update(translation: translation)
return session
}
@Test("With no window to grow, the drag re-divides instead of refusing to tick")
func aPinnedWindowRedividesFromTheFirstTick() {
let session = dragging(200)
// The 460pt strip re-divided: 2× puts the lane's edge at 167.2, 3× at 212, … and 7× at
// 301.6, whose trailing gap is the first threshold the 300pt live edge has not cleared.
#expect(session.units == 7, "the fit is 1× here, and the old clamp stopped the tick dead")
#expect(session.liveWidth == 300, "well inside the strip's capacity, so no resistance")
// The siblings compress, which is what a re-divide IS: the standard every other lane is
// drawn at comes down as the dragged one takes more units of the same strip.
#expect(abs(session.standard - 32.8) < 0.0001) // (460 12·11) / 10
#expect(session.standard < standard)
}
@Test("The tick still stops — at the strip's capacity, where the re-divide runs out of strip")
func theRedivideStopsAtTheStripsCapacity() {
// floor((460 12) / 13) = 34 whole units the strip can still divide into, of which this
// lane contributes 31. Past that `standardWidth`'s 1pt floor would break the exact fill.
let session = dragging(10_000)
#expect(session.units == 31)
#expect(session.liveWidth > LaneLayoutMath.slotWidth(units: 31, standard: session.standard, gap: gap),
"the rubber band gives past the true end of travel, and the tick does not follow")
}
@Test("A release past the fit settles on the re-divided slot, not the frozen one")
func theSettleUsesTheRedividedStandard() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = dragging(200)
session.end { id, units in store.setLaneWidth(id, units: units) }
#expect(session.isSettled)
#expect(session.hold == LaneWidthHold(laneID: lane1, units: 7))
// The width the release wrote, measured against the standard THAT count implies — the one
// still governing the strip while the hold stands.
#expect(session.liveWidth
== LaneLayoutMath.slotWidth(units: 7, standard: session.standard, gap: gap))
#expect(abs(session.liveWidth - 301.6) < 0.0001)
}
}