Files
lanework/KanbanTests/DropSlotMathTests.swift
T
rzen 524488122f Land Finder file drops positionally, header release topmost
04's settled clauses were mostly shipped already — the create landing
resolved through DropSlotMath.cardSlot with one nominal shadow per
importable file — but a release on the lane header fell through to the
card zones, which clamp inward, so a scrolled lane could propose behind
the header stripe. FileDropZones now folds header, attach hit-test, and
card-slot resolution into one pure seam asked in that order, the header
answering topmost per the ruling; lane headers register their frames
for it. FinderDrop.shadowCount names the floor-at-one rule. New tests
pin the header boundary, a differential against cardSlot's own zones
(same zones, not similar), and a store-level differential proving a
file landing takes the very ranks a card move there takes.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 08:28:28 -04:00

621 lines
32 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: - A Finder file drag's zones
/// `FileDropZones` — **"created cards land at the drop position"** (04-interactions.md ▸ Drag and
/// drop, settled 2026-07-28), pinned on the same fixture the card zones are pinned on, because that
/// is the claim: a file drop resolves through *the same card-grid zones an ordinary card drag uses*.
///
/// The lane, exactly as `CardSlotTests` reads it — 2 columns, 100pt wide, 8pt spacing, origin (0, 0):
/// 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. The incoming cards have no measured height, so the zones are capped at
/// the nominal one — `LaneDropRegistry.nominalCardHeight`, the same stand-in a cross-board arrival
/// gets.
/// `@MainActor` for one reason: `LaneDropRegistry.nominalCardHeight` is the app's own answer to "how
/// tall is a card nobody has measured", and reading it here — rather than repeating the number — is
/// what keeps these zones pinned to the height the shadows actually draw at.
@MainActor
@Suite("FileDropZones ▸ a Finder file drag's landing")
struct FileDropZoneTests {
private let placement = MasonryPlacement(columnCount: 2, columnWidth: 100, spacing: 8)
private let heights: [CGFloat] = [40, 60, 30, 20, 50]
private let nominal = LaneDropRegistry.nominalCardHeight
private func landing(
_ x: CGFloat, _ y: CGFloat, headerBottom: CGFloat? = nil, current: Int? = nil,
heights: [CGFloat]? = nil
) -> FileDropZones.Landing {
FileDropZones.landing(
cursor: CGPoint(x: x, y: y), headerBottom: headerBottom, placement: placement,
heights: heights ?? self.heights, nominalHeight: nominal, current: current)
}
/// Every probe below that is **not** over a card, so the create branch is the one answering.
private let emptyProbes: [(CGFloat, CGFloat, Int)] = [
(20, 150, 5), // below column 0's last card — the end slot
(150, 150, 5), // below column 1's last card — the end slot too
(20, 82, 4), // the gap between card 2 and card 4, in column 0
(150, 64, 3), // the gap between card 1 and card 3, in column 1
(-60, 20, 0), // the lane's leading padding: still column 0
(400, 20, 1), // and its trailing padding: column 1
(104, 30, 1), // the gutter between the columns, which the band rule gives to column 1
]
@Test("The create slot is the card-drag zone, at the incoming run's nominal footprint")
func createIsTheCardZone() {
for (x, y, expected) in emptyProbes {
#expect(landing(x, y) == .create(index: expected), "(\(x), \(y))")
// The claim itself: not "an index like the card zones'" but *the card zones'* answer.
let card = DropSlotMath.cardSlot(
cursor: CGPoint(x: x, y: y), placement: placement, heights: heights,
draggedHeight: nominal, current: nil)
#expect(landing(x, y) == .create(index: card ?? -1),
"(\(x), \(y)) must be exactly what an ordinary card drag proposes")
}
}
@Test("A card under the cursor attaches — anywhere on its bounds, closed at the edges")
func attachBeatsCreate() {
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(landing(x, y, current: current) == .attach(index: expected),
"(\(x), \(y)) with current \(String(describing: current))")
}
}
// Closed containment: a cursor exactly on a shared edge still counts, and the first match
// wins, so the answer is deterministic however the frames abut.
#expect(landing(100, 40) == .attach(index: 0))
#expect(landing(108, 0) == .attach(index: 1))
}
/// **"A release on the lane header resolves to the topmost position"** (04-interactions.md,
/// settled 2026-07-28) — forgiving beats a dead stripe.
@Test("The lane header is the topmost position, whatever column the cursor is over")
func headerIsTheTopmostPosition() {
for x: CGFloat in [-60, 20, 104, 150, 400] {
for y: CGFloat in [-500, -40, -20] {
#expect(landing(x, y, headerBottom: -20) == .create(index: 0), "(\(x), \(y))")
}
}
// The edge is the header's own, and one point below it the masonry answers again — which is
// column 1's first row, the very reading the rule exists to override.
#expect(landing(150, -20, headerBottom: -20) == .create(index: 0))
#expect(landing(150, -19, headerBottom: -20) == .create(index: 1))
}
@Test("Without the header rule the stripe reads as a column, which is why the rule exists")
func noHeaderFrameLetsTheMasonryAnswer() {
// A lane whose header has not laid out yet: the masonry clamps inward to the nearest column,
// so the same cursor proposes column 1's first row rather than the top of the lane.
#expect(landing(150, -40, headerBottom: nil) == .create(index: 1))
#expect(landing(20, -40, headerBottom: nil) == .create(index: 0))
}
@Test("The header is asked first, so a scrolled masonry cannot hide the stripe behind a card")
func headerWinsOverACardBehindIt() {
// The masonry is scroll-view content: scrolled down, a card's resting frame can compute to a
// y the header stripe occupies. The ruling admits no exception, so the header answers.
#expect(landing(150, 20) == .attach(index: 1), "with no header the card takes it")
#expect(landing(150, 20, headerBottom: 50) == .create(index: 0))
#expect(landing(20, 20, headerBottom: 50) == .create(index: 0))
}
@Test("A dead region holds, and with nothing to hold the containing zone answers")
func deadRegionHolds() {
// A 200pt card in column 1 and the cursor in the gutter beside its far side: the nominal
// footprint (44) does not reach there, so the zone is dead.
let tall: [CGFloat] = [40, 200]
#expect(landing(104, 150, current: 1, heights: tall) == .hold)
#expect(landing(104, 150, current: nil, heights: tall) == .create(index: 1),
"a fresh entry must still have a landing spot")
}
@Test("An empty lane takes the drop at its only position, header or not")
func emptyLane() {
#expect(landing(10, 10, heights: []) == .create(index: 0))
#expect(landing(400, 900, heights: []) == .create(index: 0))
#expect(landing(150, -40, headerBottom: -20, heights: []) == .create(index: 0))
}
}
// 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)
}
}