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:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import Testing
|
||||
|
||||
/// `DragAutoScrollMath` — given a viewport and a pointer inside (or just outside) it, how fast, and
|
||||
/// which way, should the scroll view move? Ported from the pathfinder's suite, whose numbers are
|
||||
/// what was proven. The live driver is the drag session's; this is the decision it makes 60 times a
|
||||
/// second (DRAG-REORDER.md § Edge autoscroll).
|
||||
/// what was proven. The live driver is the drag session's; this is the decision it makes once per
|
||||
/// frame of the display the lane is on (`DragAutoScrollDriverTests`, DRAG-REORDER.md § Edge
|
||||
/// autoscroll).
|
||||
|
||||
private let length: CGFloat = 400
|
||||
private let band = DragAutoScrollMath.band
|
||||
|
||||
Reference in New Issue
Block a user