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
360 lines
16 KiB
Swift
360 lines
16 KiB
Swift
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))
|
||
}
|
||
}
|