// Standalone tests for reasampler::instrument::ui::envelope_edit — no VST3, no REAPER, no
// framework. Same fast assert loop as the sibling pure tests. Assert the INVERSE (edit) map
// against envelope_overlay's forward map: a grab lands on the node that was drawn there, and a
// pixel delta produces exactly the param a knob would have.
//
// Covers: nodeAtPoint (every drawn handle grabbable, the anchored ReleaseEnd and the Origin
// never grabbed, other-kind nodes rejected, misses outside the radius, a dead coincident AHD
// DecayEnd excluded while a functional one stays grabbable); resolveNodeDrag (AHDSR stage nodes
// tracking the cursor across the TAPERED schematic and being its exact inverse, the sustain level
// on Y, the release dragged from its START with the inverted sign, the caller's clamp domain, AHD
// stage times at the 1:1 scale, the hold FRACTION); curve-knot drags (the exponent domain, its
// endpoints, and the round trip through the shared law that keeps knot and dial on one value);
// the interaction law (Ctrl's rate on every axis, Shift's per-category snap); degenerate no-ops.

#include "../src/core/instrument/ui/envelope_edit.h"

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

using namespace reasampler;
using namespace reasampler::instrument::ui;

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 OverlayArea overlayOf(const Rect& r) { return OverlayArea{r}; }
static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 110); }  // width 1000, height 100
static constexpr double kTotal = 4.0;

// The shell's own domain (editor_controls' envClampBounds), so a drag here is clamped exactly
// where a knob is.
static EnvClampBounds bounds() {
    EnvClampBounds b;
    b.maxAttackSeconds = kGateStageMaxSeconds;
    b.maxHoldSeconds = kGateStageMaxSeconds;
    b.maxDecaySeconds = kGateStageMaxSeconds;
    b.maxReleaseSeconds = kGateStageMaxSeconds;
    return b;
}

static StageEnvelope ahdsrEnv() {
    StageEnvelope e;
    e.kind = EnvKind::Ahdsr;
    e.attackSeconds = 0.3;
    e.holdSeconds = 0.2;
    e.decaySeconds = 0.4;
    e.sustainLevel = 0.6;
    e.releaseSeconds = 0.5;
    return e;
}

// F1's coincidence cases need attack/decay/fraction/span combinations ahdEnv() doesn't cover.
static StageEnvelope ahd(double a, double d, double frac, double origin, double span) {
    StageEnvelope e;
    e.kind = EnvKind::Ahd;
    e.attackSeconds = a;
    e.decaySeconds = d;
    e.holdFraction = frac;
    e.originSeconds = origin;
    e.spanSeconds = span;
    return e;
}

static StageEnvelope ahdEnv() {
    StageEnvelope e;
    e.kind = EnvKind::Ahd;
    e.attackSeconds = 0.4;
    e.decaySeconds = 0.6;
    e.holdFraction = 0.5;
    e.originSeconds = 0.0;
    e.spanSeconds = 3.0;
    return e;
}

static bool findNode(const std::vector<EnvVertex>& poly, EnvNode node, EnvVertex& out) {
    for (const EnvVertex& v : poly) {
        if (v.node == node) { out = v; return true; }
    }
    return false;
}

// Grab exactly where the forward map drew the node.
static NodeHit grabAt(const StageEnvelope& e, EnvNode node) {
    const Rect a = wideArea();
    EnvVertex v;
    if (!findNode(buildEnvelopePolyline(e, overlayOf(a), kTotal), node, v)) return NodeHit{};
    return nodeAtPoint(e, overlayOf(a), kTotal, v.x, v.y);
}

// --- hit-test ------------------------------------------------------------------

static void testEveryDrawnHandleIsGrabbable() {
    const StageEnvelope e = ahdsrEnv();
    const EnvNode want[] = {EnvNode::AttackEnd,    EnvNode::HoldEnd,     EnvNode::DecayEnd,
                            EnvNode::ReleaseStart, EnvNode::AttackCurve, EnvNode::DecayCurve,
                            EnvNode::ReleaseCurve};
    for (EnvNode n : want) {
        const NodeHit h = grabAt(e, n);
        CHECK(h.hit);
        CHECK(h.node == n);
    }
}

