Implement drag & drop with the locality model

The second half: system drag sessions over phase 1's model, per
DRAG-REORDER.md and 04-interactions.md § Drag & drop.

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

784 unit tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 20:58:26 -04:00
parent f2d9f3ad07
commit 90cf82d740
24 changed files with 2307 additions and 739 deletions
+181
View File
@@ -0,0 +1,181 @@
import AppKit
import Foundation
import Testing
@testable import Kanban
/// The drag session's **value** halves the pasteboard payload, the locality model, and the
/// committed overlay's hand-off condition (DRAG-REORDER.md; 04-interactions.md Drag and drop).
///
/// The session object itself, the drop delegates and the gestures are not unit-testable they are
/// deliberately thin over these three, plus `DropSlotMath`'s arithmetic, which is why the split falls
/// where it does.
// MARK: - The payload
@Suite("DragPayload")
struct DragPayloadTests {
private static func payload(kind: DragKind = .cards, side: Liveness = .live) -> DragPayload {
DragPayload(
boardRoot: URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true),
kind: kind,
side: side,
items: [
DragPayload.Item(id: "aaa", folder: "/Boards/Work.kanban/lane/aaa", title: "First"),
DragPayload.Item(id: "bbb", folder: "/Boards/Work.kanban/lane/bbb", title: nil)
]
)
}
@Test("A payload round-trips through its JSON representation unchanged")
func roundTrip() throws {
for kind in [DragKind.cards, .lanes] {
for side in [Liveness.live, .trashed] {
let original = Self.payload(kind: kind, side: side)
let data = try #require(original.encoded())
#expect(DragPayload(data: data) == original)
}
}
}
@Test("Garbage decodes to nothing rather than to an empty drag")
func garbageDecodesToNil() {
#expect(DragPayload(data: Data("not json".utf8)) == nil)
#expect(DragPayload(data: Data()) == nil)
}
@Test("The ids, folders and root are read back off the strings, in flatten order")
func derivedValues() {
let payload = Self.payload()
#expect(payload.ids == [ItemID(rawValue: "aaa"), ItemID(rawValue: "bbb")])
#expect(payload.folders.map(\.path) == [
"/Boards/Work.kanban/lane/aaa",
"/Boards/Work.kanban/lane/bbb"
])
#expect(payload.rootURL.path == "/Boards/Work.kanban")
}
@Test("The plain-text representation is the dragged titles, one per line")
func plainText() {
// The stray-drop-into-a-text-editor fallback. An untitled item renders as the board renders
// it "Untitled" is a rendering, never a value (03-board-ui.md § Card face).
#expect(Self.payload().plainText == "First\nUntitled")
}
@Test("The side survives the round trip, because it is what makes a trash drag a trash drag")
func sideSurvives() throws {
let data = try #require(Self.payload(side: .trashed).encoded())
#expect(DragPayload(data: data)?.side.liveness == .trashed)
}
}
// MARK: - Locality
@Suite("DragLocality")
struct DragLocalityTests {
private static let here = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true)
private static let there = URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true)
private static let none: NSEvent.ModifierFlags = []
private static let option: NSEvent.ModifierFlags = [.option]
private static let command: NSEvent.ModifierFlags = [.command]
@Test("Roots compare by their standardized path, so the same board is the same board")
func rootComparison() {
#expect(DragLocality.isSameBoard(here, here))
#expect(DragLocality.isSameBoard(here, URL(fileURLWithPath: "/Boards/./Work.kanban/")))
#expect(DragLocality.isSameBoard(here, URL(fileURLWithPath: "/Boards/Other/../Work.kanban")))
#expect(!DragLocality.isSameBoard(here, there))
}
/// The Finder volume model: within a board a drag rearranges, between boards it transfers.
@Test("Locality picks the default — within is a move, across is a copy")
func theDefault() {
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: none) == .move)
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: none) == .copy)
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: none) == .copy)
}
@Test("⌥ forces copy and ⌘ forces move, each a no-op where it is already the default")
func modifiersOverride() {
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: option) == .copy)
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: command) == .move)
// The no-ops.
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: command) == .move)
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: option) == .copy)
}
@Test("⌘ wins over ⌥ when both are held")
func commandWinsOverOption() {
// Finder's own reduction, and the same precedence `ClickModifier.current` applies to clicks.
#expect(DragLocality.operation(
kind: .cards, side: .live, isWithinBoard: false, modifiers: [.option, .command]) == .move)
}
/// The first carve-out: "Lane drags never copy *within their board*. is simply ignored there:
/// the drag stays a clean reorder and the badge never shows copy."
@Test("A within-board lane drag ignores ⌥ entirely")
func laneDragsNeverCopyWithinTheirBoard() {
for modifiers in [none, option, command, [.option, .command] as NSEvent.ModifierFlags] {
#expect(
DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: true, modifiers: modifiers) == .move,
"a within-board lane drag is a reorder whatever is held"
)
}
// Across boards the lane obeys the ordinary grammar again.
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: option) == .copy)
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: command) == .move)
}
/// The second: a trash row's drag is copy-out grammar (04-interactions.md The trash). Within its
/// own board the default is the restore a move, no badge; across boards the default is the live
/// copy that leaves the tombstone standing. forces the true restore-move either way, and the
/// live copy either way.
@Test("A trash row drags as a restore at home and as a copy-out abroad")
func trashDragDefaults() {
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: true, modifiers: none) == .move)
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: false, modifiers: none) == .copy)
#expect(DragLocality.operation(
kind: .cards, side: .trashed, isWithinBoard: false, modifiers: command) == .move)
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: true, modifiers: option) == .copy)
}
}
// MARK: - The committed-overlay hold
@Suite("CommittedHold")
struct CommittedHoldTests {
private static let here = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true)
private static let there = URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true)
private static let hold = CommittedHold(boardRoot: here, generation: 7)
@Test("The hold stands until the destination board applies a *newer* snapshot")
func retiredByTheNextSnapshot() {
// The generation at the commit is the one already on screen it is the pre-drop arrangement,
// and retiring on it would drop the overlay before the write has round-tripped.
#expect(!Self.hold.isRetired(byRoot: Self.here, generation: 7))
#expect(Self.hold.isRetired(byRoot: Self.here, generation: 8))
// *Any* snapshot hands off, not just the app-mediated echo: a foreign one that lands first
// re-grounds everything anyway.
#expect(Self.hold.isRetired(byRoot: Self.here, generation: 99))
}
@Test("A reload on another board says nothing about this one")
func otherBoardsDoNotRetireIt() {
#expect(!Self.hold.isRetired(byRoot: Self.there, generation: 99))
}
@Test("The board is matched by identity, not by string")
func rootMatchingUsesTheLocalityComparison() {
#expect(Self.hold.isRetired(byRoot: URL(fileURLWithPath: "/Boards/./Work.kanban/"), generation: 8))
}
@Test("A stale generation never retires it")
func staleGenerations() {
#expect(!Self.hold.isRetired(byRoot: Self.here, generation: 0))
#expect(!Self.hold.isRetired(byRoot: Self.here, generation: 6))
}
}
+132
View File
@@ -425,6 +425,99 @@ struct ReceiveCardsTests {
}
}
// MARK: - Within-board lane moves
/// A three-lane board, so a run of two can land at either end or between the survivors.
@MainActor
private func makeThreeLaneBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
return fixture
}
private func laneTitles(_ fixture: WriterFixture) throws -> [String] {
try loaded(fixture).lanes.filter { !$0.isDeleted }.compactMap(\.title.value)
}
@MainActor
@Suite("BoardStore ▸ moveLanes")
struct MoveLanesTests {
@Test("A run of lanes lands contiguously at the drop, in board order")
func runLandsContiguously() throws {
let fixture = try makeThreeLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let untouched = try stat(fixture, Ident.lane2)
// Todo and Done together, dropped at 0 "before what is left", which is Doing. The two
// arrive adjacent and in board order even though they were not adjacent to begin with.
store.moveLanes([lane1, lane3], toIndex: 0)
#expect(try laneTitles(fixture) == ["Todo", "Done", "Doing"])
// Ranks are inserted, never permuted: the lane that did not move was never opened.
#expect(try stat(fixture, Ident.lane2) == untouched)
#expect(try order(fixture, Ident.lane2) == .valid(2048))
#expect(store.banners.oneShots.isEmpty)
}
@Test("A run dropped past the survivors appends in order")
func runAppends() throws {
let fixture = try makeThreeLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.moveLanes([lane1, lane2], toIndex: 1)
#expect(try laneTitles(fixture) == ["Done", "Todo", "Doing"])
}
@Test("A single lane behaves exactly as the singular command does")
func singleLane() throws {
let fixture = try makeThreeLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.moveLanes([lane3], toIndex: 0)
#expect(try laneTitles(fixture) == ["Done", "Todo", "Doing"])
}
@Test("A drag that lands where everything already is writes nothing")
func noOpDropWritesNothing() throws {
let fixture = try makeThreeLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try [Ident.lane1, Ident.lane2, Ident.lane3].map { try stat(fixture, $0) }
store.moveLanes([lane1], toIndex: 0)
store.moveLanes([lane1, lane2], toIndex: 0)
store.moveLanes([], toIndex: 1)
store.moveLanes([ItemID(rawValue: Ident.indexless)], toIndex: 0)
#expect(try [Ident.lane1, Ident.lane2, Ident.lane3].map { try stat(fixture, $0) } == before)
#expect(store.banners.oneShots.isEmpty)
}
@Test("An out-of-range index clamps rather than trapping")
func indexClamps() throws {
// A store apiece: a write does not touch the snapshot (the one-way flow), so two drops
// against one store would both be computed against the pre-drop board.
let high = try makeThreeLaneBoard()
defer { high.tearDown() }
try BoardStore(rootURL: high.root).moveLanes([lane1], toIndex: 99)
#expect(try laneTitles(high) == ["Doing", "Done", "Todo"])
let low = try makeThreeLaneBoard()
defer { low.tearDown() }
try BoardStore(rootURL: low.root).moveLanes([lane3], toIndex: -5)
#expect(try laneTitles(low) == ["Done", "Todo", "Doing"])
}
}
// MARK: - Cross-board lane arrivals
@MainActor
@@ -579,6 +672,45 @@ struct RestoreByDragPositionTests {
#expect(try titles(lane1, in: fixture) == ["First", "Third", "Trashed"])
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(4096))
}
@Test("A multi-row restore lands the run contiguously, in drop order, in one bracket")
func multiRestore() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "TrashedA"))
try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstoned(order: "3072", title: "TrashedB"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
let store = try BoardStore(rootURL: fixture.root)
store.restoreByDrag(cardIDs: [card3, card2], intoLane: lane2, at: 0)
#expect(try titles(lane2, in: fixture) == ["TrashedB", "TrashedA", "Fourth"],
"the payload's order is the landing order")
#expect(TrashModel.isEmpty(try loaded(fixture)))
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card3)"))
#expect(store.banners.oneShots.isEmpty)
}
@Test("A row that is not a trash row is skipped, and an empty list writes nothing")
func skipsWhatIsNotARow() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let untouched = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
// `card1` is live it has no trash row to drag and `indexless` names nothing.
store.restoreByDrag(cardIDs: [card1, ItemID(rawValue: Ident.indexless)], intoLane: lane2, at: 0)
store.restoreByDrag(cardIDs: [], intoLane: lane2, at: 0)
#expect(try titles(lane2, in: fixture) == ["Fourth"])
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == untouched)
#expect(store.banners.oneShots.isEmpty)
}
}
@MainActor
+26 -9
View File
@@ -184,8 +184,8 @@ struct SpanCappedSlotTests {
// MARK: - The lane strip
/// `standard = 100`, `gap = 10`, matching `LaneReorderMathTests`: 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.
/// `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
@@ -197,14 +197,14 @@ struct LaneSlotTests {
#expect(extents == [10...110, 120...440, 450...550])
#expect(DropSlotMath.laneExtents(unitCounts: [], standard: standard, gap: gap).isEmpty)
// The centres agree with `LaneReorderMath.centre`, which reads the same layout the two
// must never drift, since the drag's replica offsets from one and its proposal from the
// other.
for index in 0..<3 {
let centre = LaneReorderMath.centre(ofLaneAt: index, unitCounts: [1, 3, 1],
standard: standard, gap: gap)
#expect(centre == (extents[index].lowerBound + extents[index].upperBound) / 2)
// 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")
@@ -250,6 +250,23 @@ struct LaneSlotTests {
#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
-162
View File
@@ -1,162 +0,0 @@
import CoreGraphics
import Testing
@testable import Kanban
/// `LaneReorderMath` the lane drag's proposal, as arithmetic.
///
/// The board these numbers describe: `standard = 100`, `gap = 10`, so a 1× slot is 100pt wide, a 2×
/// slot is 210 (two standards plus the interior gap it swallows) and a 3× is 320. The strip's outer
/// margin is one gap, so the first slot starts at x = 10.
private let standard: CGFloat = 100
private let gap: CGFloat = 10
private func proposal(_ units: [Int], dragging index: Int, centre: CGFloat) -> Int {
LaneReorderMath.proposedIndex(
unitCounts: units,
draggedIndex: index,
dragCentreX: centre,
standard: standard,
gap: gap
)
}
@Suite("LaneReorderMath")
struct LaneReorderMathTests {
// MARK: Resting geometry
@Test("A lane's resting centre is its slot's midpoint, gaps and wide lanes counted")
func restingCentres() {
// Four 1× lanes: slots at [10, 110), [120, 220), [230, 330), [340, 440).
let uniform = [1, 1, 1, 1]
#expect(LaneReorderMath.centre(ofLaneAt: 0, unitCounts: uniform, standard: standard, gap: gap) == 60)
#expect(LaneReorderMath.centre(ofLaneAt: 1, unitCounts: uniform, standard: standard, gap: gap) == 170)
#expect(LaneReorderMath.centre(ofLaneAt: 3, unitCounts: uniform, standard: standard, gap: gap) == 390)
// A 3× lane in the middle: slots at [10, 110), [120, 440), [450, 550).
let mixed = [1, 3, 1]
#expect(LaneReorderMath.centre(ofLaneAt: 1, unitCounts: mixed, standard: standard, gap: gap) == 280)
#expect(LaneReorderMath.centre(ofLaneAt: 2, unitCounts: mixed, standard: standard, gap: gap) == 500)
// An index past the end yields the position the next slot would start at, rather than
// trapping: the drag's lane can vanish between a render and a gesture callback.
#expect(LaneReorderMath.centre(ofLaneAt: 9, unitCounts: mixed, standard: standard, gap: gap) == 560)
}
// MARK: The proposal
@Test("A lane that has not moved proposes its own index")
func restingDragProposesNoChange() {
// Dragging lane 1 of four: with it removed the remaining centres are 60, 170, 280. Its own
// resting centre is 170, which has passed exactly one of them.
#expect(proposal([1, 1, 1, 1], dragging: 1, centre: 170) == 1)
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 60) == 0)
#expect(proposal([1, 1, 1, 1], dragging: 3, centre: 390) == 3)
}
@Test("The proposal steps once the cursor passes a remaining lane's centre, and not before")
func theThresholdIsTheNeighboursCentre() {
// Dragging lane 0 out of four. Remaining slots are the other three, laid out from x = 10:
// centres 60, 170, 280.
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 59) == 0)
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 60) == 0, "the boundary itself does not step — strictly past")
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 61) == 1)
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 171) == 2)
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 281) == 3)
}
@Test("A far drag in either direction clamps to the ends")
func farDragsClampToTheEnds() {
#expect(proposal([1, 1, 1, 1], dragging: 2, centre: -5000) == 0)
#expect(proposal([1, 1, 1, 1], dragging: 2, centre: 5000) == 3, "the last index with the lane itself removed")
#expect(proposal([1, 1, 1], dragging: 0, centre: 5000) == 2)
}
@Test("Width-aware: a wide neighbour has to be crossed, not merely touched")
func wideNeighboursDemandRealTravel() {
// Lanes [1, 3, 1] with the 1× at index 0 dragged. The remaining pair is the 3× then the 1×:
// slots [10, 330) and [340, 440), centres 170 and 390.
//
// "No reflow until the cursor reaches where the dragged lane would actually land": at 200 the
// cursor is well inside the wide lane but has passed its centre, so the step is honest; at
// 150 it has not, and proposing a swap there would reorder the board under a cursor still
// sitting over the lane it started left of.
#expect(proposal([1, 3, 1], dragging: 0, centre: 150) == 0)
#expect(proposal([1, 3, 1], dragging: 0, centre: 200) == 1)
#expect(proposal([1, 3, 1], dragging: 0, centre: 400) == 2)
}
@Test("The proposal is monotone in the cursor — it never oscillates")
func theProposalIsMonotone() {
// One threshold per remaining slot, crossed once, is what makes the shadow stable rather
// than jittery (04-interactions.md Drag and drop). Sweeping the whole strip must therefore
// produce a non-decreasing sequence.
let units = [2, 1, 3, 1, 2]
var last = 0
for x in stride(from: CGFloat(-200), through: 1200, by: 1) {
let next = proposal(units, dragging: 2, centre: x)
#expect(next >= last, "the proposal went backwards as the cursor moved right, at x = \(x)")
last = next
}
#expect(last == units.count - 1)
}
@Test("A single-lane board proposes the only index there is")
func singleLaneBoard() {
#expect(proposal([1], dragging: 0, centre: -900) == 0)
#expect(proposal([1], dragging: 0, centre: 900) == 0)
}
@Test("An out-of-range dragged index yields zero rather than trapping")
func vanishedLaneDoesNotTrap() {
// The lane vanished under the drag; the caller's release-with-no-valid-proposal rule cancels
// anyway, so the only contract here is totality.
#expect(proposal([1, 1], dragging: 7, centre: 100) == 0)
#expect(proposal([], dragging: 0, centre: 100) == 0)
}
// MARK: Applying a proposal
@Test("Reordering applies the proposal's own index convention")
func reorderedAppliesTheConvention() {
let lanes = ["a", "b", "c", "d"]
// `to` counts positions with the item already removed, which is what `proposedIndex`
// returns so `to == from` must be the identity.
#expect(LaneReorderMath.reordered(lanes, from: 1, to: 1) == lanes)
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 0) == lanes)
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 1) == ["b", "a", "c", "d"])
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 3) == ["b", "c", "d", "a"])
#expect(LaneReorderMath.reordered(lanes, from: 3, to: 0) == ["d", "a", "b", "c"])
#expect(LaneReorderMath.reordered(lanes, from: 2, to: 1) == ["a", "c", "b", "d"])
}
@Test("Reordering is total: out-of-range indices clamp or pass through")
func reorderedIsTotal() {
let lanes = ["a", "b", "c"]
#expect(LaneReorderMath.reordered(lanes, from: 9, to: 0) == lanes)
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 99) == ["b", "c", "a"])
#expect(LaneReorderMath.reordered(lanes, from: 2, to: -5) == ["c", "a", "b"])
}
@Test("A dragged lane parked over each slot in turn lands exactly there")
func aRoundTripThroughEverySlot() {
// The end-to-end claim the two halves compose into: park the dragged lane on top of a
// sibling's resting centre and the proposal, applied, puts it in that sibling's place.
let units = [1, 2, 1, 3]
let lanes = ["a", "b", "c", "d"]
let from = 0
var remaining = units
remaining.remove(at: from)
for slot in remaining.indices {
let centre = LaneReorderMath.centre(ofLaneAt: slot, unitCounts: remaining, standard: standard, gap: gap)
// A hair past the centre is what "passed it" means; sitting exactly on it holds.
let landed = proposal(units, dragging: from, centre: centre + 1)
#expect(landed == slot + 1)
#expect(LaneReorderMath.reordered(lanes, from: from, to: landed).firstIndex(of: "a") == slot + 1)
}
}
}