Files
lanework/KanbanTests/DragAutoScrollMathTests.swift
T
rzen 21a5a6dbfd Build the drop-slot model and the drop commits — drag & drop, first half
The pathfinder's drag-reorder model, ported and generalized (DRAG-REORDER.md
travels with it, rewritten for lanes, the interior masonry, multi-drag,
cross-board sessions, the re-grounding trio, and the committed-overlay hold):

- DropSlotMath — resting-layout zones from analytic lane arithmetic and the
  pure masonry placement (MasonryLayout now lays out through the same
  MasonryPlacement the drag reads, so geometry cannot drift), span-capped
  triggers sized to the dragged run's future footprint, hysteresis holds with
  the fresh-entry fallback, boundary ties, own-slot no-ops; nil means hold.
- DragAutoScrollMath — the activation bands and velocity ramp, pure.
- The drop commits, one performWrite bracket each: moveCards/copyCards within
  a board (insertion ranks touch only the dragged cards; renumber fallback);
  receiveCards/receiveLanes/receiveRestoredCards on the destination store for
  cross-board copy and ⌘-move with the import-boundary remint, lane copies
  stripping tombstoned cards while moves carry them; restoreByDrag is now
  positional, writing order only when the drop names a new one.

Gestures, sessions, previews, and delegates are the second half.

773 unit tests (87 new since the keyboard grammar).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 20:10:24 -04:00

167 lines
7.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 60 times a
/// second (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)
}
}