// Standalone tests for reasampler::instrument::engine::velocity_curve — no VST3, no REAPER, no framework. Same fast
// assert loop as the sibling pure tests. Assert the velocity transfer curve HARD:
//
//  * eval — flat y=1 unipolar default (EVERY velocity -> 1.0), linear ramp, curved shape
//    between points, box-clamp of an out-of-range velocity, monotonic-in-x over the whole domain.
//  * the BIPOLAR domain — zero() is exactly 0 everywhere, the negative half evaluates and clamps
//    at -1, knots inside [0,1] evaluate identically in either domain (what the codec's v12
//    re-tag rests on), and the pixel maps put value 0 on the box's centre line, not its floor.
//  * editing — addPoint keeps X-order + box-clamp; movePoint clamps an interior point between its
//    neighbours (can't cross) and box-clamps the value; endpoints are X-pinned (velocity 0 / 127)
//    with only the value mobile; deletePoint removes interior points but REFUSES the two endpoints.
//  * hit-test + inverse map — pointAtPixel grabs a drawn node; resolvePointDrag maps pixel delta to
//    a clamped point (endpoint X-pinned; interior clamped to neighbours); degenerate box -> no motion.
//  * fromPoints — the deserialization repair: sorts by X, box-clamps, forces endpoints, and falls
//    back to each domain's OWN neutral for a sub-2-point list.

#include "../src/core/instrument/engine/velocity_curve.h"

#include <algorithm>
#include <cmath>
#include <cstdio>
#include <vector>

using namespace reasampler;
using namespace reasampler::instrument::engine;

static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
    std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)

static bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b) <= eps; }

using Box = VelocityCurve::Box;

// The pixel maps are members (the y domain lives on the curve), so a mapping test speaks
// through a curve of the domain under test rather than a free function.
static const VelocityCurve& uni() {
    static const VelocityCurve c = VelocityCurve::flat();
    return c;
}
static const VelocityCurve& bip() {
    static const VelocityCurve c = VelocityCurve::zero();
    return c;
}

// --- eval ---------------------------------------------------------------------

static void testFlatIsUnityEverywhere() {
    const VelocityCurve c = VelocityCurve::flat();
    // R10-F1 Option A: every velocity plays at full level. Sweep the whole domain.
    for (int v = 0; v <= 127; ++v) CHECK(near(c.eval(v), 1.0));
    // Two endpoints only.
    CHECK(c.size() == 2);
}

static void testLinearRamp() {
    const VelocityCurve c = VelocityCurve::linear();
    CHECK(near(c.eval(0), 0.0));
    CHECK(near(c.eval(127), 1.0));
    // linear() is an EXACT straight line y = velocity/127: at any velocity the amp equals v/127.
    CHECK(near(c.eval(63.5), 0.5));       // the exact midpoint
    CHECK(near(c.eval(64.0), 64.0 / 127.0));
    CHECK(near(c.eval(100.0), 100.0 / 127.0));
}

static void testEvalBoxClampsOutOfRangeVelocity() {
    const VelocityCurve c = VelocityCurve::linear();
    CHECK(near(c.eval(-10.0), 0.0));    // below 0 -> reads velocity-0 endpoint amp
    CHECK(near(c.eval(200.0), 1.0));    // above 127 -> reads velocity-127 endpoint amp
}

static void testEvalMonotonicInX() {
    // A curve that dips then rises must still be a well-defined FUNCTION (one amp per velocity) and
    // monotonic WITHIN each segment. Build (0,1)->(64,0)->(127,1): eval sweeps must be single-valued
    // and each half monotonic (down then up), never oscillating within a segment.
    VelocityCurve c = VelocityCurve::flat();
    c.movePoint(0, 0, 1.0);
    c.addPoint(64.0, 0.0);
    c.movePoint(2, 127, 1.0);  // index 2 is the last endpoint after the insert
    CHECK(c.size() == 3);
    // Descending half [0,64]: non-increasing.
    double prev = c.eval(0);
    for (int v = 1; v <= 64; ++v) {
        const double cur = c.eval(v);
        CHECK(cur <= prev + 1e-9);
        prev = cur;
    }
    // Ascending half [64,127]: non-decreasing.
    prev = c.eval(64);
    for (int v = 65; v <= 127; ++v) {
        const double cur = c.eval(v);
        CHECK(cur >= prev - 1e-9);
        prev = cur;
    }
    CHECK(near(c.eval(64), 0.0));  // the trough sits exactly on the moved point
}

