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.Continuation? private var startWaiters: [CheckedContinuation] = [] private var stopWaiters: [CheckedContinuation] = [] 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 { let (stream, continuation) = AsyncStream.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() } }