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:
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user