// --- eval: monotone cubic Hermite spline (Fritsch–Carlson) --------------------

static void testCollinearControlPointsReproduceExactLinearRamp() {
    // The Option-B guarantee: for COLLINEAR knots the FC tangents reduce to the secant slope, so the
    // spline IS the straight line y = velocity/127 — bit-exact, not merely close. Add an interior
    // point that sits EXACTLY on the linear ramp so all three knots are collinear.
    VelocityCurve c = VelocityCurve::linear();      // (0,0),(127,1)
    c.addPoint(60.0, 60.0 / 127.0);                 // on the line -> still collinear
    // Every velocity must equal velocity/127 to full double precision (bit-exact reproduction).
    for (int v = 0; v <= 127; ++v) CHECK(near(c.eval(v), v / 127.0, 1e-12));
    // And the untouched linear() with only its two endpoints, too.
    const VelocityCurve line = VelocityCurve::linear();
    for (int v = 0; v <= 127; ++v) CHECK(near(line.eval(v), v / 127.0, 1e-12));
}

static void testNoOvershootWithSharpInteriorDip() {
    // A sharp interior dip is the classic overshoot trap: a NON-monotone interpolant (Catmull-Rom /
    // natural cubic) would bulge the curve below 0 near the trough. Fritsch–Carlson must keep every
    // sampled amp inside [0,1] across the whole domain. Build (0,1)->(64,0)->(127,1).
    VelocityCurve c = VelocityCurve::flat();
    c.movePoint(0, 0, 1.0);
    c.addPoint(64.0, 0.0);
    c.movePoint(2, 127, 1.0);
    for (int v = 0; v <= 127; ++v) {
        const double y = c.eval(v);
        CHECK(y >= 0.0 - 1e-12 && y <= 1.0 + 1e-12);
    }
    // A dense sub-integer sweep too (the spline could overshoot between integer velocities).
    for (int k = 0; k <= 1270; ++k) {
        const double y = c.eval(k / 10.0);
        CHECK(y >= 0.0 - 1e-12 && y <= 1.0 + 1e-12);
    }
    CHECK(near(c.eval(64), 0.0));  // knot honored exactly
}

static void testSplineStaysSingleValuedMonotoneInEachSegment() {
    // A rising staircase of knots: the spline must be non-decreasing across the whole domain (the FC
    // limiter forbids overshoot, so a monotone-increasing knot set yields a monotone-increasing
    // curve — no local wiggles that would make eval multi-valued in feel).
    VelocityCurve c = VelocityCurve::linear();
    c.addPoint(30.0, 0.1);
    c.addPoint(60.0, 0.15);   // a near-flat run then a steep rise: overshoot bait for a plain cubic
    c.addPoint(90.0, 0.9);
    double prev = c.eval(0);
    for (int k = 1; k <= 1270; ++k) {
        const double cur = c.eval(k / 10.0);
        CHECK(cur >= prev - 1e-9);   // non-decreasing everywhere -> single-valued, no wiggle
        CHECK(cur >= 0.0 - 1e-12 && cur <= 1.0 + 1e-12);
        prev = cur;
    }
}

