Files
lanework/KanbanTests/DragAutoScrollMathTests.swift
T
rzen cfee4a4b41 The board learns to zoom — eight rungs on one ruler, and Actual Size is the untouched board
View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight
rungs, app-wide and persisted (the Show Comments precedent) — a viewing
comfort, not a property of any one board. The level travels as
BoardZoomContext in the environment, injected on BoardView alone so the
banner strip, search bar, sheets and popovers stay at the system size; the
environment is also what carries it through CardFaceView's equality gate,
which compares nothing that moves with the level. Every BoardMetrics figure
follows zoom.bodyPointSize — card and lane chrome, drag replicas and the
count badge, the resize handle, the trash column — and the drop registry
carries the ruler for event-time reads, with the autoscroller's three
reaches turning font-derived (reachSide named as the stripGap it always
equalled). Lanes still divide the window; zoom never moves the window or
its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the
menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung
announces itself to VoiceOver, and the render suite pins both invariants:
a rung repaints every face, a no-op Actual Size repaints nothing.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 11:22:02 -04:00

205 lines
9.2 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 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
/// The standard system body size — the ruler every figure below was tuned against.
private static let standardBody: CGFloat = 13
@Test("Engagement reaches over the header but barely sideways")
func engagementReach() {
let viewport = CGSize(width: 240, height: 400)
let size = Self.standardBody
let above = DragAutoScrollMath.reachAbove(bodyPointSize: size)
let below = DragAutoScrollMath.reachBelow(bodyPointSize: size)
let side = DragAutoScrollMath.reachSide(bodyPointSize: size)
let reach = DragAutoScrollMath.engagementRect(viewport: viewport, bodyPointSize: size)
#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: -above + 1)))
#expect(reach.contains(CGPoint(x: 120, y: viewport.height + below - 1)))
#expect(!reach.contains(CGPoint(x: 120, y: -above - 1)))
#expect(!reach.contains(CGPoint(x: 120, y: viewport.height + below + 1)))
// Sideways: only a sliver, so the neighbouring lane never engages.
#expect(reach.contains(CGPoint(x: -side + 1, y: 200)))
#expect(!reach.contains(CGPoint(x: -side - 1, y: 200)))
#expect(!reach.contains(CGPoint(x: viewport.width + side + 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(side < 28 / 2)
}
@Test("The standard body size draws the reaches it always drew")
func reachesAtStandardBodySize() {
let size = Self.standardBody
#expect(DragAutoScrollMath.reachAbove(bodyPointSize: size) == 48)
#expect(DragAutoScrollMath.reachBelow(bodyPointSize: size) == 24)
#expect(DragAutoScrollMath.reachSide(bodyPointSize: size) == 12)
}
/// The reaches are distances to the lane's own furniture — a header, a padding, a gap — and all
/// three of those grow with the board's zoom (03-board-ui.md ▸ Layout — zoom). A reach fixed in
/// points would stop covering the header it is specified against.
@Test("Every reach grows with the board's ruler")
func reachesFollowTheRuler() {
let standard = Self.standardBody
let zoomed = BoardZoom.bodyPointSize(system: standard, level: 2.0)
#expect(DragAutoScrollMath.reachAbove(bodyPointSize: zoomed)
> DragAutoScrollMath.reachAbove(bodyPointSize: standard))
#expect(DragAutoScrollMath.reachBelow(bodyPointSize: zoomed)
> DragAutoScrollMath.reachBelow(bodyPointSize: standard))
#expect(DragAutoScrollMath.reachSide(bodyPointSize: zoomed)
> DragAutoScrollMath.reachSide(bodyPointSize: standard))
// And the sideways rule holds on the zoomed ruler too: half the distance between two lanes'
// scroll areas is the gap plus a plate padding on each side.
let gap = BoardMetrics.stripGap(bodyPointSize: zoomed)
let padding = BoardMetrics.lanePlatePadding(bodyPointSize: zoomed)
#expect(DragAutoScrollMath.reachSide(bodyPointSize: zoomed) <= (gap + 2 * padding) / 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)
}
}