static void testAnchoredEndAndOriginAreNotGrabbable() {
    const StageEnvelope e = ahdsrEnv();
    const Rect a = wideArea();
    const std::vector<EnvVertex> poly = buildEnvelopePolyline(e, overlayOf(a), kTotal);
    EnvVertex end;
    CHECK(findNode(poly, EnvNode::ReleaseEnd, end));
    // The bottom-right corner is fixed: a grab there either misses or resolves to a NEIGHBOUR,
    // never to ReleaseEnd itself.
    const NodeHit h = nodeAtPoint(e, overlayOf(a), kTotal, end.x, end.y);
    CHECK(!h.hit || h.node != EnvNode::ReleaseEnd);
    EnvVertex origin;
    CHECK(findNode(poly, EnvNode::Origin, origin));
    const NodeHit o = nodeAtPoint(e, overlayOf(a), kTotal, origin.x, origin.y);
    CHECK(!o.hit || o.node != EnvNode::Origin);
}

static void testAhdHasNoSustainNodes() {
    const StageEnvelope e = ahdEnv();
    CHECK(grabAt(e, EnvNode::AttackEnd).hit);
    CHECK(grabAt(e, EnvNode::HoldEnd).hit);
    CHECK(grabAt(e, EnvNode::DecayEnd).hit);
    // ReleaseStart is not drawn on an AHD at all, so there is nothing to grab.
    CHECK(!grabAt(e, EnvNode::ReleaseStart).hit);
    // And an explicit resolve of an other-kind node is a no-op rather than a stray write.
    const StageEnvelope out = resolveNodeDrag(e, EnvNode::ReleaseStart, overlayOf(wideArea()),
                                              kTotal, bounds(), 40, 0);
    CHECK(out.releaseSeconds == e.releaseSeconds);
    CHECK(out.attackSeconds == e.attackSeconds);
}

// F1: at the Trigger default (decay 0, holdFraction 1.0) DecayEnd sits on HoldEnd's own instant
// AND cannot move there (resolveNodeDrag's decay branch has derivative 0 — see the denom guard).
// A grab at its true (now un-nudged) position must miss rather than resolve to a dead handle;
// HoldEnd, the live node underneath, stays fully grabbable.
static void testDeadCoincidentDecayEndIsNotGrabbable() {
    const StageEnvelope e = ahd(0.5, 0.0, 1.0, 0.0, 3.0);
    CHECK(!grabAt(e, EnvNode::DecayEnd).hit);
    CHECK(grabAt(e, EnvNode::HoldEnd).hit);
    CHECK(grabAt(e, EnvNode::HoldEnd).node == EnvNode::HoldEnd);
}

// Coincidence alone does not drop DecayEnd — only holdFraction == 1.0 makes it truly dead. With
// holdFraction < 1 the X-drag still moves decaySeconds (denom > 0), so it stays grabbable even
// when decay is 0 and it starts out coincident with HoldEnd.
static void testFunctionalCoincidentDecayEndStaysGrabbable() {
    const StageEnvelope e = ahd(0.5, 0.0, 0.5, 0.0, 3.0);
    CHECK(grabAt(e, EnvNode::DecayEnd).hit);
    CHECK(grabAt(e, EnvNode::DecayEnd).node == EnvNode::DecayEnd);
}

static void testMissOutsideTheRadius() {
    const StageEnvelope e = ahdsrEnv();
    const Rect a = wideArea();
    // Far from every handle in both axes.
    const NodeHit h = nodeAtPoint(e, overlayOf(a), kTotal, a.x + 3, a.bottom() - 40);
    CHECK(!h.hit);
}

// --- AHDSR drags ---------------------------------------------------------------

// The x position of node `n` as the FORWARD map draws it — the only thing a tapered-axis drag can
// be measured against, since there is no longer a fixed seconds-per-pixel rate to restate.
static int drawnX(const StageEnvelope& e, EnvNode n) {
    EnvVertex v;
    return findNode(buildEnvelopePolyline(e, overlayOf(wideArea()), kTotal), n, v) ? v.x : -1;
}