static void testSplinePinsEndpointsExactly() {
    // The curve must pass THROUGH every control point, endpoints included, regardless of curvature.
    VelocityCurve c = VelocityCurve::flat();
    c.movePoint(0, 0, 0.2);       // first endpoint amp 0.2
    c.addPoint(40.0, 0.9);
    c.addPoint(80.0, 0.1);
    c.movePoint(3, 127, 0.7);     // last endpoint amp 0.7
    CHECK(near(c.eval(0), 0.2));
    CHECK(near(c.eval(40), 0.9));
    CHECK(near(c.eval(80), 0.1));
    CHECK(near(c.eval(127), 0.7));
}

// --- editing: addPoint --------------------------------------------------------

static void testAddPointKeepsXOrderAndClamps() {
    VelocityCurve c = VelocityCurve::linear();  // (0,0), (127,1)
    const std::size_t i = c.addPoint(60.0, 0.3);
    CHECK(i == 1);  // inserted between the two endpoints
    CHECK(c.size() == 3);
    CHECK(near(c.points()[1].velocity, 60.0) && near(c.points()[1].value, 0.3));
    // Out-of-box add clamps into [0,127] x [0,1].
    c.addPoint(500.0, 5.0);
    const VelocityPoint& last = c.points().back();
    CHECK(near(last.velocity, 127.0) && near(last.value, 1.0));
    // Points remain X-ordered.
    for (std::size_t k = 1; k < c.size(); ++k)
        CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity);
}

// --- editing: movePoint -------------------------------------------------------

static void testMoveInteriorClampsToNeighbours() {
    VelocityCurve c = VelocityCurve::linear();
    c.addPoint(40.0, 0.4);   // idx 1
    c.addPoint(80.0, 0.8);   // idx 2
    CHECK(c.size() == 4);    // (0,0)(40,.4)(80,.8)(127,1)
    // Try to drag idx 1 PAST idx 2 (velocity 200): clamps to idx 2's velocity (80), not beyond.
    const VelocityPoint r = c.movePoint(1, 200.0, 0.5);
    CHECK(near(r.velocity, 80.0));
    CHECK(near(r.value, 0.5));  // amp is free (box-clamped only)
    // Try to drag idx 1 BELOW idx 0 (velocity -5): clamps to idx 0's velocity (0).
    const VelocityPoint r2 = c.movePoint(1, -5.0, 0.5);
    CHECK(near(r2.velocity, 0.0));
}

static void testMoveEndpointsArePinnedInX() {
    VelocityCurve c = VelocityCurve::linear();
    // Move the first endpoint: velocity argument ignored (pinned at 0), amp moves.
    const VelocityPoint f = c.movePoint(0, 50.0, 0.25);
    CHECK(near(f.velocity, 0.0));
    CHECK(near(f.value, 0.25));
    // Move the last endpoint: pinned at 127, amp moves, and amp box-clamps.
    const VelocityPoint l = c.movePoint(1, 10.0, 5.0);
    CHECK(near(l.velocity, 127.0));
    CHECK(near(l.value, 1.0));
}

static void testMoveOutOfRangeIndexIsNoOp() {
    VelocityCurve c = VelocityCurve::linear();
    c.movePoint(99, 50.0, 0.5);
    CHECK(c.size() == 2);
    CHECK(near(c.points()[0].value, 0.0) && near(c.points()[1].value, 1.0));  // unchanged
}

// --- editing: deletePoint -----------------------------------------------------

static void testDeleteRemovesInteriorRefusesEndpoints() {
    VelocityCurve c = VelocityCurve::linear();
    c.addPoint(60.0, 0.5);   // idx 1
    CHECK(c.size() == 3);
    // Endpoints refuse deletion.
    CHECK(!c.deletePoint(0));
    CHECK(!c.deletePoint(2));
    CHECK(c.size() == 3);
    // Interior deletes.
    CHECK(c.deletePoint(1));
    CHECK(c.size() == 2);
    // Out-of-range refuses.
    CHECK(!c.deletePoint(9));
}

// --- hit-test + inverse map ---------------------------------------------------

