Files
lanework/KanbanTests/DragPreviewAnchorTests.swift
rzen f83385e79f Lane drag preview attaches at the grabbed title bar, not its middle
SwiftUI's .onDrag(_:preview:) exposes no anchor or grab-point API and
lays the preview centered over the grabbed view, so a full-height lane
replica grabbed by its ~1.5-line title bar hung half a lane above the
pointer. DragPreviewAnchor is the pure fix: transparent one-sided
padding computed so the padded image's center IS the replica's title
bar - the system's centering then lands the bar under the cursor and
the body over the lane it was lifted from. LaneView measures the real
bar height in the geometry observer it already runs, accounting for the
accent band when the lane's color resolves.

Pixel-exact grab preservation would need re-homing the whole gesture
stack onto an AppKit beginDraggingSession path; declined for a polish
card - this gets the cursor onto the grabbed bar under either of
SwiftUI's possible placement rules. Manual verification pending (no
display here): grab specific bar pixels, colored + uncolored lanes,
large text sizes, multi-lane fan, cross-board drop geometry.

9 tests. 1661 green on both schemes with the paired card-width fix.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 13:02:57 -04:00

116 lines
4.8 KiB
Swift

import CoreGraphics
import Testing
@testable import Kanban
/// `DragPreviewAnchor` — given a preview's length and the point in it the pointer grabbed, how much
/// transparent room goes on either side so the system's centred placement lands that point under the
/// cursor? The visual result is a real drag session's and cannot be asserted here; this is the one
/// decision it is made of.
/// The centre of the padded box, and the anchor's position in it — the two figures the padding
/// exists to make equal.
private func centreAndAnchor(
length: CGFloat,
anchor: CGFloat
) -> (centre: CGFloat, anchor: CGFloat) {
let padding = DragPreviewAnchor.padding(length: length, anchor: anchor)
let padded = padding.before + length + padding.after
return (centre: padded / 2, anchor: padding.before + anchor)
}
private func isClose(_ value: CGFloat, _ expected: CGFloat, _ tolerance: CGFloat = 0.0001) -> Bool {
abs(value - expected) <= tolerance
}
@Suite("DragPreviewAnchor")
struct DragPreviewAnchorTests {
// MARK: The promise
@Test("The padded preview's centre is the anchor, wherever the anchor is")
func anchorBecomesTheCentre() {
let length: CGFloat = 640
for anchor in stride(from: CGFloat(0), through: length, by: 16) {
let result = centreAndAnchor(length: length, anchor: anchor)
#expect(
isClose(result.centre, result.anchor),
"anchor \(anchor) landed at \(result.anchor), centre is \(result.centre)"
)
}
}
@Test("A lane grabbed by its title bar hangs from the bar, not from its middle")
func laneHeaderAnchor() {
// A tall lane whose bar sits near the top: the compensation is most of a second lane's
// height, all of it transparent and all of it above.
let padding = DragPreviewAnchor.padding(length: 700, anchor: 20)
#expect(padding.before == 660)
#expect(padding.after == 0)
// The bar's centre and the image's centre are then the same point — 20 down from the
// replica's top edge, which is 680 down from the padded image's.
let result = centreAndAnchor(length: 700, anchor: 20)
#expect(isClose(result.centre, result.anchor))
#expect(isClose(result.centre, 680))
}
// MARK: Which side
@Test("An anchor above the middle pads before it, one below pads after it")
func padsTheOppositeSide() {
let high = DragPreviewAnchor.padding(length: 100, anchor: 30)
#expect(high.before == 40)
#expect(high.after == 0)
let low = DragPreviewAnchor.padding(length: 100, anchor: 70)
#expect(low.before == 0)
#expect(low.after == 40)
}
@Test("An anchor already at the centre pads nothing")
func centredAnchorPadsNothing() {
#expect(DragPreviewAnchor.padding(length: 100, anchor: 50) == .none)
}
@Test("Either extreme pads a whole length — the preview hangs entirely from one edge")
func extremesPadAWholeLength() {
#expect(DragPreviewAnchor.padding(length: 100, anchor: 0) == .init(before: 100, after: 0))
#expect(DragPreviewAnchor.padding(length: 100, anchor: 100) == .init(before: 0, after: 100))
}
// MARK: Symmetric padding does not disturb it
@Test("The figure is invariant under the replica's own transparent margin")
func invariantUnderSymmetricPadding() {
// `BoardMetrics.replicaPadding` wraps every replica so its shadow is not clipped. Adding it
// to both ends adds to the length and to the anchor alike, so the compensation is the same
// number and the two paddings compose in either order.
let margin: CGFloat = 12
let bare = DragPreviewAnchor.padding(length: 700, anchor: 20)
let wrapped = DragPreviewAnchor.padding(length: 700 + 2 * margin, anchor: 20 + margin)
#expect(bare == wrapped)
}
// MARK: Degenerate input
@Test("An unmeasured preview pads nothing")
func unmeasuredPadsNothing() {
#expect(DragPreviewAnchor.padding(length: 0, anchor: 0) == .none)
#expect(DragPreviewAnchor.padding(length: -100, anchor: 20) == .none)
}
@Test("Non-finite input pads nothing")
func nonFinitePadsNothing() {
#expect(DragPreviewAnchor.padding(length: .infinity, anchor: 20) == .none)
#expect(DragPreviewAnchor.padding(length: .nan, anchor: 20) == .none)
#expect(DragPreviewAnchor.padding(length: 700, anchor: .infinity) == .none)
#expect(DragPreviewAnchor.padding(length: 700, anchor: .nan) == .none)
}
@Test("An anchor outside the preview clamps to its edges")
func anchorClampsIntoThePreview() {
#expect(DragPreviewAnchor.padding(length: 100, anchor: -50) == .init(before: 100, after: 0))
#expect(DragPreviewAnchor.padding(length: 100, anchor: 500) == .init(before: 0, after: 100))
}
}