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
168 lines
7.3 KiB
Swift
168 lines
7.3 KiB
Swift
import CoreGraphics
|
||
import Testing
|
||
@testable import Kanban
|
||
|
||
/// `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 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
|
||
private let minSpeed = DragAutoScrollMath.minSpeed
|
||
private let maxSpeed = DragAutoScrollMath.maxSpeed
|
||
|
||
private func velocity(_ position: CGFloat, length viewport: CGFloat = length) -> CGFloat {
|
||
DragAutoScrollMath.velocity(position: position, length: viewport)
|
||
}
|
||
|
||
private func isClose(_ value: CGFloat, _ expected: CGFloat, _ tolerance: CGFloat = 0.0001) -> Bool {
|
||
abs(value - expected) <= tolerance
|
||
}
|
||
|
||
@Suite("DragAutoScrollMath")
|
||
struct DragAutoScrollMathTests {
|
||
|
||
// MARK: The neutral middle
|
||
|
||
@Test("The middle of the viewport never scrolls")
|
||
func middleNeverScrolls() {
|
||
for position in stride(from: band, through: length - band, by: 8) {
|
||
#expect(velocity(position) == 0, "cursor \(position) is outside both bands")
|
||
}
|
||
// The band boundaries themselves are neutral — a band is the region strictly inside one.
|
||
#expect(velocity(band) == 0)
|
||
#expect(velocity(length - band) == 0)
|
||
}
|
||
|
||
// MARK: Direction
|
||
|
||
@Test("The leading band scrolls toward the start and the trailing band toward the end")
|
||
func direction() {
|
||
#expect(velocity(band - 1) < 0)
|
||
#expect(velocity(0) < 0)
|
||
#expect(velocity(length - band + 1) > 0)
|
||
#expect(velocity(length) > 0)
|
||
}
|
||
|
||
// MARK: The ramp
|
||
|
||
@Test("Speed ramps with edge proximity, on both ends")
|
||
func speedRampsWithProximity() {
|
||
var previous = abs(velocity(band - 0.5))
|
||
for position in stride(from: band - 8, through: 0, by: -8) {
|
||
let speed = abs(velocity(position))
|
||
#expect(speed > previous, "cursor \(position) should beat the shallower sample")
|
||
previous = speed
|
||
}
|
||
previous = abs(velocity(length - band + 0.5))
|
||
for position in stride(from: length - band + 8, through: length, by: 8) {
|
||
let speed = abs(velocity(position))
|
||
#expect(speed > previous, "cursor \(position) should beat the shallower sample")
|
||
previous = speed
|
||
}
|
||
}
|
||
|
||
@Test("The ramp spans the floor to the ceiling, linearly")
|
||
func rampIsLinearBetweenFloorAndCeiling() {
|
||
// Just inside the band: the floor, which exists so entering a band produces visible motion
|
||
// rather than an imperceptible crawl. At the viewport edge: the ceiling. Halfway: the mean.
|
||
#expect(isClose(abs(velocity(band - 0.0001)), minSpeed, 0.01))
|
||
#expect(isClose(abs(velocity(0)), maxSpeed))
|
||
#expect(isClose(abs(velocity(band / 2)), (minSpeed + maxSpeed) / 2))
|
||
#expect(isClose(abs(velocity(length)), maxSpeed))
|
||
#expect(isClose(abs(velocity(length - band / 2)), (minSpeed + maxSpeed) / 2))
|
||
}
|
||
|
||
@Test("Beyond the viewport edge the speed saturates rather than growing")
|
||
func saturatesBeyondTheEdge() {
|
||
// A pointer over the lane header (above the scroll area) or below its bottom padding drives
|
||
// the fastest scroll, never faster.
|
||
#expect(isClose(velocity(-40), -maxSpeed))
|
||
#expect(isClose(velocity(-4000), -maxSpeed))
|
||
#expect(isClose(velocity(length + 40), maxSpeed))
|
||
}
|
||
|
||
// MARK: Degenerate viewports
|
||
|
||
@Test("A short viewport halves its bands instead of overlapping them")
|
||
func shortViewport() {
|
||
let short: CGFloat = 60
|
||
#expect(velocity(30, length: short) == 0, "the exact centre still resolves to no scrolling")
|
||
#expect(velocity(29, length: short) < 0)
|
||
#expect(velocity(31, length: short) > 0)
|
||
#expect(isClose(abs(velocity(0, length: short)), maxSpeed))
|
||
}
|
||
|
||
@Test("An empty or inverted viewport never scrolls")
|
||
func emptyViewport() {
|
||
#expect(velocity(0, length: 0) == 0)
|
||
#expect(velocity(10, length: -5) == 0)
|
||
}
|
||
|
||
// MARK: Two axes
|
||
|
||
@Test("The two axes are resolved independently")
|
||
func axesAreIndependent() {
|
||
let viewport = CGSize(width: 400, height: 400)
|
||
let bottom = DragAutoScrollMath.velocity(pointer: CGPoint(x: 200, y: 390), viewport: viewport)
|
||
#expect(bottom.dx == 0)
|
||
#expect(bottom.dy > 0)
|
||
|
||
let corner = DragAutoScrollMath.velocity(pointer: CGPoint(x: 2, y: 2), viewport: viewport)
|
||
#expect(corner.dx < 0)
|
||
#expect(corner.dy < 0)
|
||
|
||
let centre = DragAutoScrollMath.velocity(pointer: CGPoint(x: 200, y: 200), viewport: viewport)
|
||
#expect(centre.dx == 0)
|
||
#expect(centre.dy == 0)
|
||
}
|
||
|
||
// MARK: Engagement reach
|
||
|
||
@Test("Engagement reaches over the header but barely sideways")
|
||
func engagementReach() {
|
||
let viewport = CGSize(width: 240, height: 400)
|
||
let reach = DragAutoScrollMath.engagementRect(viewport: viewport)
|
||
|
||
#expect(reach.contains(CGPoint(x: 120, y: 200)), "inside the visible area, always")
|
||
|
||
// Above it (the lane header) and below it (the strip's padding).
|
||
#expect(reach.contains(CGPoint(x: 120, y: -DragAutoScrollMath.reachAbove + 1)))
|
||
#expect(reach.contains(CGPoint(x: 120, y: viewport.height + DragAutoScrollMath.reachBelow - 1)))
|
||
#expect(!reach.contains(CGPoint(x: 120, y: -DragAutoScrollMath.reachAbove - 1)))
|
||
#expect(!reach.contains(CGPoint(x: 120, y: viewport.height + DragAutoScrollMath.reachBelow + 1)))
|
||
|
||
// Sideways: only a sliver, so the neighbouring lane never engages.
|
||
#expect(reach.contains(CGPoint(x: -DragAutoScrollMath.reachSide + 1, y: 200)))
|
||
#expect(!reach.contains(CGPoint(x: -DragAutoScrollMath.reachSide - 1, y: 200)))
|
||
#expect(!reach.contains(CGPoint(x: viewport.width + DragAutoScrollMath.reachSide + 1, y: 200)))
|
||
|
||
// The sideways reach must stay under half the distance between two lanes' scroll areas, or
|
||
// two lanes would scroll at once.
|
||
#expect(DragAutoScrollMath.reachSide < 28 / 2)
|
||
}
|
||
|
||
// MARK: Stepping the offset
|
||
|
||
@Test("One tick advances the offset by velocity × elapsed")
|
||
func nextOffsetAdvances() {
|
||
#expect(DragAutoScrollMath.nextOffset(current: 100, velocity: 600, elapsed: 0.5,
|
||
minOffset: 0, maxOffset: 1000) == 400)
|
||
#expect(DragAutoScrollMath.nextOffset(current: 100, velocity: -600, elapsed: 0.1,
|
||
minOffset: 0, maxOffset: 1000) == 40)
|
||
}
|
||
|
||
@Test("A tick clamps into the scrollable range")
|
||
func nextOffsetClamps() {
|
||
#expect(DragAutoScrollMath.nextOffset(current: 10, velocity: -800, elapsed: 1,
|
||
minOffset: 0, maxOffset: 1000) == 0)
|
||
#expect(DragAutoScrollMath.nextOffset(current: 990, velocity: 800, elapsed: 1,
|
||
minOffset: 0, maxOffset: 1000) == 1000)
|
||
// Content shorter than the viewport: nothing to scroll, pin to the top.
|
||
#expect(DragAutoScrollMath.nextOffset(current: 0, velocity: 800, elapsed: 1,
|
||
minOffset: 0, maxOffset: -120) == 0)
|
||
}
|
||
}
|