// A 127px-wide, 101px-tall box at origin: velocity->x is 1px/unit, amp->y spans 100 rows (1 px per
// 0.01 amp), amp 1 at top (y=0), amp 0 at bottom (y=100).
static Box wideBox() { return Box{0, 0, 127, 101}; }

static void testPointAtPixelGrabsDrawnNode() {
    VelocityCurve c = VelocityCurve::linear();  // (0,0) at (0,100); (127,1) at (127,0)
    const Box b = wideBox();
    // Grab near the first endpoint's drawn point (x=0, y=100).
    CHECK(c.pointAtPixel(b, 0, 100) == 0);
    // Grab near the last endpoint (x=127, y=0).
    CHECK(c.pointAtPixel(b, 127, 0) == 1);
    // A point far from any node misses.
    CHECK(c.pointAtPixel(b, 63, 50) == -1);
}

static void testResolveDragMovesAndClamps() {
    VelocityCurve grab = VelocityCurve::linear();
    grab.addPoint(60.0, 0.5);  // idx 1, drawn at x=60, y=50
    const Box b = wideBox();
    // Drag idx 1 right 10px, up 10px: velocity +10 (->70), amp +0.10 (up = higher amp -> 0.60).
    const VelocityCurve moved = VelocityCurve::resolvePointDrag(grab, 1, b, 10, -10);
    CHECK(near(moved.points()[1].velocity, 70.0, 1e-6));
    CHECK(near(moved.points()[1].value, 0.60, 1e-6));
    // Dragging the first endpoint horizontally does not move it in X (pinned), only amp.
    const VelocityCurve movedEnd = VelocityCurve::resolvePointDrag(grab, 0, b, 40, -20);
    CHECK(near(movedEnd.points()[0].velocity, 0.0));
    CHECK(near(movedEnd.points()[0].value, 0.20, 1e-6));  // dragged up 20px = +0.20 from 0
}

static void testResolveDragDegenerateBoxNoMotion() {
    const VelocityCurve grab = VelocityCurve::linear();
    const VelocityCurve r = VelocityCurve::resolvePointDrag(grab, 1, Box{0, 0, 0, 0}, 50, 50);
    CHECK(r.equals(grab));  // zero-size box -> unchanged
}

// --- fromPoints (deserialization repair) --------------------------------------

static void testFromPointsSortsClampsAndForcesEndpoints() {
    // Unsorted, out-of-box, missing endpoints -> repaired to a valid curve.
    std::vector<VelocityPoint> raw = {{80.0, 0.9}, {20.0, -1.0}, {50.0, 2.0}};
    const VelocityCurve c = VelocityCurve::fromPoints(raw, CurveDomain::Unipolar);
    // X-ordered.
    for (std::size_t k = 1; k < c.size(); ++k)
        CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity);
    // Endpoints forced present at 0 and 127.
    CHECK(near(c.points().front().velocity, 0.0));
    CHECK(near(c.points().back().velocity, 127.0));
    // Interior amps box-clamped (the -1 became 0, the 2 became 1).
    for (const VelocityPoint& p : c.points()) {
        CHECK(p.value >= 0.0 - 1e-12 && p.value <= 1.0 + 1e-12);
    }
}

static void testFromPointsSubTwoFallsBackToFlat() {
    const VelocityCurve c0 = VelocityCurve::fromPoints({}, CurveDomain::Unipolar);
    CHECK(c0.equals(VelocityCurve::flat()));
    const VelocityCurve c1 = VelocityCurve::fromPoints({{50.0, 0.3}}, CurveDomain::Unipolar);
    CHECK(c1.equals(VelocityCurve::flat()));
    // The bipolar fallback is the domain's OWN neutral, not the unipolar one: degrading a
    // corrupt pitch/filter curve to flat-at-unity would transpose or open the filter fully.
    const VelocityCurve b0 = VelocityCurve::fromPoints({}, CurveDomain::Bipolar);
    CHECK(b0.equals(VelocityCurve::zero()));
    const VelocityCurve b1 = VelocityCurve::fromPoints({{50.0, 0.3}}, CurveDomain::Bipolar);
    CHECK(b1.equals(VelocityCurve::zero()));
}

