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
+481
View File
@@ -0,0 +1,481 @@
import CoreGraphics
import Testing
@testable import Kanban
/// `DropSlotMath` where a drag would land, as arithmetic. The model is DRAG-REORDER.md; these
/// pin it rule for rule, ported from the pathfinder's `DropSlotTests` and extended for the two
/// things Lanework has that it did not: a masonry card grid that is genuinely two-dimensional from
/// day one, and a hysteresis contract that says "hold" with `nil` rather than by echoing the
/// caller's own value back at it.
// MARK: - Zones (one axis)
/// Three cards of height 40 with an 8pt gap, starting at y = 0:
/// card 0: [0, 40] · card 1: [48, 88] · card 2: [96, 136]
/// Zone boundaries: the gap midpoints 44 and 92, then the last edge plus half a gap, 140.
/// slot 0 (, 44) · slot 1 [44, 92) · slot 2 [92, 140) · slot 3 [140, )
private let extents: [ClosedRange<CGFloat>] = [0...40, 48...88, 96...136]
private let gap: CGFloat = 8
private var boundaries: [CGFloat] { DropSlotMath.zoneBoundaries(extents: extents, gap: gap) }
@Suite("DropSlotMath ▸ zones")
struct DropSlotZoneTests {
@Test("Zone boundaries are the gap midpoints, plus half a gap past the last item")
func zoneBoundariesTile() {
#expect(boundaries == [44, 92, 140])
#expect(DropSlotMath.zoneBoundaries(extents: [], gap: gap) == [])
#expect(DropSlotMath.zoneBoundaries(extents: [10...50], gap: gap) == [54])
}
@Test("Anywhere over an item claims its slot, whatever was proposed before")
func anywhereOverAnItemClaimsIt() {
for y: CGFloat in [48, 60, 68, 80, 88] {
for current in [nil, 0, 1, 2, 3] {
#expect(DropSlotMath.containingSlot(cursor: y, boundaries: boundaries, current: current) == 1,
"cursor \(y) is over item 1 (current \(String(describing: current)))")
}
}
// The half-gap flanks belong to the zone too the zones tile with no dead space.
#expect(DropSlotMath.containingSlot(cursor: 45, boundaries: boundaries, current: 0) == 1)
#expect(DropSlotMath.containingSlot(cursor: 91, boundaries: boundaries, current: 2) == 1)
}
@Test("A zone is entered exactly at its border, and left only by entering another")
func enteredAtTheBorder() {
#expect(DropSlotMath.containingSlot(cursor: 44.0001, boundaries: boundaries, current: 0) == 1)
#expect(DropSlotMath.containingSlot(cursor: 43.9999, boundaries: boundaries, current: 1) == 0)
#expect(DropSlotMath.containingSlot(cursor: 140.0001, boundaries: boundaries, current: 2) == 3)
for y in stride(from: 44.5, through: 91.5, by: 0.5) {
#expect(DropSlotMath.containingSlot(cursor: CGFloat(y), boundaries: boundaries, current: 1) == 1,
"cursor \(y) is inside slot 1's zone; the proposal must hold")
}
}
@Test("Past the last item is the end slot")
func pastTheLastItem() {
#expect(DropSlotMath.containingSlot(cursor: 141, boundaries: boundaries, current: nil) == 3)
#expect(DropSlotMath.containingSlot(cursor: 500, boundaries: boundaries, current: 0) == 3)
}
@Test("Picking an item up over its own resting spot proposes its own slot — a no-op")
func ownSlotPickupIsANoOp() {
for y: CGFloat in [48, 55, 68, 80, 88] {
#expect(DropSlotMath.containingSlot(cursor: y, boundaries: boundaries, current: 1) == 1)
}
var index = 1
for _ in 0..<10 {
index = DropSlotMath.containingSlot(cursor: 68, boundaries: boundaries, current: index)
}
#expect(index == 1, "re-evaluating the same cursor is a fixed point")
}
@Test("A cursor on an exact boundary keeps whichever adjoining slot is proposed")
func exactBoundaryTie() {
#expect(DropSlotMath.containingSlot(cursor: 92, boundaries: boundaries, current: 1) == 1)
#expect(DropSlotMath.containingSlot(cursor: 92, boundaries: boundaries, current: 2) == 2)
var index = 1
for _ in 0..<10 {
index = DropSlotMath.containingSlot(cursor: 92, boundaries: boundaries, current: index)
}
#expect(index == 1, "the boundary pixel is a fixed point, so the shadow cannot oscillate")
// A non-adjacent current has no claim on the tie; the border rule wins.
#expect(DropSlotMath.containingSlot(cursor: 92, boundaries: boundaries, current: 0) == 2)
#expect(DropSlotMath.containingSlot(cursor: 92, boundaries: boundaries, current: nil) == 2)
}
@Test("Degenerate inputs are total")
func degenerateInputs() {
#expect(DropSlotMath.containingSlot(cursor: 123, boundaries: [], current: nil) == 0)
#expect(DropSlotMath.containingSlot(cursor: 123, boundaries: [], current: 0) == 0)
// An index from a snapshot one reload old is ignored rather than trusted.
#expect(DropSlotMath.containingSlot(cursor: 116, boundaries: boundaries, current: 99) == 2)
#expect(DropSlotMath.containingSlot(cursor: 116, boundaries: boundaries, current: -1) == 2)
}
}
// MARK: - Span-capped triggers
/// Lanes along x with an 8pt gap: a 1× (100), a 3× (320), a 1× (100).
/// lane 0: [0, 100] · lane 1: [108, 428] · lane 2: [436, 536]
/// Zone boundaries: 104, 432, 540. Dragging a 1× lane (span 100):
/// slot 1's trigger = [104, 104 + 100 + 8 = 212]; (212, 432) is dead.
@Suite("DropSlotMath ▸ span-capped triggers")
struct SpanCappedSlotTests {
private let extents: [ClosedRange<CGFloat>] = [0...100, 108...428, 436...536]
private let gap: CGFloat = 8
private let narrowSpan: CGFloat = 100
private func slot(_ cursor: CGFloat, current: Int?, span: CGFloat? = nil) -> Int? {
DropSlotMath.slot(cursor: cursor, extents: extents, gap: gap,
draggedSpan: span ?? narrowSpan, current: current)
}
@Test("A slot triggers over the footprint the dragged run would actually occupy")
func triggerIsTheFutureFootprint() {
// The near side of the wide lane where the dragged lane would land claims slot 1 from
// any prior proposal. (x = 104 exactly is the boundary pixel, owned by the tie rule.)
for x: CGFloat in [105, 110, 160, 212] {
for current in [nil, 0, 1, 2, 3] {
#expect(slot(x, current: current) == 1,
"cursor \(x) is inside slot 1's trigger (current \(String(describing: current)))")
}
}
}
@Test("The far side of a wider item is dead, and holds the proposal")
func deadRegionHolds() {
for x: CGFloat in [213, 300, 420, 431] {
#expect(slot(x, current: 0) == nil, "dead region at \(x) must hold, not re-propose")
#expect(slot(x, current: 2) == nil)
#expect(slot(x, current: 3) == nil)
}
// Repeated evaluation in the dead region never moves the proposal.
var current = 0
for _ in 0..<10 { current = slot(300, current: current) ?? current }
#expect(current == 0)
}
@Test("A dead region with no valid prior proposal snaps to the containing zone")
func freshEntryFallsBackToTheContainingZone() {
// A drag in flight over a live target must always have some landing spot the fresh
// cross-board entry, and the first sample after a reload invalidated the last proposal.
#expect(slot(300, current: nil) == 1)
#expect(slot(300, current: 99) == 1)
#expect(slot(300, current: -1) == 1)
}
@Test("A dragged run at least as large as the item it crosses behaves uncapped")
func wideRunIsUncapped() {
for (x, expected): (CGFloat, Int) in [(50, 0), (300, 1), (420, 1), (500, 2), (600, 3)] {
#expect(slot(x, current: 0, span: 320) == expected, "cursor \(x)")
}
}
@Test("The terminal slots are never capped")
func terminalSlotsAreUncapped() {
#expect(slot(-50, current: 2) == 0, "before the first item, slot 0 is the only reading")
#expect(slot(600, current: 0) == 3, "past the last item, appending is the only reading")
#expect(slot(10_000, current: nil) == 3)
}
@Test("The exact-boundary tie survives the cap")
func boundaryTieStillHolds() {
#expect(slot(104, current: 0) == 0)
#expect(slot(104, current: 1) == 1)
}
@Test("A multi-drag's span includes the gaps between its members")
func multiDragSpanIncludesInnerGaps() {
// Two 1× lanes dragged together: span = 100 + 8 + 100 = 208, so the wide lane's trigger
// stretches to 104 + 208 + 8 = 320.
#expect(slot(300, current: 0, span: 208) == 1)
#expect(slot(321, current: 0, span: 208) == nil, "beyond the run's footprint is still dead")
}
@Test("An empty container is always index zero")
func emptyContainer() {
#expect(DropSlotMath.slot(cursor: 42, extents: [], gap: gap, draggedSpan: 100, current: nil) == 0)
}
}
// MARK: - The lane strip
/// `standard = 100`, `gap = 10`, matching `LaneReorderMathTests`: a 1× slot is 100 wide, a 2× is
/// 210 and a 3× is 320, and the strip's outer margin is one gap, so the first slot starts at 10.
@Suite("DropSlotMath ▸ the lane strip")
struct LaneSlotTests {
private let standard: CGFloat = 100
private let gap: CGFloat = 10
@Test("The resting extents are LaneLayoutMath's own arithmetic, in range form")
func restingExtents() {
let extents = DropSlotMath.laneExtents(unitCounts: [1, 3, 1], standard: standard, gap: gap)
#expect(extents == [10...110, 120...440, 450...550])
#expect(DropSlotMath.laneExtents(unitCounts: [], standard: standard, gap: gap).isEmpty)
// The centres agree with `LaneReorderMath.centre`, which reads the same layout the two
// must never drift, since the drag's replica offsets from one and its proposal from the
// other.
for index in 0..<3 {
let centre = LaneReorderMath.centre(ofLaneAt: index, unitCounts: [1, 3, 1],
standard: standard, gap: gap)
#expect(centre == (extents[index].lowerBound + extents[index].upperBound) / 2)
}
}
@Test("A dragged run's span is its slots plus the gaps between them")
func runSpan() {
#expect(DropSlotMath.laneRunSpan(unitCounts: [], standard: standard, gap: gap) == 0)
#expect(DropSlotMath.laneRunSpan(unitCounts: [1], standard: standard, gap: gap) == 100)
#expect(DropSlotMath.laneRunSpan(unitCounts: [3], standard: standard, gap: gap) == 320)
#expect(DropSlotMath.laneRunSpan(unitCounts: [1, 1], standard: standard, gap: gap) == 210)
#expect(DropSlotMath.laneRunSpan(unitCounts: [1, 2, 1], standard: standard, gap: gap) == 430)
}
@Test("A 1× lane crossing a 3× lane does not reflow until it reaches where it would land")
func widthAwareTriggers() {
// Remaining lanes [1, 3, 1]; dragging a 1× lane. Slot 1's trigger runs from 115 (the wide
// lane's leading edge less half a gap) for 100 + 10 225. (225, 445) is dead.
func slot(_ x: CGFloat, current: Int?) -> Int? {
DropSlotMath.laneSlot(cursorX: x, restingUnits: [1, 3, 1], draggedUnits: [1],
standard: standard, gap: gap, current: current)
}
#expect(slot(130, current: 0) == 1, "the wide lane's leading edge is where the drop lands")
#expect(slot(225, current: 0) == 1, "the cap's far edge still triggers")
#expect(slot(300, current: 0) == nil, "the wide lane's far side holds the proposal")
#expect(slot(300, current: nil) == 1, "with nothing to hold, the containing zone answers")
#expect(slot(500, current: 0) == 2)
#expect(slot(600, current: 0) == 3, "past the last lane: the end slot, uncapped")
#expect(slot(-100, current: 2) == 0, "before the first lane: slot 0, uncapped")
}
@Test("A wide dragged run reaches further, and a run of two reaches further still")
func runSpanWidensTheTrigger() {
// Dragging a 3× lane (span 320): the cap covers the whole of the wide lane's zone.
#expect(DropSlotMath.laneSlot(cursorX: 300, restingUnits: [1, 3, 1], draggedUnits: [3],
standard: standard, gap: gap, current: 0) == 1)
// Two 1× lanes together (span 210): the trigger reaches 115 + 210 + 10 = 335.
#expect(DropSlotMath.laneSlot(cursorX: 330, restingUnits: [1, 3, 1], draggedUnits: [1, 1],
standard: standard, gap: gap, current: 0) == 1)
#expect(DropSlotMath.laneSlot(cursorX: 340, restingUnits: [1, 3, 1], draggedUnits: [1, 1],
standard: standard, gap: gap, current: 0) == nil)
}
@Test("An empty strip proposes slot zero")
func emptyStrip() {
#expect(DropSlotMath.laneSlot(cursorX: 200, restingUnits: [], draggedUnits: [1],
standard: standard, gap: gap, current: nil) == 0)
}
}
// MARK: - The masonry's resting grid
@Suite("MasonryPlacement")
struct MasonryPlacementTests {
private let placement = MasonryPlacement(columnCount: 2, columnWidth: 100, spacing: 8)
@Test("Children are assigned round-robin and each column stacks independently")
func roundRobinStacking() {
let frames = placement.frames(heights: [40, 60, 30, 20, 50])
#expect(frames == [
CGRect(x: 0, y: 0, width: 100, height: 40), // column 0, row 0
CGRect(x: 108, y: 0, width: 100, height: 60), // column 1, row 0
CGRect(x: 0, y: 48, width: 100, height: 30), // column 0, row 1 under card 0 only
CGRect(x: 108, y: 68, width: 100, height: 20), // column 1, row 1 under card 1 only
CGRect(x: 0, y: 86, width: 100, height: 50),
])
}
@Test("The placement matches an independent reading of the documented rule")
func differentialAgainstTheStatedRule() {
// A second implementation of the rule as 03-board-ui.md states it "child `i` column
// `i % columns`, each column stacks top-aligned and independently" written from the
// words rather than from the code. `MasonryLayout` places subviews through
// `MasonryPlacement`, so agreeing here is agreeing with what is drawn.
func naive(_ heights: [CGFloat], columns: Int, width: CGFloat, spacing: CGFloat,
origin: CGPoint) -> [CGRect] {
var stacks = [[CGFloat]](repeating: [], count: columns)
var frames: [CGRect] = []
for (index, height) in heights.enumerated() {
let column = index % columns
let stacked = stacks[column].reduce(0) { $0 + $1 + spacing }
frames.append(CGRect(x: origin.x + CGFloat(column) * (width + spacing),
y: origin.y + stacked,
width: width, height: height))
stacks[column].append(height)
}
return frames
}
let heights: [CGFloat] = [40, 60, 30, 20, 50, 55, 12]
for columns in 1...4 {
let origin = CGPoint(x: 17, y: 23)
let placement = MasonryPlacement(columnCount: columns, columnWidth: 100,
spacing: 8, origin: origin)
#expect(placement.frames(heights: heights)
== naive(heights, columns: columns, width: 100, spacing: 8, origin: origin),
"\(columns) interior columns")
}
}
@Test("The reported height is the tallest column's stack")
func heightIsTheTallestColumn() {
let heights: [CGFloat] = [40, 60, 30, 20, 50]
let frames = placement.frames(heights: heights)
// Column 0 stacks 40 + 8 + 30 + 8 + 50 = 136; column 1 stacks 60 + 8 + 20 = 88.
#expect(placement.height(heights: heights) == 136)
#expect(placement.height(heights: heights) == frames.map(\.maxY).max())
#expect(placement.height(heights: []) == 0)
#expect(placement.frames(heights: []).isEmpty)
}
@Test("Column and row invert to the logical index")
func columnRowInversion() {
for index in 0..<9 {
#expect(placement.index(column: placement.column(of: index),
row: placement.row(of: index)) == index)
}
#expect(placement.column(of: 3) == 1)
#expect(placement.row(of: 3) == 1)
}
@Test("Column width divides the lane, and a degenerate column count clamps to one")
func columnWidthArithmetic() {
#expect(MasonryPlacement.columnWidth(totalWidth: 316, columnCount: 3, spacing: 8) == 100)
#expect(MasonryPlacement.columnWidth(totalWidth: 100, columnCount: 1, spacing: 8) == 100)
// A lane narrower than its own spacings never proposes a negative width.
#expect(MasonryPlacement.columnWidth(totalWidth: 4, columnCount: 3, spacing: 8) == 0)
#expect(MasonryPlacement(columnCount: 0, columnWidth: 100, spacing: 8).columnCount == 1)
}
}
// MARK: - The masonry's insertion index
/// A 2-wide lane of five cards, 100pt columns and 8pt spacing:
/// column 0 (x 0100): card 0 [0, 40] · card 2 [48, 78] · card 4 [86, 136]
/// column 1 (x 108208): card 1 [0, 60] · card 3 [68, 88]
/// Column bands meet at 104. Column 0's zone boundaries are 44, 82, 140; column 1's are 64, 92.
@Suite("DropSlotMath ▸ the card masonry")
struct CardSlotTests {
private let placement = MasonryPlacement(columnCount: 2, columnWidth: 100, spacing: 8)
private let heights: [CGFloat] = [40, 60, 30, 20, 50]
private func slot(_ x: CGFloat, _ y: CGFloat, current: Int?, dragged: CGFloat = 30) -> Int? {
DropSlotMath.cardSlot(cursor: CGPoint(x: x, y: y), placement: placement,
heights: heights, draggedHeight: dragged, current: current)
}
@Test("A cursor over a card claims that card's logical position")
func cursorOverACardClaimsItsLogicalPosition() {
let probes: [(CGFloat, CGFloat, Int)] = [
(20, 20, 0), (150, 20, 1), (20, 60, 2), (150, 75, 3), (20, 100, 4),
]
for (x, y, expected) in probes {
for current in [nil, 0, 1, 2, 3, 4, 5] {
#expect(slot(x, y, current: current) == expected,
"(\(x), \(y)) should claim slot \(expected) (current \(String(describing: current)))")
}
}
}
@Test("Column, then row, then r · C + c — the round-robin inverse")
func columnAndRowComposeTheIndex() {
// Column 1's second row is logical position 3, not "the fourth thing the cursor passed":
// the index is the lane's card order, which is what the store writes and what VoiceOver
// traverses (10-accessibility.md's logical-order rule).
#expect(slot(150, 75, current: nil) == 3)
#expect(placement.column(of: 3) == 1)
#expect(placement.row(of: 3) == 1)
}
@Test("Below any column's last card is the end slot")
func belowAColumnIsTheEndSlot() {
#expect(slot(20, 200, current: nil) == 5, "below column 0 — clamped past the end")
#expect(slot(150, 200, current: nil) == 5, "below column 1 — exactly the end")
#expect(slot(20, 200, current: 1) == 5)
}
@Test("Above and beside the grid clamp inward to the nearest column")
func clampingAtTheEdges() {
#expect(slot(20, -40, current: nil) == 0, "the lane header targets the first row")
#expect(slot(150, -40, current: nil) == 1)
#expect(slot(-60, 20, current: nil) == 0, "the lane's leading padding is still column 0")
#expect(slot(400, 20, current: nil) == 1, "and its trailing padding column 1")
}
@Test("A dead region below a tall card holds the proposal")
func deadRegionHolds() {
// Dragging a 10pt card: slot 4's trigger runs from 82 for 10 + 8 100, so (100, 140) is
// the far side of card 4's zone and changes nothing.
#expect(slot(20, 95, current: 0, dragged: 10) == 4, "inside the footprint, the slot triggers")
#expect(slot(20, 120, current: 0, dragged: 10) == nil)
#expect(slot(20, 120, current: 2, dragged: 10) == nil)
// With nothing to hold, the containing zone answers a drag in flight has a landing spot.
#expect(slot(20, 120, current: nil, dragged: 10) == 4)
var current = 0
for _ in 0..<10 { current = slot(20, 120, current: current, dragged: 10) ?? current }
#expect(current == 0)
}
@Test("A cursor on a zone boundary keeps whichever adjoining slot is proposed")
func boundaryTie() {
// y = 44 is the boundary between column 0's slots 0 and 1. A 60pt dragged card reaches
// past it from either side, so the cap does not decide and the tie rule does.
#expect(slot(20, 44, current: 0, dragged: 60) == 0)
#expect(slot(20, 44, current: 2, dragged: 60) == 2, "slot 2 is column 0's row 1")
var index = 0
for _ in 0..<10 { index = slot(20, 44, current: index, dragged: 60) ?? index }
#expect(index == 0, "the boundary pixel is a fixed point")
}
@Test("Re-evaluating a resting hover is a fixed point — own-slot pickup never reflows")
func ownSlotPickupIsANoOp() {
var index = 2
for _ in 0..<10 { index = slot(20, 60, current: index) ?? index }
#expect(index == 2)
}
@Test("A proposal in another column never holds this one")
func aProposalInAnotherColumnDoesNotHold() {
// Slot 1 lives in column 1; a cursor deep in column 0's dead region cannot "hold" it,
// because holding a proposal the cursor is nowhere near would strand the shadow.
#expect(slot(20, 120, current: 1, dragged: 10) == 4)
}
@Test("A one-column lane behaves like a plain vertical list")
func oneColumnLane() {
let column = MasonryPlacement(columnCount: 1, columnWidth: 200, spacing: 8)
func slot(_ y: CGFloat, current: Int?) -> Int? {
DropSlotMath.cardSlot(cursor: CGPoint(x: 100, y: y), placement: column,
heights: [40, 40], draggedHeight: 40, current: current)
}
#expect(slot(20, current: nil) == 0)
#expect(slot(60, current: nil) == 1)
#expect(slot(120, current: nil) == 2)
#expect(slot(44, current: 0) == 0)
#expect(slot(44, current: 1) == 1)
}
@Test("An empty lane proposes slot zero, and a lane with fewer cards than columns still appends")
func degenerateGrids() {
#expect(DropSlotMath.cardSlot(cursor: CGPoint(x: 10, y: 10), placement: placement,
heights: [], draggedHeight: 30, current: nil) == 0)
// One card, two columns: column 1 is empty, and its only slot is the end.
#expect(DropSlotMath.cardSlot(cursor: CGPoint(x: 150, y: 10), placement: placement,
heights: [40], draggedHeight: 30, current: nil) == 1)
}
}
// MARK: - Applying a proposal
@Suite("DropSlotMath ▸ applying a proposal")
struct AppliedTests {
@Test("A run lifts out and re-inserts contiguously, in the order it was given")
func contiguousInsertPreservesOrder() {
let items = ["a", "b", "c", "d", "e"]
#expect(DropSlotMath.applied(items, moving: ["b", "d"], to: 0) == ["b", "d", "a", "c", "e"])
#expect(DropSlotMath.applied(items, moving: ["b", "d"], to: 3) == ["a", "c", "e", "b", "d"])
#expect(DropSlotMath.applied(items, moving: ["d", "b"], to: 1) == ["a", "d", "b", "c", "e"],
"the run's own order is preserved, not re-derived")
}
@Test("An index counted with the run removed makes the resting position a no-op")
func ownSlotIsIdentity() {
let items = ["a", "b", "c"]
#expect(DropSlotMath.applied(items, moving: ["b"], to: 1) == items)
}
@Test("Out-of-range indices clamp rather than trap")
func clamping() {
let items = ["a", "b", "c"]
#expect(DropSlotMath.applied(items, moving: ["a"], to: -5) == ["a", "b", "c"])
#expect(DropSlotMath.applied(items, moving: ["a"], to: 99) == ["b", "c", "a"])
#expect(DropSlotMath.applied(items, moving: [], to: 1) == items)
}
}