// The schematic axis IS the knob's taper, so what a stage node tracks is the CURSOR — at both
// ends of the range, which a fixed-rate inverse could not manage once the axis stopped being
// linear in seconds. Swept across four decades of stage time for exactly that reason.
static void testAhdsrStageNodesTrackTheCursorAcrossTheWholeRange() {
    const Rect a = wideArea();
    const double startTimes[] = {0.0, 0.003, 0.25, 2.0};
    for (double t : startTimes) {
        StageEnvelope e = ahdsrEnv();
        e.attackSeconds = t;
        const StageEnvelope moved =
            resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 40, 0);
        CHECK(std::abs((drawnX(moved, EnvNode::AttackEnd) - drawnX(e, EnvNode::AttackEnd)) - 40)
              <= 1);
        CHECK(moved.attackSeconds > t);
        CHECK(moved.holdSeconds == e.holdSeconds);  // only the dragged param moves
    }
    // Hold and decay ride the same axis, in both directions.
    const StageEnvelope e = ahdsrEnv();
    const StageEnvelope hold =
        resolveNodeDrag(e, EnvNode::HoldEnd, overlayOf(a), kTotal, bounds(), -20, 0);
    CHECK(std::abs((drawnX(hold, EnvNode::HoldEnd) - drawnX(e, EnvNode::HoldEnd)) + 20) <= 1);
    CHECK(hold.holdSeconds < e.holdSeconds);
    const StageEnvelope decay =
        resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 30, 0);
    CHECK(std::abs((drawnX(decay, EnvNode::DecayEnd) - drawnX(e, EnvNode::DecayEnd)) - 30) <= 1);
    CHECK(decay.decaySeconds > e.decaySeconds);
}

// The one-model rule, at the tapered axis: a node dragged to a pixel and the knob's value at that
// pixel are ONE number, so the inverse has to be EXACT and not merely close. A zero-delta drag
// reproduces the grab value bit for bit, and a drag out and straight back lands where it started.
static void testDrawAndDragAreExactInverses() {
    const Rect a = wideArea();
    // Four decades of stage time, stopping short of the clamp: a drag that saturates at the
    // domain end deliberately does NOT come back (testStageTimesClampToTheKnobDomain owns that).
    const double startTimes[] = {0.0, 0.003, 0.060, 1.0};
    for (double t : startTimes) {
        StageEnvelope e = ahdsrEnv();
        e.attackSeconds = t;
        CHECK(resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 0, 0)
                  .attackSeconds == t);
        const StageEnvelope out =
            resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 30, 0);
        const StageEnvelope back =
            resolveNodeDrag(out, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), -30, 0);
        // The DRAWN node returns to the exact pixel it left, which is the property the one-model
        // rule actually needs; the underlying seconds return to within the taper's own quantum
        // read back through the map, which is proportional to the value.
        CHECK(drawnX(back, EnvNode::AttackEnd) == drawnX(e, EnvNode::AttackEnd));
        CHECK(std::fabs(back.attackSeconds - t) < 1e-6 * (t + 0.01));
    }
}

// The release is dragged from its TOP node and its end is anchored to the canvas edge, so
// pulling that node LEFT lengthens the release — the sign is inverted relative to every other
// stage.
static void testReleaseDragsFromItsStartWithInvertedSign() {
    const Rect a = wideArea();
    const StageEnvelope e = ahdsrEnv();
    const StageEnvelope longer =
        resolveNodeDrag(e, EnvNode::ReleaseStart, overlayOf(a), kTotal, bounds(), -40, 0);
    CHECK(longer.releaseSeconds > e.releaseSeconds);
    const StageEnvelope shorter =
        resolveNodeDrag(e, EnvNode::ReleaseStart, overlayOf(a), kTotal, bounds(), 40, 0);
    CHECK(shorter.releaseSeconds < e.releaseSeconds);
    // The node still tracks the cursor, inverted sign notwithstanding.
    CHECK(std::abs((drawnX(longer, EnvNode::ReleaseStart) -
                    drawnX(e, EnvNode::ReleaseStart)) + 40) <= 1);
}

static void testSustainLevelOnTheDecayNodesYAxis() {
    const Rect a = wideArea();
    const StageEnvelope e = ahdsrEnv();
    const double lvlPerPx = 1.0 / (a.height - 1);
    const StageEnvelope up =
        resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, -10);
    CHECK(std::fabs(up.sustainLevel - (e.sustainLevel + 10 * lvlPerPx)) < 1e-9);
    // Clamped to [0,1] at both ends.
    CHECK(resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, -10000)
              .sustainLevel == 1.0);
    CHECK(resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, 10000)
              .sustainLevel == 0.0);
}

static void testStageTimesClampToTheKnobDomain() {
    const Rect a = wideArea();
    const StageEnvelope e = ahdsrEnv();
    CHECK(resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 100000, 0)
              .attackSeconds == bounds().maxAttackSeconds);
    CHECK(resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), -100000, 0)
              .attackSeconds == 0.0);
}