// --- the bipolar domain -------------------------------------------------------

static void testZeroIsExactlyZeroAtEveryVelocity() {
    // The off-by-default contract: not "approximately zero" — EXACTLY zero, so a pitch or
    // cutoff offset derived from it cannot nudge anything.
    const VelocityCurve c = VelocityCurve::zero();
    CHECK(c.domain() == CurveDomain::Bipolar);
    for (int v = -20; v <= 200; ++v) CHECK(c.eval(v) == 0.0);
    CHECK(c.size() == 2);
}

static void testBipolarEvalSpansTheNegativeHalf() {
    // A ramp from -1 at velocity 0 to +1 at 127: collinear knots, so the spline is the exact
    // straight line through zero — the whole point of the widened domain.
    const VelocityCurve c =
        VelocityCurve::fromPoints({{0.0, -1.0}, {127.0, 1.0}}, CurveDomain::Bipolar);
    CHECK(near(c.eval(0), -1.0));
    CHECK(near(c.eval(127), 1.0));
    CHECK(near(c.eval(63.5), 0.0, 1e-12));
    for (int v = 0; v <= 127; ++v) CHECK(near(c.eval(v), 2.0 * v / 127.0 - 1.0, 1e-12));
}

static void testUnipolarClampsAtZeroWhereBipolarDoesNot() {
    // The same negative knot, read in the two domains: unipolar floors it at 0 (an amp gain
    // cannot be negative), bipolar keeps it.
    const std::vector<VelocityPoint> raw = {{0.0, -0.5}, {127.0, 0.5}};
    const VelocityCurve u = VelocityCurve::fromPoints(raw, CurveDomain::Unipolar);
    const VelocityCurve b = VelocityCurve::fromPoints(raw, CurveDomain::Bipolar);
    CHECK(near(u.eval(0), 0.0));
    CHECK(near(b.eval(0), -0.5));
    // Out-of-domain magnitudes clamp to each domain's own floor.
    const VelocityCurve b2 =
        VelocityCurve::fromPoints({{0.0, -9.0}, {127.0, 9.0}}, CurveDomain::Bipolar);
    CHECK(near(b2.eval(0), -1.0));
    CHECK(near(b2.eval(127), 1.0));
}

static void testTheSameKnotsEvaluateIdenticallyInEitherDomain() {
    // What the codec's v12 domain re-tag rests on: a curve whose y values all lie in [0,1] is
    // read the same way in either domain — the domain governs the CLAMP, not the evaluation.
    // Asserted against a CURVED (non-collinear) knot set, where the tangents are doing work,
    // and with ==: the re-tag is bit-identical, not merely close.
    const std::vector<VelocityPoint> knots = {
        {0.0, 0.1}, {30.0, 0.15}, {64.0, 0.9}, {100.0, 0.4}, {127.0, 1.0}};
    const VelocityCurve u = VelocityCurve::fromPoints(knots, CurveDomain::Unipolar);
    const VelocityCurve b = VelocityCurve::fromPoints(knots, CurveDomain::Bipolar);
    for (int v = 0; v <= 127; ++v) CHECK(b.eval(v) == u.eval(v));
}

