// 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 #include #include 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& 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 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 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(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; }