// --- AHD drags -----------------------------------------------------------------

static void testAhdStageTimesTrackTheWallClockScale() {
    const Rect a = wideArea();
    const StageEnvelope e = ahdEnv();
    const double secPerPx = kTotal / a.width;
    const StageEnvelope attack =
        resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 100, 0);
    CHECK(std::fabs(attack.attackSeconds - (e.attackSeconds + 100 * secPerPx)) < 1e-9);

    // DecayEnd's underlying param (decaySeconds) does NOT move 1:1 with the cursor: the drawn
    // endpoint is t0 + total, and Hold eats a holdFraction share of whatever decay gives up
    // (d(total)/d(decay) = 1 - holdFraction), so decaySeconds itself has to move faster than
    // the cursor to make the DRAWN node track it. Assert on the RENDERED position, not the
    // raw param — that is the property a drag actually has to deliver, and asserting the old
    // 1:1 param delta here is exactly what let the node-tracking defect through undetected.
    EnvVertex before;
    CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), kTotal), EnvNode::DecayEnd, before));
    const StageEnvelope decay =
        resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 100, 0);
    EnvVertex after;
    CHECK(findNode(buildEnvelopePolyline(decay, overlayOf(a), kTotal), EnvNode::DecayEnd, after));
    CHECK(std::abs((after.x - before.x) - 100) <= 1);  // 1:1 with the cursor, to rounding
}

// Hold is a fraction of what attack and decay left, so the node's pixel motion converts through
// that remainder — and the fraction can never leave [0,1], which is what keeps the sum bounded.
static void testAhdHoldNodeEditsTheFraction() {
    const Rect a = wideArea();
    const StageEnvelope e = ahdEnv();
    const double secPerPx = kTotal / a.width;
    const AhdSplit s = splitAhdSeconds(e);
    const double rem = e.spanSeconds - s.attack - s.decay;
    const StageEnvelope moved =
        resolveNodeDrag(e, EnvNode::HoldEnd, overlayOf(a), kTotal, bounds(), 100, 0);
    CHECK(std::fabs(moved.holdFraction - ((s.hold + 100 * secPerPx) / rem)) < 1e-9);
    CHECK(resolveNodeDrag(e, EnvNode::HoldEnd, overlayOf(a), kTotal, bounds(), 100000, 0)
              .holdFraction == 1.0);
    CHECK(resolveNodeDrag(e, EnvNode::HoldEnd, overlayOf(a), kTotal, bounds(), -100000, 0)
              .holdFraction == 0.0);
}

// --- curve knots ---------------------------------------------------------------

static void testKnotDragMovesTheExponentWithinItsDomain() {
    const Rect a = wideArea();
    StageEnvelope e = ahdsrEnv();
    e.attackCurve = util::kCurveNeutral;
    const StageEnvelope up =
        resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, -12);
    const StageEnvelope down =
        resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 12);
    // Dragging the attack knot UP (toward the ceiling) is a faster-rising, SMALLER exponent.
    CHECK(up.attackCurve < util::kCurveNeutral);
    CHECK(down.attackCurve > util::kCurveNeutral);
    CHECK(up.attackCurve >= util::kCurveMin && up.attackCurve <= util::kCurveMax);
    CHECK(down.attackCurve >= util::kCurveMin && down.attackCurve <= util::kCurveMax);
    // Extreme drags saturate at the domain endpoints rather than escaping them.
    CHECK(resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, -100000)
              .attackCurve == util::kCurveMin);
    CHECK(resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 100000)
              .attackCurve == util::kCurveMax);
    // Only the dragged segment's exponent moves.
    CHECK(up.decayCurve == e.decayCurve && up.releaseCurve == e.releaseCurve);
    CHECK(up.attackSeconds == e.attackSeconds);
}

// The one-model rule, asserted structurally: the drawn knot's height IS the shared law's
// reading of the stored exponent, and a zero-delta drag from that grab reproduces the exponent
// exactly — so the overlay and the inner dial cannot express different values for one field.
static void testKnotAndModelCannotDiverge() {
    const Rect a = wideArea();
    for (double exp : {0.2, 0.5, 1.0, 2.0, 7.0}) {
        StageEnvelope e = ahdsrEnv();
        e.attackCurve = exp;
        EnvVertex knot;
        CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), kTotal), EnvNode::AttackCurve,
                       knot));
        CHECK(std::fabs(knot.level - util::curveMidLevel(exp)) < 1e-12);
        const StageEnvelope same =
            resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 0);
        CHECK(std::fabs(same.attackCurve - exp) < 1e-9);
    }
}