static void testBipolarPixelMapPutsZeroOnTheCentreLine() {
    // The same box, read in the two domains: value 0 sits at the vertical centre for a bipolar
    // curve and at the bottom row for a unipolar one — the one mapping difference the shared
    // popup code path has to get right.
    const Box box{10, 20, 100, 101};   // 100 value rows: centre is 50 rows down
    CHECK(bip().pixelFromPoint(box, {0.0, 0.0}).y == 70);
    CHECK(uni().pixelFromPoint(box, {0.0, 0.0}).y == 120);
    // Each domain's floor lands on the bottom row, its ceiling on the top.
    CHECK(bip().pixelFromPoint(box, {0.0, -1.0}).y == 120);
    CHECK(bip().pixelFromPoint(box, {0.0, 1.0}).y == 20);
    // And the inverse agrees: the centre row reads back as 0 in bipolar, mid-scale in unipolar.
    CHECK(near(bip().pointFromPixel(box, 10, 70).value, 0.0, 1e-12));
    CHECK(near(uni().pointFromPixel(box, 10, 70).value, 0.5, 1e-12));
}

static void testBipolarDragCoversTwiceTheValueRange() {
    // A drag of N pixels moves twice as much value in bipolar as in unipolar over the same box
    // — the domain spans 2.0, not 1.0. Both still land inside their own domain.
    const Box box{0, 0, 127, 101};  // 100 value rows
    VelocityCurve u = VelocityCurve::flat();
    u.movePoint(0, 0.0, 0.5);
    VelocityCurve b = VelocityCurve::zero();
    const VelocityCurve uMoved = VelocityCurve::resolvePointDrag(u, 0, box, 0, -10);
    const VelocityCurve bMoved = VelocityCurve::resolvePointDrag(b, 0, box, 0, -10);
    CHECK(near(uMoved.points()[0].value, 0.60, 1e-6));
    CHECK(near(bMoved.points()[0].value, 0.20, 1e-6));
}

// --- S-VIEW-10 pixel maps (the editor draw/add seam) -----------------------------

static void testPixelFromPointMapsCornersAndMidpoint() {
    // Box 100 px wide, 51 px tall at (10, 20). velToX spans the WIDTH (frac * w); ampToY spans
    // (h - 1) rows with amp 1 at the top — assert the drawn corners land where the module's own
    // hit-test mapping puts them.
    const Box box{10, 20, 100, 51};
    const auto tl = uni().pixelFromPoint(box, {0.0, 1.0});
    CHECK(tl.x == 10 && tl.y == 20);
    const auto br = uni().pixelFromPoint(box, {127.0, 0.0});
    CHECK(br.x == 110 && br.y == 70);
    const auto mid = uni().pixelFromPoint(box, {63.5, 0.5});
    CHECK(mid.x == 60 && mid.y == 45);
    // Out-of-box values are clamped by the mapping (velocity 200 draws at the right edge).
    const auto clamped = uni().pixelFromPoint(box, {200.0, 2.0});
    CHECK(clamped.x == 110 && clamped.y == 20);
}

static void testSubpixelMapIsTheIntegerMapBeforeRounding() {
    // The antialiased trace draws off subpixelFromPoint while the hit-test still resolves against
    // pixelFromPoint. If the two were separate formulas the trace would drift off its own handles,
    // so pin the relationship rather than the sub-pixel values: int == round(subpixel), everywhere.
    const Box boxes[] = {{10, 20, 100, 51}, {0, 0, 127, 101}, {7, 3, 33, 17}};
    for (const Box& box : boxes) {
        for (const VelocityCurve& c : {uni(), bip()}) {
            const double lo = curveYMin(c.domain());
            for (double v = 0.0; v <= 127.0; v += 1.0) {
                for (double a = -1.0; a <= 1.0; a += 0.125) {
                    const auto ip = c.pixelFromPoint(box, {v, a});
                    const auto fp = c.subpixelFromPoint(box, {v, a});
                    // Relationship guard: int IS round(subpixel), everywhere — a forward guard
                    // against the trace drifting off its own handles. This alone is a tautology
                    // against the CURRENT formula (int literally computes this expression), so
                    // it cannot catch a regression against the PRE-subpixel formula below.
                    CHECK(ip.x == box.left + static_cast<int>(fp.x - box.left + 0.5));
                    CHECK(ip.y == box.top + static_cast<int>(fp.y - box.top + 0.5));

                    // Independent re-derivation of the formula the int map claims to reproduce —
                    // box.left + (int)(frac*w + 0.5) — computed here from scratch rather than
                    // through subpixelFromPoint's box.left-add-then-subtract round trip, so a
                    // flip introduced by that round trip (however unlikely per Sterbenz) has
                    // something to fail against.
                    const double vClamped = (std::min)(kVelMax, (std::max)(kVelMin, v));
                    const double fracX = (vClamped - kVelMin) / (kVelMax - kVelMin);
                    const int oldX =
                        box.left + static_cast<int>(fracX * static_cast<double>(box.width) + 0.5);
                    CHECK(ip.x == oldX);

                    const double aClamped = (std::min)(kCurveYMax, (std::max)(lo, a));
                    const double fracY = (aClamped - lo) / (kCurveYMax - lo);
                    const int oldY =
                        box.top + static_cast<int>((1.0 - fracY) *
                                                    static_cast<double>(box.height - 1) + 0.5);
                    CHECK(ip.y == oldY);
                }
            }
        }
    }
}

