Files
lanework/KanbanTests/DropSlotMathTests.swift
T
rzen 90cf82d740 Implement drag & drop with the locality model
The second half: system drag sessions over phase 1's model, per
DRAG-REORDER.md and 04-interactions.md § Drag & drop.

- Card faces, lane headers, and trash rows drag as NSItemProvider sessions
  (two exported UTTypes, JSON payload in flatten order, plain-text titles as
  the secondary representation) — replacing m4's custom lane-reorder gesture
  and trash drag-out wholesale; the app-wide DragSession carries the members,
  the frozen dragged sizes, the live proposal, and the effective operation.
- Three drop delegates (lane masonry, strip, window fallback), each accepting
  both types and routing internally per the single-target-dispatch rule; the
  cursor is the physical mouse converted to strip space; proposals come from
  DropSlotMath with hysteresis threaded through, and the lane-strip proposal
  clamps in front of the shown trash.
- Locality picks the default — move within a board, copy across, the badge
  tracking live; ⌥ forces copy (ignored on within-board lane drags), ⌘
  forces move; trash rows restore within their board (positional), copy out
  across boards by default, ⌘ forcing the true restore-move.
- N contiguous shadows with reflow keyed on the proposal; the
  committed-overlay hold renders the dropped arrangement until the reload
  echo lands (1.5 s dissolution deadline for refused writes); the
  re-grounding trio: geometry re-derives per render, proposals re-validate
  by liveness at release, an emptied drag cancels itself.
- Edge autoscroll (ticking driver over DragAutoScrollMath, re-targeting per
  step), the mouse-up-gated late-event cleanup, and the polling watchdog —
  the pathfinder's lifecycle traps, ported.
- Store: moveLanes and multi-card restoreByDrag join the one-bracket drop
  commits.

784 unit tests.

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

499 lines
25 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`: 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)
// Each extent is exactly what `BoardView` frames that lane at, and they tile with one gap
// between them — the strip always exactly fills (03-board-ui.md § Layout).
for (index, units) in [1, 3, 1].enumerated() {
#expect(extents[index].upperBound - extents[index].lowerBound
== LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap))
}
#expect(extents[1].lowerBound - extents[0].upperBound == gap)
#expect(extents[2].lowerBound - extents[1].upperBound == gap)
}
@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)
}
/// **The trash is never a landing spot** (04-interactions.md ▸ The trash: "no move or paste ever
/// targets the trash"). The quasi-lane consumes one unit while shown, and it is excluded from the
/// slot list by construction — so the terminal slot's uncapped reach past the last *real* lane
/// lands in front of the trash column, never in it or past it.
@Test("The end slot stops before the shown trash column, however far the cursor goes")
func theEndSlotClampsInFrontOfTheTrash() {
// Two 1× lanes plus a shown trash: the strip lays out three units, so the trash occupies
// [230, 330]. `restingUnits` names the lanes only.
let restingUnits = [1, 1]
for x: CGFloat in [240, 300, 330, 900, 5000] {
let slot = DropSlotMath.laneSlot(cursorX: x, restingUnits: restingUnits, draggedUnits: [1],
standard: standard, gap: gap, current: 0)
#expect(slot == restingUnits.count,
"a cursor over the trash column at x = \(x) appends after the last real lane")
}
}
}
// 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)
}
}