// A decay into a sustain of exactly 1.0 is a LEVEL segment: there is no curve to express, so
// the drag must leave the exponent alone rather than divide by a zero level span.
static void testKnotOnALevelSegmentIsANoOp() {
    const Rect a = wideArea();
    StageEnvelope e = ahdsrEnv();
    e.sustainLevel = 1.0;
    e.decayCurve = 2.5;
    const StageEnvelope out =
        resolveNodeDrag(e, EnvNode::DecayCurve, overlayOf(a), kTotal, bounds(), 0, -30);
    CHECK(out.decayCurve == 2.5);
}

// A NEAR-level segment (sustain 0.99) is not caught by the exact-equality guard above, but its
// tiny divisor turns a one-pixel drag into a saturating swing of the exponent — the drag must
// still be a no-op rather than slam to a domain endpoint.
static void testKnotOnANearLevelSegmentIsANoOp() {
    const Rect a = wideArea();
    StageEnvelope e = ahdsrEnv();
    e.sustainLevel = 0.99;
    e.decayCurve = 2.5;
    const StageEnvelope out =
        resolveNodeDrag(e, EnvNode::DecayCurve, overlayOf(a), kTotal, bounds(), 0, -1);
    CHECK(out.decayCurve == 2.5);
}

// The knot drag must read the SAME phi the draw used even off the segment midpoint (an odd
// pixel span), not the fixed phi = 0.5 wideArea()'s AttackCurve span happens to land on above.
// Checked two ways: a zero-delta grab reproduces the stored exponent, and a real one-pixel drag
// moves the knot's own drawn y by the same one pixel every other node axis tracks 1:1.
static void testKnotDragTracksTheDrawOnAnOddPixelSpan() {
    bool found = false;
    for (int width = 24; width <= 260 && !found; ++width) {
        const Rect a = Rect::ltrb(0, 0, width, 100);
        StageEnvelope e = ahdsrEnv();
        e.attackCurve = 3.0;
        EnvVertex origin, attackEnd, knot;
        const std::vector<EnvVertex> poly = buildEnvelopePolyline(e, overlayOf(a), kTotal);
        if (!findNode(poly, EnvNode::Origin, origin)) continue;
        if (!findNode(poly, EnvNode::AttackEnd, attackEnd)) continue;
        if (!findNode(poly, EnvNode::AttackCurve, knot)) continue;
        const int span = attackEnd.x - origin.x;
        if (span <= 0 || span % 2 == 0) continue;
        found = true;

        const StageEnvelope same =
            resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 0);
        CHECK(std::fabs(same.attackCurve - e.attackCurve) < 1e-9);

        const StageEnvelope dragged =
            resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 1);
        EnvVertex knotAfter;
        CHECK(findNode(buildEnvelopePolyline(dragged, overlayOf(a), kTotal), EnvNode::AttackCurve,
                       knotAfter));
        CHECK(knotAfter.x == knot.x);  // a curve drag never moves the knot's x
        CHECK(std::abs(knotAfter.y - (knot.y + 1)) <= 1);
    }
    CHECK(found);  // the sweep must actually land on an odd span
}

// --- the interaction law on the overlay ----------------------------------------

// Ctrl scales the PIXEL delta, so it composes with every axis — the tapered schematic, the 1:1
// wall clock, the level and the exponent — instead of each getting its own rule.
static void testCtrlScalesEveryAxisOfANodeDrag() {
    const Rect a = wideArea();
    const StageEnvelope e = ahdsrEnv();
    const DragModifiers fine{false, true};
    const int coarse = 10;
    const int equivalent = static_cast<int>(coarse / kFineDragScale);  // 200 fine px == 10 coarse
    CHECK(std::fabs(
              resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), equivalent,
                              0, fine).attackSeconds -
              resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), coarse, 0)
                  .attackSeconds) < 1e-9);
    CHECK(std::fabs(
              resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0,
                              equivalent, fine).sustainLevel -
              resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, coarse)
                  .sustainLevel) < 1e-9);
    // A zero delta is identical under either rate — the state the shell's re-anchor establishes
    // at every modifier transition, and why the value cannot jump across one.
    CHECK(resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 0, 0, fine)
              .attackSeconds == e.attackSeconds);
}