static void testSubpixelMapResolvesSlopesTheIntegerMapFlattens() {
    // The defect this exists for: adjacent columns of a gentle slope round to the SAME integer
    // row, so an integer-only trace is a staircase. The sub-pixel map must separate them.
    const Box box{0, 0, 400, 101};
    const VelocityCurve c = VelocityCurve::linear();
    int identicalIntRows = 0;
    double maxSubpixelStep = 0.0, minSubpixelStep = 1e18;
    for (int x = 1; x <= 200; ++x) {
        const double v0 = c.pointFromPixel(box, x - 1, box.top).velocity;
        const double v1 = c.pointFromPixel(box, x, box.top).velocity;
        const int y0 = c.pixelFromPoint(box, {v0, c.eval(v0)}).y;
        const int y1 = c.pixelFromPoint(box, {v1, c.eval(v1)}).y;
        if (y0 == y1) ++identicalIntRows;
        const double d = std::fabs(c.subpixelFromPoint(box, {v1, c.eval(v1)}).y -
                                   c.subpixelFromPoint(box, {v0, c.eval(v0)}).y);
        if (d > maxSubpixelStep) maxSubpixelStep = d;
        if (d < minSubpixelStep) minSubpixelStep = d;
    }
    CHECK(identicalIntRows > 100);                          // the integer map really does flatten
    CHECK(maxSubpixelStep - minSubpixelStep < 1e-9);        // the sub-pixel one advances evenly
    CHECK(maxSubpixelStep > 0.0);
}

static void testPointFromPixelInvertsAndClamps() {
    const Box box{10, 20, 100, 51};
    // Exact corners invert exactly.
    const VelocityPoint tl = uni().pointFromPixel(box, 10, 20);
    CHECK(near(tl.velocity, 0.0) && near(tl.value, 1.0));
    const VelocityPoint br = uni().pointFromPixel(box, 110, 70);
    CHECK(near(br.velocity, 127.0) && near(br.value, 0.0));
    // A pixel OUTSIDE the box clamps into the domain (never an invariant-violating point).
    const VelocityPoint out = uni().pointFromPixel(box, -50, 500);
    CHECK(near(out.velocity, 0.0) && near(out.value, 0.0));
    const VelocityPoint out2 = uni().pointFromPixel(box, 500, -50);
    CHECK(near(out2.velocity, 127.0) && near(out2.value, 1.0));
}

static void testPixelMapsRoundTripWithinOnePixelQuantum() {
    // Forward-then-inverse must agree within one pixel's worth of value (the rounding quantum) —
    // this is what keeps an added point under the cursor and a drawn node grabbable where drawn.
    const Box box{3, 7, 160, 120};
    const double velQuantum = 127.0 / 160.0;
    const double ampQuantum = 1.0 / 119.0;
    const VelocityPoint pts[] = {{0.0, 1.0}, {127.0, 0.0}, {40.0, 0.25}, {90.5, 0.66}, {63.5, 0.5}};
    for (const VelocityPoint& p : pts) {
        const auto px = uni().pixelFromPoint(box, p);
        const VelocityPoint back = uni().pointFromPixel(box, px.x, px.y);
        CHECK(std::fabs(back.velocity - p.velocity) <= velQuantum);
        CHECK(std::fabs(back.value - p.value) <= ampQuantum);
    }
}

