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
248 lines
11 KiB
Swift
248 lines
11 KiB
Swift
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()
|
||
}
|
||
}
|