// Shift reaches the overlay because node, knot and knob are surfaces onto ONE model: a snap
// available on the knob and not on the node would be exactly the divergence that rule forbids.
// Each axis is asserted against the snap of ITS OWN category applied to the free drag's result —
// a node that routed a level through the millisecond snap, or snapped before the axis map rather
// than after it, fails here. The snaps themselves are param_taper's own tests.
static void testShiftSnapsEachAxisToItsOwnWholeUnit() {
    const Rect a = wideArea();
    const StageEnvelope e = ahdsrEnv();
    const DragModifiers shift{true, false};

    const StageEnvelope freeMs =
        resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 37, 0);
    const StageEnvelope snapMs =
        resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 37, 0, shift);
    CHECK(snapMs.attackSeconds == snapSecondsToWholeMs(freeMs.attackSeconds));
    CHECK(snapMs.attackSeconds != freeMs.attackSeconds);  // the drag really did move to the grid
    CHECK(std::fabs(snapMs.attackSeconds - freeMs.attackSeconds) <= 0.0005 + 1e-12);

    const StageEnvelope freeLevel =
        resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, -13);
    const StageEnvelope snapLevel =
        resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, -13, shift);
    CHECK(snapLevel.sustainLevel == snapFractionToWholePercent(freeLevel.sustainLevel));
    CHECK(std::fabs(snapLevel.sustainLevel - freeLevel.sustainLevel) <= 0.005 + 1e-12);

    const StageEnvelope freeKnot =
        resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 9);
    const StageEnvelope snapKnot =
        resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 9, shift);
    CHECK(snapKnot.attackCurve == snapExponentToWhole(freeKnot.attackCurve));
    CHECK(snapKnot.attackCurve != freeKnot.attackCurve);

    // An AHD's Hold node edits a FRACTION, so its whole unit is a percent, not a millisecond.
    const StageEnvelope freeFrac =
        resolveNodeDrag(ahdEnv(), EnvNode::HoldEnd, overlayOf(a), kTotal, bounds(), 37, 0);
    const StageEnvelope snapFrac = resolveNodeDrag(ahdEnv(), EnvNode::HoldEnd, overlayOf(a),
                                                   kTotal, bounds(), 37, 0, shift);
    CHECK(snapFrac.holdFraction == snapFractionToWholePercent(freeFrac.holdFraction));
    CHECK(snapFrac.holdFraction != freeFrac.holdFraction);
}

// --- degenerate ----------------------------------------------------------------

static void testDegenerateInputsAreNoOps() {
    const StageEnvelope e = ahdsrEnv();
    const StageEnvelope zeroArea =
        resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(Rect{}), kTotal, bounds(), 50, 0);
    CHECK(zeroArea.attackSeconds == e.attackSeconds);
    const StageEnvelope zeroDur =
        resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(wideArea()), 0.0, bounds(), 50, 0);
    CHECK(zeroDur.attackSeconds == e.attackSeconds);
}

int main() {
    testEveryDrawnHandleIsGrabbable();
    testAnchoredEndAndOriginAreNotGrabbable();
    testAhdHasNoSustainNodes();
    testDeadCoincidentDecayEndIsNotGrabbable();
    testFunctionalCoincidentDecayEndStaysGrabbable();
    testMissOutsideTheRadius();

    testAhdsrStageNodesTrackTheCursorAcrossTheWholeRange();
    testDrawAndDragAreExactInverses();
    testReleaseDragsFromItsStartWithInvertedSign();
    testSustainLevelOnTheDecayNodesYAxis();
    testStageTimesClampToTheKnobDomain();

    testAhdStageTimesTrackTheWallClockScale();
    testAhdHoldNodeEditsTheFraction();

    testCtrlScalesEveryAxisOfANodeDrag();
    testShiftSnapsEachAxisToItsOwnWholeUnit();

    testKnotDragMovesTheExponentWithinItsDomain();
    testKnotAndModelCannotDiverge();
    testKnotOnALevelSegmentIsANoOp();
    testKnotOnANearLevelSegmentIsANoOp();
    testKnotDragTracksTheDrawOnAnOddPixelSpan();

    testDegenerateInputsAreNoOps();

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