static void testPixelFromPointAgreesWithPointAtPixel() {
    // A node drawn at pixelFromPoint's coordinates must hit-test back to that same node — the
    // draw/grab no-drift contract the two helpers exist to guarantee.
    VelocityCurve c = VelocityCurve::linear();
    const std::size_t idx = c.addPoint(70.0, 0.3);
    const Box box{0, 0, 200, 100};
    const auto px = uni().pixelFromPoint(box, c.points()[idx]);
    CHECK(c.pointAtPixel(box, px.x, px.y) == static_cast<int>(idx));
}

static void testPointFromPixelDegenerateBox() {
    // Zero width -> velocity 0; height <= 1 -> amp 1 (mirrors the forward map's degenerate pins).
    const Box flat{5, 5, 0, 0};
    const VelocityPoint p = uni().pointFromPixel(flat, 50, 50);
    CHECK(near(p.velocity, 0.0) && near(p.value, 1.0));
}

static void testFromPointsRoundTripsAValidCurve() {
    VelocityCurve orig = VelocityCurve::linear();
    orig.addPoint(40.0, 0.2);
    orig.addPoint(90.0, 0.7);
    // fromPoints over its OWN points reproduces it exactly (already valid, sort is stable no-op).
    const VelocityCurve rebuilt = VelocityCurve::fromPoints(orig.points(), CurveDomain::Unipolar);
    CHECK(rebuilt.equals(orig));
}

int main() {
    testFlatIsUnityEverywhere();
    testLinearRamp();
    testEvalBoxClampsOutOfRangeVelocity();
    testEvalMonotonicInX();
    testCollinearControlPointsReproduceExactLinearRamp();
    testNoOvershootWithSharpInteriorDip();
    testSplineStaysSingleValuedMonotoneInEachSegment();
    testSplinePinsEndpointsExactly();
    testAddPointKeepsXOrderAndClamps();
    testMoveInteriorClampsToNeighbours();
    testMoveEndpointsArePinnedInX();
    testMoveOutOfRangeIndexIsNoOp();
    testDeleteRemovesInteriorRefusesEndpoints();
    testPointAtPixelGrabsDrawnNode();
    testResolveDragMovesAndClamps();
    testResolveDragDegenerateBoxNoMotion();
    testFromPointsSortsClampsAndForcesEndpoints();
    testFromPointsSubTwoFallsBackToFlat();
    testZeroIsExactlyZeroAtEveryVelocity();
    testBipolarEvalSpansTheNegativeHalf();
    testUnipolarClampsAtZeroWhereBipolarDoesNot();
    testTheSameKnotsEvaluateIdenticallyInEitherDomain();
    testBipolarPixelMapPutsZeroOnTheCentreLine();
    testBipolarDragCoversTwiceTheValueRange();
    testPixelFromPointMapsCornersAndMidpoint();
    testSubpixelMapIsTheIntegerMapBeforeRounding();
    testSubpixelMapResolvesSlopesTheIntegerMapFlattens();
    testPointFromPixelInvertsAndClamps();
    testPixelMapsRoundTripWithinOnePixelQuantum();
    testPixelFromPointAgreesWithPointAtPixel();
    testPointFromPixelDegenerateBox();
    testFromPointsRoundTripsAValidCurve();

    if (g_fail == 0) std::printf("velocity_curve: all tests passed\n");
    else std::printf("velocity_curve: %d FAILURES\n", g_fail);
    return g_fail == 0 ? 0 : 1;
}
