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
167 lines
7.2 KiB
Swift
167 lines
7.2 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 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)
|
||
}
|
||
}
|