Replaces the pathfinder-inherited round-robin deal (child i -> column i % C) with contiguous column segments: base = n/C, the first n%C columns take one more, and logical order runs down each column before crossing to the next. Only the geometric mapping changes -- ranks, selection flatten, and VoiceOver order are untouched, and MasonryPlacement stays the single placement function both the Layout and the drop model replay. Why: an insertion under round-robin shifted every later card across columns; under the column-major deal later cards slide within their column and at most one card crosses each boundary, so the drag reflow is far calmer. Drop-slot math gets simpler too -- a column's cards are one contiguous range, a non-final column's tail is now a genuine mid-list position, and only the last column's tail means append. DropSlotMathTests recomputed and extended (46 -> 50): the uneven-fill deal, boundary positions, the shared tail/head boundary index, and a placement/ drop-model shadow-agreement check. DRAG-REORDER.md and DESIGN/10 amendments are listed for ratification, deliberately not edited here. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
795 lines
42 KiB
Swift
795 lines
42 KiB
Swift
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 dealt column-major and each column stacks independently")
|
||
func columnMajorStacking() {
|
||
// Five cards over two columns: `base = 2`, `extra = 1`, so column 0 takes three and column
|
||
// 1 takes two, and the logical indices run contiguously down each.
|
||
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: 0, y: 48, width: 100, height: 60), // column 0, row 1 — under card 0
|
||
CGRect(x: 0, y: 116, width: 100, height: 30), // column 0, row 2 — it holds the extra
|
||
CGRect(x: 108, y: 0, width: 100, height: 20), // column 1, row 0
|
||
CGRect(x: 108, y: 28, width: 100, height: 50), // column 1, row 1 — under card 3 only
|
||
])
|
||
}
|
||
|
||
@Test("The columns divide as evenly as they can, and the starts are the prefix sums")
|
||
func theDeal() {
|
||
// `base = n / C`, `extra = n % C`: the first `extra` columns take one more each.
|
||
func sizes(_ count: Int, columns: Int) -> [Int] {
|
||
let placement = MasonryPlacement(columnCount: columns, columnWidth: 100, spacing: 8)
|
||
return (0..<columns).map { placement.childCount(inColumn: $0, itemCount: count) }
|
||
}
|
||
func starts(_ count: Int, columns: Int) -> [Int] {
|
||
let placement = MasonryPlacement(columnCount: columns, columnWidth: 100, spacing: 8)
|
||
return (0...columns).map { placement.columnStart($0, itemCount: count) }
|
||
}
|
||
|
||
#expect(sizes(6, columns: 3) == [2, 2, 2]) // an even fill
|
||
#expect(starts(6, columns: 3) == [0, 2, 4, 6])
|
||
#expect(sizes(7, columns: 3) == [3, 2, 2]) // one column takes the remainder
|
||
#expect(starts(7, columns: 3) == [0, 3, 5, 7])
|
||
#expect(sizes(8, columns: 3) == [3, 3, 2]) // two do
|
||
#expect(starts(8, columns: 3) == [0, 3, 6, 8])
|
||
#expect(sizes(2, columns: 3) == [1, 1, 0]) // fewer cards than columns
|
||
#expect(starts(2, columns: 3) == [0, 1, 2, 2])
|
||
#expect(sizes(0, columns: 3) == [0, 0, 0])
|
||
#expect(starts(0, columns: 3) == [0, 0, 0, 0])
|
||
|
||
// The last start is always the count — a column's exclusive end is a real position, and the
|
||
// last column's is the end of the list.
|
||
for count in 0...12 {
|
||
for columns in 1...4 {
|
||
#expect(starts(count, columns: columns).last == count, "\(count) over \(columns)")
|
||
#expect(sizes(count, columns: columns).reduce(0, +) == count)
|
||
#expect(sizes(count, columns: columns).max()!
|
||
- sizes(count, columns: columns).min()! <= 1,
|
||
"the columns are never more than one card apart")
|
||
}
|
||
}
|
||
}
|
||
|
||
@Test("The placement matches an independent reading of the documented rule")
|
||
func differentialAgainstTheStatedRule() {
|
||
// A second implementation of the rule as stated — the children dealt out in contiguous runs,
|
||
// one per column, the first `n % C` columns taking one extra each, each column stacking
|
||
// 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] {
|
||
let base = heights.count / columns
|
||
let extra = heights.count % columns
|
||
var frames: [CGRect] = []
|
||
var next = 0
|
||
for column in 0..<columns {
|
||
var stacked: [CGFloat] = []
|
||
for _ in 0..<(base + (column < extra ? 1 : 0)) {
|
||
frames.append(CGRect(x: origin.x + CGFloat(column) * (width + spacing),
|
||
y: origin.y + stacked.reduce(0) { $0 + $1 + spacing },
|
||
width: width, height: heights[next]))
|
||
stacked.append(heights[next])
|
||
next += 1
|
||
}
|
||
}
|
||
return frames
|
||
}
|
||
|
||
let heights: [CGFloat] = [40, 60, 30, 20, 50, 55, 12]
|
||
for count in 0...heights.count {
|
||
for columns in 1...4 {
|
||
let origin = CGPoint(x: 17, y: 23)
|
||
let placement = MasonryPlacement(columnCount: columns, columnWidth: 100,
|
||
spacing: 8, origin: origin)
|
||
let slice = Array(heights.prefix(count))
|
||
#expect(placement.frames(heights: slice)
|
||
== naive(slice, columns: columns, width: 100, spacing: 8, origin: origin),
|
||
"\(count) cards over \(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 + 60 + 8 + 30 = 146; column 1 stacks 20 + 8 + 50 = 78.
|
||
#expect(placement.height(heights: heights) == 146)
|
||
#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, at every fill")
|
||
func columnRowInversion() {
|
||
// The mapping is a function of the child count, not of the index alone — so the round trip
|
||
// has to hold at every count, not just the one the fixture happens to render.
|
||
for columns in 1...4 {
|
||
let placement = MasonryPlacement(columnCount: columns, columnWidth: 100, spacing: 8)
|
||
for count in 1...12 {
|
||
for index in 0..<count {
|
||
let column = placement.column(of: index, itemCount: count)
|
||
let row = placement.row(of: index, itemCount: count)
|
||
#expect(placement.index(column: column, row: row, itemCount: count) == index,
|
||
"index \(index) of \(count) over \(columns)")
|
||
#expect((0..<columns).contains(column))
|
||
#expect(row < placement.childCount(inColumn: column, itemCount: count))
|
||
}
|
||
}
|
||
}
|
||
|
||
// The fixture's own reading: five cards over two columns puts card 3 at the *top* of column
|
||
// 1, where round-robin used to put it in row 1.
|
||
#expect(placement.column(of: 3, itemCount: 5) == 1)
|
||
#expect(placement.row(of: 3, itemCount: 5) == 0)
|
||
#expect(placement.column(of: 2, itemCount: 5) == 0)
|
||
#expect(placement.row(of: 2, itemCount: 5) == 2)
|
||
|
||
// A column's tail row names the next column's head — the whole of why a tail is a landing
|
||
// spot rather than an append.
|
||
#expect(placement.index(column: 0, row: 3, itemCount: 5) == 3)
|
||
#expect(placement.index(column: 1, row: 2, itemCount: 5) == 5)
|
||
}
|
||
|
||
@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-major with `base = 2` and
|
||
/// `extra = 1`, so column 0 holds three cards and column 1 holds two:
|
||
/// column 0 (x 0…100): card 0 [0, 40] · card 1 [48, 108] · card 2 [116, 146]
|
||
/// column 1 (x 108…208): card 3 [0, 20] · card 4 [28, 78]
|
||
/// Column bands meet at 104. Column 0's zone boundaries are 44, 112, 150; column 1's are 24, 82.
|
||
@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), (20, 60, 1), (20, 130, 2), (150, 10, 3), (150, 40, 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 start(c) + r — the column-major inverse")
|
||
func columnAndRowComposeTheIndex() {
|
||
// Column 1's second row is logical position 4, not "the fifth 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, 40, current: nil) == 4)
|
||
#expect(placement.column(of: 4, itemCount: heights.count) == 1)
|
||
#expect(placement.row(of: 4, itemCount: heights.count) == 1)
|
||
#expect(placement.columnStart(1, itemCount: heights.count) == 3)
|
||
}
|
||
|
||
@Test("A column's tail is that column's end — a real position, not the end of the lane")
|
||
func aColumnsTailIsItsOwnEnd() {
|
||
// The column-major model's substantive gain over round-robin: below column 0 is a landing
|
||
// spot *between* the columns, not an append. It is column 0's exclusive end, which is the
|
||
// same logical position as the head of column 1.
|
||
#expect(slot(20, 200, current: nil) == 3, "below column 0 — column 0's end, mid-list")
|
||
#expect(slot(20, 200, current: nil) == placement.columnStart(1, itemCount: heights.count))
|
||
#expect(slot(20, 200, current: 1) == 3)
|
||
|
||
// Only the last column's tail is the end of the lane.
|
||
#expect(slot(150, 200, current: nil) == 5, "below column 1 — the end slot")
|
||
#expect(slot(150, 200, current: nil) == heights.count)
|
||
}
|
||
|
||
@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) == 3, "column 1's first row is logical position 3")
|
||
#expect(slot(-60, 20, current: nil) == 0, "the lane's leading padding is still column 0")
|
||
#expect(slot(400, 20, current: nil) == 3, "and its trailing padding column 1")
|
||
}
|
||
|
||
@Test("A dead region below a tall card holds the proposal")
|
||
func deadRegionHolds() {
|
||
// Dragging a 10pt card: card 1 is 60pt tall, so slot 1's trigger runs from 44 for 10 + 8 →
|
||
// 62, and (62, 112) is the far side of card 1's zone and changes nothing.
|
||
#expect(slot(20, 55, current: 0, dragged: 10) == 1, "inside the footprint, the slot triggers")
|
||
#expect(slot(20, 90, current: 0, dragged: 10) == nil)
|
||
#expect(slot(20, 90, current: 2, dragged: 10) == nil)
|
||
// With nothing to hold, the containing zone answers — a drag in flight has a landing spot.
|
||
#expect(slot(20, 90, current: nil, dragged: 10) == 1)
|
||
|
||
var current = 0
|
||
for _ in 0..<10 { current = slot(20, 90, 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: 1, dragged: 60) == 1, "slot 1 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 = 1
|
||
for _ in 0..<10 { index = slot(20, 60, current: index) ?? index }
|
||
#expect(index == 1)
|
||
}
|
||
|
||
@Test("A proposal in another column never holds this one")
|
||
func aProposalInAnotherColumnDoesNotHold() {
|
||
// Slot 4 lives in column 1 alone; 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, 90, current: 4, dragged: 10) == 1)
|
||
}
|
||
|
||
@Test("The index where two columns meet holds from either side — it is one position")
|
||
func theSharedBoundaryIndexHoldsFromEitherColumn() {
|
||
// Index 3 is column 0's tail *and* column 1's head. Both readings name the same logical
|
||
// position, so a dead region in either column legitimately holds it — and the shadow stays
|
||
// exactly where it is drawn rather than jumping between two names for one spot.
|
||
#expect(slot(20, 90, current: 3, dragged: 10) == nil, "held from column 0's dead region")
|
||
#expect(slot(150, 60, current: 3, dragged: 10) == nil, "and from column 1's")
|
||
}
|
||
|
||
/// A 3-wide lane of seven uniform 40pt cards — `base = 2`, `extra = 1`, so the deal is 3 / 2 / 2
|
||
/// and the columns hold indices [0, 3), [3, 5), [5, 7):
|
||
/// column 0 (x 0…100): card 0 [0, 40] · card 1 [48, 88] · card 2 [96, 136]
|
||
/// column 1 (x 108…208): card 3 [0, 40] · card 4 [48, 88]
|
||
/// column 2 (x 216…316): card 5 [0, 40] · card 6 [48, 88]
|
||
/// Column bands meet at 104 and 212.
|
||
@Test("An uneven fill puts every boundary position where the prefix sums say")
|
||
func unevenFillBoundaries() {
|
||
let wide = MasonryPlacement(columnCount: 3, columnWidth: 100, spacing: 8)
|
||
let heights = [CGFloat](repeating: 40, count: 7)
|
||
func slot(_ x: CGFloat, _ y: CGFloat) -> Int? {
|
||
DropSlotMath.cardSlot(cursor: CGPoint(x: x, y: y), placement: wide,
|
||
heights: heights, draggedHeight: 40, current: nil)
|
||
}
|
||
|
||
#expect((0...3).map { wide.columnStart($0, itemCount: 7) } == [0, 3, 5, 7])
|
||
|
||
// Column 0 — the one that took the extra card.
|
||
#expect(slot(50, 20) == 0)
|
||
#expect(slot(50, 60) == 1)
|
||
#expect(slot(50, 110) == 2)
|
||
#expect(slot(50, 300) == 3, "below column 0 is column 1's head, not the end")
|
||
// Column 1.
|
||
#expect(slot(150, 20) == 3)
|
||
#expect(slot(150, 60) == 4)
|
||
#expect(slot(150, 300) == 5, "below column 1 is column 2's head")
|
||
// Column 2 — the only column whose tail is the end of the lane.
|
||
#expect(slot(250, 20) == 5)
|
||
#expect(slot(250, 60) == 6)
|
||
#expect(slot(250, 300) == 7)
|
||
#expect(slot(250, 300) == heights.count)
|
||
}
|
||
|
||
/// The layout and the drop model agree — **"the drop always lands where the shadows show"**
|
||
/// (DRAG-REORDER.md § Single-target dispatch). `cardSlot` proposes a logical index; the lane
|
||
/// then renders its cards with one shadow spliced in there and hands the whole arrangement to
|
||
/// `MasonryPlacement.frames` — the very function `MasonryLayout` places subviews with. So the
|
||
/// claim to pin is that reading the shadow's frame back out of *that* arrangement finds it at
|
||
/// the (column, row) the proposal's index names once the grid has re-dealt.
|
||
@Test("The shadow is drawn at the position the proposal named")
|
||
func theShadowLandsWhereProposed() {
|
||
let dragged: CGFloat = 30
|
||
let probes: [(CGFloat, CGFloat)] = [
|
||
(20, 20), (20, 60), (20, 130), (150, 10), (150, 40), (150, 200), (20, 200),
|
||
]
|
||
for (x, y) in probes {
|
||
guard let index = slot(x, y, current: nil, dragged: dragged) else {
|
||
Issue.record("(\(x), \(y)) proposed nothing")
|
||
continue
|
||
}
|
||
// What the lane renders: the resting cards with one shadow at the proposal.
|
||
var arrangement = heights
|
||
arrangement.insert(dragged, at: index)
|
||
let frames = placement.frames(heights: arrangement)
|
||
let count = arrangement.count
|
||
|
||
let column = placement.column(of: index, itemCount: count)
|
||
let start = placement.columnStart(column, itemCount: count)
|
||
#expect(frames[index].minX == placement.columnX(column),
|
||
"(\(x), \(y)) → \(index): the shadow's column")
|
||
#expect(frames[index].minY == arrangement[start..<index].reduce(0) { $0 + $1 + 8 },
|
||
"(\(x), \(y)) → \(index): the shadow stacks under its column's cards above it")
|
||
#expect(frames[index].height == dragged)
|
||
#expect(placement.row(of: index, itemCount: count) == index - start)
|
||
}
|
||
|
||
// The substantive half: for a proposal inside a column, the shadow is drawn **under the
|
||
// cursor** — the trigger region is the run's future footprint, and this is that footprint.
|
||
for (x, y) in probes.prefix(5) {
|
||
let index = slot(x, y, current: nil, dragged: dragged)!
|
||
var arrangement = heights
|
||
arrangement.insert(dragged, at: index)
|
||
let frame = placement.frames(heights: arrangement)[index]
|
||
#expect(frame.minX <= x && x <= frame.maxX, "(\(x), \(y)) is inside the shadow")
|
||
#expect(frame.minY <= y && y <= frame.maxY, "(\(x), \(y)) is inside the shadow")
|
||
}
|
||
|
||
// A tail proposal is the exception, and it is honest rather than wrong: "below column 0"
|
||
// resolves to index 3, which in the re-dealt six-card grid is the *head of column 1* — the
|
||
// spot the card will genuinely occupy after the drop. The shadow shows the landing, not the
|
||
// cursor.
|
||
var arrangement = heights
|
||
arrangement.insert(dragged, at: 3)
|
||
#expect(placement.column(of: 3, itemCount: arrangement.count) == 1)
|
||
#expect(placement.frames(heights: arrangement)[3] == CGRect(x: 108, y: 0, width: 100, height: 30))
|
||
}
|
||
|
||
@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),
|
||
/// dealt column-major three cards to column 0 and two to column 1:
|
||
/// column 0 (x 0…100): card 0 [0, 40] · card 1 [48, 108] · card 2 [116, 146]
|
||
/// column 1 (x 108…208): card 3 [0, 20] · card 4 [28, 78]
|
||
/// 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, 200, 3), // below column 0's last card — column 0's end, which is column 1's head
|
||
(150, 200, 5), // below column 1's last card — the end slot, the lane's own end
|
||
(20, 113, 2), // the gap between card 1 and card 2, in column 0
|
||
(150, 25, 4), // the gap between card 3 and card 4, in column 1
|
||
(-60, 20, 0), // the lane's leading padding: still column 0
|
||
(400, 20, 3), // and its trailing padding: column 1's first row
|
||
(104, 30, 4), // 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), (20, 60, 1), (20, 130, 2), (150, 10, 3), (150, 40, 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: 3), "column 1's head, which is card 3")
|
||
}
|
||
|
||
/// **"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 (logical position 3), 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: 3))
|
||
}
|
||
|
||
@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 — and
|
||
// under column-major that is logical position 3, a third of the way down the lane's order.
|
||
#expect(landing(150, -40, headerBottom: nil) == .create(index: 3))
|
||
#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: 3), "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)
|
||
}
|
||
}
|