Files
lanework/KanbanTests/DragAutoScrollDriverTests.swift
T
rzen 1fc8aaa249 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
2026-08-01 20:22:42 -04:00

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