Files
lanework/KanbanTests/LaneResizeHoldTests.swift
rzen 409f430813 The lane-resize release gets its own hold — the strip stops two-stepping through the stale snapshot
LaneWidthHold: after the release the session's frozen standard and the
WRITTEN unit count keep governing the strip until a landed snapshot
carries that width back — dividing the already-grown window by the
stale unit total in between was the visible two-step. A deliberately
separate type from CommittedHold: that hold stands in for an
arrangement and any landing retires it; this one stands in for a value
and only a landing that carries it will do. Echo reads through
LaneLayoutMath.displayUnits so the width-1 key-removal case compares
right; the watch rides landedReloads so a value-equal echo still
answers; width writes now return whether bytes reached disk so a
refused or no-op write dissolves the hold instead of arming it; the
1500ms timeout family covers the rest.

Drag-perf card 231e3693.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 19:33:33 -04:00

360 lines
16 KiB
Swift
Raw Permalink 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**, because with no `NSWindow` to measure the on-screen fit is
/// the count the drag started from (`LaneResizeSession.fittingMaxUnits`) and growth has no room —
/// direction is nothing to the hold's state machine, which is what is under test.
private let standard: CGFloat = 100
private let gap: CGFloat = 12
/// 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, 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, 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))
}
}