View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight rungs, app-wide and persisted (the Show Comments precedent) — a viewing comfort, not a property of any one board. The level travels as BoardZoomContext in the environment, injected on BoardView alone so the banner strip, search bar, sheets and popovers stay at the system size; the environment is also what carries it through CardFaceView's equality gate, which compares nothing that moves with the level. Every BoardMetrics figure follows zoom.bodyPointSize — card and lane chrome, drag replicas and the count badge, the resize handle, the trash column — and the drop registry carries the ruler for event-time reads, with the autoscroller's three reaches turning font-derived (reachSide named as the stripGap it always equalled). Lanes still divide the window; zoom never moves the window or its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung announces itself to VoiceOver, and the render suite pins both invariants: a rung repaints every face, a no-op Actual Size repaints nothing. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
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)
|
||
}
|
||
}
|