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
This commit is contained in:
2026-07-27 20:10:24 -04:00
parent 4035ba7986
commit 21a5a6dbfd
14 changed files with 2641 additions and 55 deletions
+166
View File
@@ -0,0 +1,166 @@
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)
}
}