import CoreGraphics import Testing @testable import Kanban /// `DragAutoScrollMath` — given a viewport and a pointer inside (or just outside) it, how fast, and /// which way, should the scroll view move? Ported from the pathfinder's suite, whose numbers are /// what was proven. The live driver is the drag session's; this is the decision it makes once per /// frame of the display the lane is on (`DragAutoScrollDriverTests`, DRAG-REORDER.md § Edge /// autoscroll). private let length: CGFloat = 400 private let band = DragAutoScrollMath.band private let minSpeed = DragAutoScrollMath.minSpeed private let maxSpeed = DragAutoScrollMath.maxSpeed private func velocity(_ position: CGFloat, length viewport: CGFloat = length) -> CGFloat { DragAutoScrollMath.velocity(position: position, length: viewport) } private func isClose(_ value: CGFloat, _ expected: CGFloat, _ tolerance: CGFloat = 0.0001) -> Bool { abs(value - expected) <= tolerance } @Suite("DragAutoScrollMath") struct DragAutoScrollMathTests { // MARK: The neutral middle @Test("The middle of the viewport never scrolls") func middleNeverScrolls() { for position in stride(from: band, through: length - band, by: 8) { #expect(velocity(position) == 0, "cursor \(position) is outside both bands") } // The band boundaries themselves are neutral — a band is the region strictly inside one. #expect(velocity(band) == 0) #expect(velocity(length - band) == 0) } // MARK: Direction @Test("The leading band scrolls toward the start and the trailing band toward the end") func direction() { #expect(velocity(band - 1) < 0) #expect(velocity(0) < 0) #expect(velocity(length - band + 1) > 0) #expect(velocity(length) > 0) } // MARK: The ramp @Test("Speed ramps with edge proximity, on both ends") func speedRampsWithProximity() { var previous = abs(velocity(band - 0.5)) for position in stride(from: band - 8, through: 0, by: -8) { let speed = abs(velocity(position)) #expect(speed > previous, "cursor \(position) should beat the shallower sample") previous = speed } previous = abs(velocity(length - band + 0.5)) for position in stride(from: length - band + 8, through: length, by: 8) { let speed = abs(velocity(position)) #expect(speed > previous, "cursor \(position) should beat the shallower sample") previous = speed } } @Test("The ramp spans the floor to the ceiling, linearly") func rampIsLinearBetweenFloorAndCeiling() { // Just inside the band: the floor, which exists so entering a band produces visible motion // rather than an imperceptible crawl. At the viewport edge: the ceiling. Halfway: the mean. #expect(isClose(abs(velocity(band - 0.0001)), minSpeed, 0.01)) #expect(isClose(abs(velocity(0)), maxSpeed)) #expect(isClose(abs(velocity(band / 2)), (minSpeed + maxSpeed) / 2)) #expect(isClose(abs(velocity(length)), maxSpeed)) #expect(isClose(abs(velocity(length - band / 2)), (minSpeed + maxSpeed) / 2)) } @Test("Beyond the viewport edge the speed saturates rather than growing") func saturatesBeyondTheEdge() { // A pointer over the lane header (above the scroll area) or below its bottom padding drives // the fastest scroll, never faster. #expect(isClose(velocity(-40), -maxSpeed)) #expect(isClose(velocity(-4000), -maxSpeed)) #expect(isClose(velocity(length + 40), maxSpeed)) } // MARK: Degenerate viewports @Test("A short viewport halves its bands instead of overlapping them") func shortViewport() { let short: CGFloat = 60 #expect(velocity(30, length: short) == 0, "the exact centre still resolves to no scrolling") #expect(velocity(29, length: short) < 0) #expect(velocity(31, length: short) > 0) #expect(isClose(abs(velocity(0, length: short)), maxSpeed)) } @Test("An empty or inverted viewport never scrolls") func emptyViewport() { #expect(velocity(0, length: 0) == 0) #expect(velocity(10, length: -5) == 0) } // MARK: Two axes @Test("The two axes are resolved independently") func axesAreIndependent() { let viewport = CGSize(width: 400, height: 400) let bottom = DragAutoScrollMath.velocity(pointer: CGPoint(x: 200, y: 390), viewport: viewport) #expect(bottom.dx == 0) #expect(bottom.dy > 0) let corner = DragAutoScrollMath.velocity(pointer: CGPoint(x: 2, y: 2), viewport: viewport) #expect(corner.dx < 0) #expect(corner.dy < 0) let centre = DragAutoScrollMath.velocity(pointer: CGPoint(x: 200, y: 200), viewport: viewport) #expect(centre.dx == 0) #expect(centre.dy == 0) } // MARK: Engagement reach /// The standard system body size — the ruler every figure below was tuned against. private static let standardBody: CGFloat = 13 @Test("Engagement reaches over the header but barely sideways") func engagementReach() { let viewport = CGSize(width: 240, height: 400) let size = Self.standardBody let above = DragAutoScrollMath.reachAbove(bodyPointSize: size) let below = DragAutoScrollMath.reachBelow(bodyPointSize: size) let side = DragAutoScrollMath.reachSide(bodyPointSize: size) let reach = DragAutoScrollMath.engagementRect(viewport: viewport, bodyPointSize: size) #expect(reach.contains(CGPoint(x: 120, y: 200)), "inside the visible area, always") // Above it (the lane header) and below it (the strip's padding). #expect(reach.contains(CGPoint(x: 120, y: -above + 1))) #expect(reach.contains(CGPoint(x: 120, y: viewport.height + below - 1))) #expect(!reach.contains(CGPoint(x: 120, y: -above - 1))) #expect(!reach.contains(CGPoint(x: 120, y: viewport.height + below + 1))) // Sideways: only a sliver, so the neighbouring lane never engages. #expect(reach.contains(CGPoint(x: -side + 1, y: 200))) #expect(!reach.contains(CGPoint(x: -side - 1, y: 200))) #expect(!reach.contains(CGPoint(x: viewport.width + side + 1, y: 200))) // The sideways reach must stay under half the distance between two lanes' scroll areas, or // two lanes would scroll at once. #expect(side < 28 / 2) } @Test("The standard body size draws the reaches it always drew") func reachesAtStandardBodySize() { let size = Self.standardBody #expect(DragAutoScrollMath.reachAbove(bodyPointSize: size) == 48) #expect(DragAutoScrollMath.reachBelow(bodyPointSize: size) == 24) #expect(DragAutoScrollMath.reachSide(bodyPointSize: size) == 12) } /// The reaches are distances to the lane's own furniture — a header, a padding, a gap — and all /// three of those grow with the board's zoom (03-board-ui.md ▸ Layout — zoom). A reach fixed in /// points would stop covering the header it is specified against. @Test("Every reach grows with the board's ruler") func reachesFollowTheRuler() { let standard = Self.standardBody let zoomed = BoardZoom.bodyPointSize(system: standard, level: 2.0) #expect(DragAutoScrollMath.reachAbove(bodyPointSize: zoomed) > DragAutoScrollMath.reachAbove(bodyPointSize: standard)) #expect(DragAutoScrollMath.reachBelow(bodyPointSize: zoomed) > DragAutoScrollMath.reachBelow(bodyPointSize: standard)) #expect(DragAutoScrollMath.reachSide(bodyPointSize: zoomed) > DragAutoScrollMath.reachSide(bodyPointSize: standard)) // And the sideways rule holds on the zoomed ruler too: half the distance between two lanes' // scroll areas is the gap plus a plate padding on each side. let gap = BoardMetrics.stripGap(bodyPointSize: zoomed) let padding = BoardMetrics.lanePlatePadding(bodyPointSize: zoomed) #expect(DragAutoScrollMath.reachSide(bodyPointSize: zoomed) <= (gap + 2 * padding) / 2) } // MARK: Stepping the offset @Test("One tick advances the offset by velocity × elapsed") func nextOffsetAdvances() { #expect(DragAutoScrollMath.nextOffset(current: 100, velocity: 600, elapsed: 0.5, minOffset: 0, maxOffset: 1000) == 400) #expect(DragAutoScrollMath.nextOffset(current: 100, velocity: -600, elapsed: 0.1, minOffset: 0, maxOffset: 1000) == 40) } @Test("A tick clamps into the scrollable range") func nextOffsetClamps() { #expect(DragAutoScrollMath.nextOffset(current: 10, velocity: -800, elapsed: 1, minOffset: 0, maxOffset: 1000) == 0) #expect(DragAutoScrollMath.nextOffset(current: 990, velocity: 800, elapsed: 1, minOffset: 0, maxOffset: 1000) == 1000) // Content shorter than the viewport: nothing to scroll, pin to the top. #expect(DragAutoScrollMath.nextOffset(current: 0, velocity: 800, elapsed: 1, minOffset: 0, maxOffset: -120) == 0) } }