Autoscroll ticks on the display's own clock — the 16ms sleeping loop retires

DragAutoScroller.run() consumes an AsyncStream of CADisplayLink
targetTimestamps from NSView.displayLink(target:selector:) on the
lane's existing anchor view (which was already the right NSView — no
new plumbing). The stream is the lifetime: cancellation ends the
iteration, termination invalidates the link, the link releases its
target. DragAutoScrollClock keeps the retired loop's two non-trivial
rules — first tick scrolls nothing, no tick integrates past 50ms (a
paused link resumes with the whole gap as its delta) — and the math is
frame-rate independent by test: one second at the edge moves 800.0
points at 120Hz and at 60Hz alike. Run-loop mode .common is
load-bearing: a drag runs AppKit's event-tracking mode.

Drag-perf card 2e08fd31.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 20:22:42 -04:00
parent 0218ae4c21
commit 1fc8aaa249
4 changed files with 409 additions and 21 deletions
+247
View File
@@ -0,0 +1,247 @@
import AppKit
import CoreGraphics
import Foundation
import Testing
@testable import Kanban
/// **The edge-autoscroll driver's clock** the half of `DragAutoScroller` that is not AppKit
/// (DRAG-REORDER.md § Edge autoscroll; the geometry it feeds is `DragAutoScrollMathTests`).
///
/// The split is where the testable line falls. `step(at:)` reads the physical mouse, the anchor's
/// window and a live `NSClipView`, none of which a unit test can pose; what it *decides* is
/// `elapsed × velocity`, and both factors are pure. So `DragAutoScrollClock` is exercised directly
/// composed with `DragAutoScrollMath` exactly as the step composes them, which is what makes the
/// frame-rate-independence claim a measurement rather than an assertion and the loop around it is
/// exercised through a scripted tick source, because a `CADisplayLink` needs a screen and a run loop
/// and there is neither in a test bundle.
// MARK: - Composing the clock with the geometry, as the step does
/// A tick sequence of `count` frames at `hz`, starting at a plausible `CACurrentMediaTime()` rather
/// than at zero the driver never sees a timeline that begins at the origin.
private func frames(hz: Int, count: Int, from start: CFTimeInterval = 41_297.5) -> [CFTimeInterval] {
(0...count).map { start + CFTimeInterval($0) / CFTimeInterval(hz) }
}
/// Total scroll travel over `ticks`, integrated the way `DragAutoScroller.step(at:)` integrates: the
/// clock's elapsed for this frame, the geometry's velocity for this pointer, `nextOffset` for the
/// new origin.
private func travel(over ticks: [CFTimeInterval],
pointer: CGFloat,
length: CGFloat = 400) -> CGFloat {
let velocity = DragAutoScrollMath.velocity(position: pointer, length: length)
var clock = DragAutoScrollClock()
var offset: CGFloat = 0
for timestamp in ticks {
offset = DragAutoScrollMath.nextOffset(
current: offset,
velocity: velocity,
elapsed: CGFloat(clock.elapsed(at: timestamp)),
// Far beyond anything a second of scrolling can reach, in either direction, so the range
// clamp which `DragAutoScrollMathTests` owns never confounds these numbers.
minOffset: -100_000,
maxOffset: 100_000
)
}
return offset
}
private func isClose(_ value: CGFloat, _ expected: CGFloat, _ tolerance: CGFloat = 0.0001) -> Bool {
abs(value - expected) <= tolerance
}
@Suite("DragAutoScrollClock")
struct DragAutoScrollClockTests {
@Test("A session's first tick establishes the base and scrolls nothing")
func firstTickIsTheBase() {
var clock = DragAutoScrollClock()
#expect(clock.elapsed(at: 41_297.5) == 0)
// And the second measures from it rather than from anything the driver did before.
#expect(isClose(CGFloat(clock.elapsed(at: 41_297.5 + 1.0 / 120)), CGFloat(1.0 / 120)))
}
@Test("Resetting makes the next tick a first tick again")
func resetRestoresTheBase() {
var clock = DragAutoScrollClock()
_ = clock.elapsed(at: 100)
_ = clock.elapsed(at: 100.5)
clock.reset()
#expect(clock.elapsed(at: 900) == 0, "a new drag must not integrate the gap since the last one")
}
@Test("A second at 120Hz and a second at 60Hz travel the same distance")
func frameRateIndependence() {
// Deep in the trailing band, so the velocity is the 800pt/s ceiling and the expected figure
// is arithmetic anyone can check: one second of it.
let atTheEdge: CGFloat = 400
let fast = travel(over: frames(hz: 120, count: 120), pointer: atTheEdge)
let slow = travel(over: frames(hz: 60, count: 60), pointer: atTheEdge)
#expect(isClose(fast, DragAutoScrollMath.maxSpeed))
#expect(isClose(slow, DragAutoScrollMath.maxSpeed))
#expect(isClose(fast, slow, 0.000_001), "\(fast) vs \(slow)")
}
@Test("A ramp speed is frame-rate independent too, not just the ceiling")
func frameRateIndependenceOnTheRamp() {
// Halfway into the leading band: the mean of floor and ceiling, scrolling the other way.
let midBand = DragAutoScrollMath.band / 2
let expected = -(DragAutoScrollMath.minSpeed + DragAutoScrollMath.maxSpeed) / 2
// Same second, three different cadences including one no display runs at, because the
// point is that the integration does not know or care.
let rates = [120, 60, 47]
let travelled = rates.map { travel(over: frames(hz: $0, count: $0), pointer: midBand) }
for (rate, distance) in zip(rates, travelled) {
#expect(isClose(distance, expected, 0.001), "\(rate)Hz travelled \(distance)")
}
}
@Test("An irregular cadence integrates its real span, not its frame count")
func jitterIntegratesTheSpan() {
// 120Hz with two frames dropped and one late the same second of wall clock either way.
let start: CFTimeInterval = 41_297.5
var ticks: [CFTimeInterval] = [start]
var t = start
for step in [0.008, 0.0083, 0.025, 0.0083, 0.0083, 0.0418, 0.9] {
t += step
ticks.append(t)
}
// The 0.9 gap is clamped, so the honest expectation is the sum of the clamped steps.
let expected = DragAutoScrollMath.maxSpeed
* CGFloat(0.008 + 0.0083 + 0.025 + 0.0083 + 0.0083 + 0.0418 + DragAutoScrollClock.maxStep)
#expect(isClose(travel(over: ticks, pointer: 400), expected, 0.001))
}
@Test("A resumed link steps once, not by however long it was paused")
func pauseResumeHitsTheClamp() {
var clock = DragAutoScrollClock()
_ = clock.elapsed(at: 41_297.5)
_ = clock.elapsed(at: 41_297.5 + 1.0 / 120)
// The view was occluded for two seconds; the first frame back must not teleport the lane.
#expect(clock.elapsed(at: 41_299.5) == DragAutoScrollClock.maxStep)
// 40 points rather than 1600.
#expect(isClose(travel(over: [0, 2.0], pointer: 400),
DragAutoScrollMath.maxSpeed * CGFloat(DragAutoScrollClock.maxStep)))
}
@Test("A merely late frame is honoured in full")
func aLateFrameIsNotClamped() {
var clock = DragAutoScrollClock()
_ = clock.elapsed(at: 100)
// Two 60Hz frames' worth: late, well inside the clamp, and worth exactly what it says.
#expect(isClose(CGFloat(clock.elapsed(at: 100 + 1.0 / 30)), CGFloat(1.0 / 30)))
}
@Test("A timestamp that goes backwards scrolls nothing rather than backwards")
func backwardsTimeIsInert() {
var clock = DragAutoScrollClock()
_ = clock.elapsed(at: 500)
#expect(clock.elapsed(at: 499) == 0)
// And the base moved with it, so the sequence recovers rather than stalling.
#expect(isClose(CGFloat(clock.elapsed(at: 499 + 1.0 / 120)), CGFloat(1.0 / 120)))
}
}
// MARK: - The loop and the link's lifetime
/// A stand-in for `DisplayLinkTickSource` whose frames the test writes, and which records the one
/// thing the real source does on termination: it invalidates the link.
@MainActor
private final class ScriptedTicks: DragAutoScrollTickSource {
private var continuation: AsyncStream<CFTimeInterval>.Continuation?
private var startWaiters: [CheckedContinuation<Void, Never>] = []
private var stopWaiters: [CheckedContinuation<Void, Never>] = []
private(set) var started = false
/// Set from the stream's `onTermination`, through the same main-actor hop the real ticker uses to
/// reach `CADisplayLink.invalidate()`.
private(set) var invalidated = false
func ticks(anchoredTo view: NSView?) -> AsyncStream<CFTimeInterval> {
let (stream, continuation) = AsyncStream<CFTimeInterval>.makeStream(bufferingPolicy: .unbounded)
self.continuation = continuation
continuation.onTermination = { _ in
Task { @MainActor in self.markInvalidated() }
}
started = true
for waiter in startWaiters { waiter.resume() }
startWaiters = []
return stream
}
func yield(_ timestamp: CFTimeInterval) {
continuation?.yield(timestamp)
}
func finish() {
continuation?.finish()
}
/// Resumes once the driver has asked for its stream the loop is running and suspended on it.
func waitUntilStarted() async {
guard !started else { return }
await withCheckedContinuation { startWaiters.append($0) }
}
func waitUntilInvalidated() async {
guard !invalidated else { return }
await withCheckedContinuation { stopWaiters.append($0) }
}
private func markInvalidated() {
invalidated = true
for waiter in stopWaiters { waiter.resume() }
stopWaiters = []
}
}
@Suite("DragAutoScrollDriver")
@MainActor
struct DragAutoScrollDriverTests {
@Test("The loop is driven by the frames, and ends with them")
func theLoopFollowsTheStream() async {
let source = ScriptedTicks()
let scroller = DragAutoScroller(tickSource: source)
let running = Task { await scroller.run() }
await source.waitUntilStarted()
for frame in frames(hz: 120, count: 4) { source.yield(frame) }
source.finish()
// Returning at all is the assertion: nothing in the driver sleeps or polls, so a run that
// ends when its frames do is a run the frames were driving. A hang here is the failure.
await running.value
await source.waitUntilInvalidated()
#expect(source.invalidated, "finishing terminates the stream, which retires the link")
}
@Test("Cancelling the drag terminates the stream, which is what invalidates the link")
func cancellationRetiresTheLink() async {
let source = ScriptedTicks()
let scroller = DragAutoScroller(tickSource: source)
// A source that never finishes: only cancellation can end this, which is the drag session's
// contract the `.task(id:)` is cancelled the moment the session clears.
let running = Task { await scroller.run() }
await source.waitUntilStarted()
source.yield(41_297.5)
source.yield(41_297.5 + 1.0 / 120)
running.cancel()
await running.value
await source.waitUntilInvalidated()
#expect(source.invalidated, "the link must not outlive the drag")
}
@Test("A driver with no anchor returns instead of spinning")
func noAnchorNoFrames() async {
// The shipping source with nothing to scroll: a lane whose representable never bound a view
// has no display to link to, and must not leave a `.task` running for the drag's duration.
let scroller = DragAutoScroller()
await scroller.run()
}
}