Files
lanework/KanbanTests/DropSlotMathTests.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

482 lines
24 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
/// `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 0…100): card 0 [0, 40] · card 2 [48, 78] · card 4 [86, 136]
/// column 1 (x 108…208): 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)
}
}