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) } } }