// Standalone tests for reasampler::vst::param_slider — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling pure editor tests (capture_browser / keyboard_strip): // assert the S12/S15/S16 control-surface layout, toggle-segment split + hit-test, slider // value<->pixel mapping (round-trip + clamping + endpoints), and point->control routing. // // Covers: layoutControls stacking rows top-down with the label column + control column and the // inter-row gap; an empty list / degenerate panel yielding nothing; toggleSegmentRect splitting // a toggle into two tiling segments (last absorbs the remainder) + toggleSegmentHitTest; // sliderTrackRect insetting a half-handle at each end; sliderHandleRect at value 0/0.5/1 and // out-of-range clamping; valueAtPoint mapping x back to 0..1 (endpoints saturate) as the inverse // of the handle position; controlAtPoint routing a point to the right control id (toggle whole // area vs slider track vs knob circle) and MISSING in the label column, a row gap, and // off-panel. FA4 adds the radial KNOB: computeKnob inscribing the circle in its cell, the // circular hit-test, the arc angle<->value mapping (min at startDeg, max at endDeg, linear // midpoint; default = the 6->4 o'clock 300-degree sweep), the needle endpoint on the circle, // and the vertical-drag delta->value map (up = increase) with clamping at 0/1. #include "../src/vst/param_slider.h" #include #include using namespace reasampler::vst; 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 approx(double a, double b) { return (a - b) < 1e-9 && (b - a) < 1e-9; } // --- layoutControls ----------------------------------------------------------- static void testLayoutStacksRows() { const Rect panel{0, 100, 300, 400}; std::vector ctl{ {1, ControlKind::Toggle}, {2, ControlKind::Slider}, {3, ControlKind::Slider}, }; const std::vector rows = layoutControls(panel, ctl); CHECK(rows.size() == 3); // Row 0 sits at the panel top; each subsequent row is one row-height + gap below. CHECK(rows[0].row.top == 100); CHECK(rows[0].row.bottom == 100 + kControlRowHeight); CHECK(rows[1].row.top == rows[0].row.bottom + kControlRowGap); CHECK(rows[2].row.top == rows[1].row.bottom + kControlRowGap); // Ids + kinds carried through in order. CHECK(rows[0].id == 1 && rows[0].kind == ControlKind::Toggle); CHECK(rows[1].id == 2 && rows[1].kind == ControlKind::Slider); // Label column then control column, contiguous, spanning the panel width. CHECK(rows[0].label.left == panel.left); CHECK(rows[0].control.left == rows[0].label.right); CHECK(rows[0].control.right == panel.right); CHECK(rows[0].label.width() == kControlLabelWidth); } static void testLayoutEmptyAndDegenerate() { CHECK(layoutControls(Rect{0, 0, 300, 300}, {}).empty()); std::vector ctl{{1, ControlKind::Slider}}; CHECK(layoutControls(Rect{0, 0, 0, 0}, ctl).empty()); CHECK(layoutControls(Rect{0, 0, 300, 0}, ctl).empty()); } static void testLayoutNarrowPanelClampsLabel() { // A panel narrower than 2*labelWidth clamps the label column to half so a control column // survives. const Rect panel{0, 0, 100, 200}; const std::vector rows = layoutControls(panel, {{1, ControlKind::Slider}}); CHECK(rows.size() == 1); CHECK(rows[0].label.width() <= panel.width() / 2 + 1); CHECK(rows[0].control.width() > 0); } // --- toggle ------------------------------------------------------------------- static void testToggleSegmentsTile() { const Rect control{100, 0, 300, 22}; // width 200 const Rect s0 = toggleSegmentRect(control, 0); const Rect s1 = toggleSegmentRect(control, 1); CHECK(s0.left == 100 && s0.right == 200); CHECK(s1.left == 200 && s1.right == 300); // last absorbs remainder -> reaches control.right // Out of range. CHECK(toggleSegmentRect(control, 2).width() == 0); CHECK(toggleSegmentRect(control, -1).width() == 0); } static void testToggleSegmentRemainderInLast() { const Rect control{0, 0, 201, 22}; // odd width -> seg0 = 100, seg1 = 101 (absorbs remainder) CHECK(toggleSegmentRect(control, 0).width() == 100); CHECK(toggleSegmentRect(control, 1).right == 201); } static void testToggleHitTest() { const Rect control{100, 0, 300, 22}; CHECK(toggleSegmentHitTest(control, 150, 10) == 0); CHECK(toggleSegmentHitTest(control, 250, 10) == 1); CHECK(toggleSegmentHitTest(control, 50, 10) == -1); // left of control CHECK(toggleSegmentHitTest(control, 150, 40) == -1); // below control } // --- slider ------------------------------------------------------------------- static void testSliderTrackInsetsHalfHandle() { const Rect control{100, 0, 300, 22}; const Rect track = sliderTrackRect(control); CHECK(track.left == control.left + kSliderHandleWidth / 2); CHECK(track.right == control.right - kSliderHandleWidth / 2); // A control too narrow for a handle yields an empty track. CHECK(sliderTrackRect(Rect{0, 0, kSliderHandleWidth - 1, 22}).width() == 0); } static void testSliderHandleAtEndpointsAndMid() { const Rect control{100, 0, 300, 22}; const Rect track = sliderTrackRect(control); const int half = kSliderHandleWidth / 2; // Value 0 -> handle centered at track.left. const Rect h0 = sliderHandleRect(control, 0.0); CHECK(h0.left + half == track.left); // Value 1 -> handle centered at track.right. const Rect h1 = sliderHandleRect(control, 1.0); CHECK(h1.left + half == track.right); // Value 0.5 -> centered at the track middle. const Rect hm = sliderHandleRect(control, 0.5); CHECK(hm.left + half == track.left + track.width() / 2); } static void testSliderHandleClampsOutOfRange() { const Rect control{0, 0, 200, 22}; CHECK(sliderHandleRect(control, -0.5).left == sliderHandleRect(control, 0.0).left); CHECK(sliderHandleRect(control, 5.0).left == sliderHandleRect(control, 1.0).left); } static void testValueAtPointEndpointsSaturate() { const Rect control{100, 0, 300, 22}; const Rect track = sliderTrackRect(control); CHECK(approx(valueAtPoint(control, track.left - 20), 0.0)); CHECK(approx(valueAtPoint(control, track.left), 0.0)); CHECK(approx(valueAtPoint(control, track.right + 20), 1.0)); CHECK(approx(valueAtPoint(control, track.right), 1.0)); } static void testValueAtPointIsHandleInverse() { // Round-trip: a value -> handle center -> valueAtPoint recovers (within one pixel quantum). const Rect control{50, 0, 450, 22}; // wide track for pixel resolution const Rect track = sliderTrackRect(control); for (double v : {0.1, 0.25, 0.5, 0.75, 0.9}) { const Rect h = sliderHandleRect(control, v); const int centerX = h.left + kSliderHandleWidth / 2; const double back = valueAtPoint(control, centerX); CHECK(back >= v - 0.01 && back <= v + 0.01); CHECK(centerX >= track.left && centerX <= track.right); } } static void testValueAtPointDegenerateTrack() { CHECK(approx(valueAtPoint(Rect{0, 0, kSliderHandleWidth - 1, 22}, 5), 0.0)); } // --- knob (FA4) ----------------------------------------------------------------- static bool near(double a, double b, double tol) { return (a - b) < tol && (b - a) < tol; } static void testKnobGeometryInscribesCell() { // A 44x44 cell at (100,0): center (122,22), radius 22. const KnobGeometry g = computeKnob(Rect{100, 0, 144, 44}); CHECK(approx(g.centerX, 122.0)); CHECK(approx(g.centerY, 22.0)); CHECK(approx(g.radius, 22.0)); // A wide cell inscribes on the smaller (vertical) dimension. const KnobGeometry w = computeKnob(Rect{0, 0, 200, 22}); CHECK(approx(w.radius, 11.0)); CHECK(approx(w.centerX, 100.0)); // Degenerate cells yield radius 0. CHECK(computeKnob(Rect{0, 0, 0, 22}).radius == 0.0); CHECK(computeKnob(Rect{0, 0, 22, 0}).radius == 0.0); } static void testKnobHitTestCircle() { const KnobGeometry g = computeKnob(Rect{100, 0, 144, 44}); // center (122,22), r 22 CHECK(knobHitTest(g, 122, 22)); // center CHECK(knobHitTest(g, 122 + 22, 22)); // on the circle boundary (inclusive) CHECK(!knobHitTest(g, 122 + 22, 44)); // cell corner: inside the rect, outside the circle CHECK(!knobHitTest(g, 122, 45)); // just below the circle CHECK(!knobHitTest(KnobGeometry{}, 0, 0)); // degenerate knob hits nothing } static void testKnobDefaultArcIsSixToFourOClock() { const KnobArc arc; // default: 180 (6 o'clock) clockwise to 120 (4 o'clock) CHECK(approx(knobSweepDeg(arc), 300.0)); CHECK(approx(knobValueAngleDeg(arc, 0.0), kKnobArcStartDeg)); // min at 6 o'clock CHECK(approx(knobValueAngleDeg(arc, 1.0), kKnobArcEndDeg)); // max at 4 o'clock // Midpoint: halfway around the clockwise sweep -> 180 + 150 = 330. CHECK(approx(knobValueAngleDeg(arc, 0.5), 330.0)); // Out-of-range values clamp to the arc ends. CHECK(approx(knobValueAngleDeg(arc, -0.5), kKnobArcStartDeg)); CHECK(approx(knobValueAngleDeg(arc, 1.5), kKnobArcEndDeg)); } static void testKnobArcIsParameterized() { // A custom non-wrapping arc: 3 o'clock down to 9 o'clock through 6. const KnobArc arc{90.0, 270.0}; CHECK(approx(knobSweepDeg(arc), 180.0)); CHECK(approx(knobValueAngleDeg(arc, 0.0), 90.0)); CHECK(approx(knobValueAngleDeg(arc, 0.5), 180.0)); CHECK(approx(knobValueAngleDeg(arc, 1.0), 270.0)); // Equal start/end means a full-circle sweep (end at-or-behind start wraps +360). CHECK(approx(knobSweepDeg(KnobArc{0.0, 0.0}), 360.0)); } static void testKnobNeedlePointOnCircle() { const KnobGeometry g = computeKnob(Rect{100, 0, 144, 44}); // center (122,22), r 22 // Default arc, value 0 -> 6 o'clock -> straight DOWN from the center (screen +y). const KnobPoint p6 = knobNeedlePoint(g, KnobArc{}, 0.0); CHECK(near(p6.x, 122.0, 1e-6) && near(p6.y, 44.0, 1e-6)); // A 12 o'clock needle points straight UP; 3 o'clock points RIGHT. const KnobPoint p12 = knobNeedlePoint(g, KnobArc{0.0, 180.0}, 0.0); CHECK(near(p12.x, 122.0, 1e-6) && near(p12.y, 0.0, 1e-6)); const KnobPoint p3 = knobNeedlePoint(g, KnobArc{0.0, 180.0}, 0.5); CHECK(near(p3.x, 144.0, 1e-6) && near(p3.y, 22.0, 1e-6)); // Every needle endpoint sits ON the circle. for (double v : {0.0, 0.25, 0.5, 0.75, 1.0}) { const KnobPoint p = knobNeedlePoint(g, KnobArc{}, v); const double dx = p.x - g.centerX, dy = p.y - g.centerY; CHECK(near(dx * dx + dy * dy, g.radius * g.radius, 1e-6)); } } static void testKnobDragUpIncreases() { // Up (negative dy) increases, down decreases, scaled by the drag range. CHECK(approx(knobDragValue(0.5, -32, 128), 0.75)); CHECK(approx(knobDragValue(0.5, +32, 128), 0.25)); // A full-range upward drag from 0 lands exactly at 1. CHECK(approx(knobDragValue(0.0, -128, 128), 1.0)); // Default sensitivity applies when the range is omitted. CHECK(approx(knobDragValue(0.0, -kKnobDragRangePixels), 1.0)); } static void testKnobDragClamps() { CHECK(approx(knobDragValue(0.9, -64, 128), 1.0)); // over-drag up clamps at 1 CHECK(approx(knobDragValue(0.1, +64, 128), 0.0)); // over-drag down clamps at 0 // The start value itself is clamped before the delta applies. CHECK(approx(knobDragValue(1.5, 0, 128), 1.0)); CHECK(approx(knobDragValue(-0.5, 0, 128), 0.0)); // A degenerate drag range yields the clamped start value. CHECK(approx(knobDragValue(0.7, -50, 0), 0.7)); } // --- controlAtPoint routing --------------------------------------------------- static void testControlAtPointRoutes() { const Rect panel{0, 0, 300, 400}; std::vector ctl{ {10, ControlKind::Toggle}, {20, ControlKind::Slider}, }; ctl.push_back({30, ControlKind::Knob}); const std::vector rows = layoutControls(panel, ctl); // A point in the toggle's control area routes to the toggle id. const Rect tctl = rows[0].control; CHECK(controlAtPoint(rows, (tctl.left + tctl.right) / 2, (tctl.top + tctl.bottom) / 2) == 10); // A point on the slider's track routes to the slider id. const Rect strack = sliderTrackRect(rows[1].control); CHECK(controlAtPoint(rows, (strack.left + strack.right) / 2, (strack.top + strack.bottom) / 2) == 20); // A point at the knob's center routes to the knob id; the control-rect corner (outside // the circle) is a miss. const KnobGeometry kg = computeKnob(rows[2].control); CHECK(controlAtPoint(rows, static_cast(kg.centerX), static_cast(kg.centerY)) == 30); CHECK(controlAtPoint(rows, rows[2].control.left + 1, rows[2].control.top + 1) == -1); } static void testControlAtPointMisses() { const Rect panel{0, 0, 300, 400}; const std::vector rows = layoutControls(panel, {{10, ControlKind::Toggle}, {20, ControlKind::Slider}}); // The label column is not interactive. CHECK(controlAtPoint(rows, rows[0].label.left + 2, rows[0].label.top + 4) == -1); // The gap between rows is a miss. const int gapY = rows[0].row.bottom + kControlRowGap / 2; CHECK(controlAtPoint(rows, 200, gapY) == -1); // Off-panel below. CHECK(controlAtPoint(rows, 200, 5000) == -1); } int main() { testLayoutStacksRows(); testLayoutEmptyAndDegenerate(); testLayoutNarrowPanelClampsLabel(); testToggleSegmentsTile(); testToggleSegmentRemainderInLast(); testToggleHitTest(); testSliderTrackInsetsHalfHandle(); testSliderHandleAtEndpointsAndMid(); testSliderHandleClampsOutOfRange(); testValueAtPointEndpointsSaturate(); testValueAtPointIsHandleInverse(); testValueAtPointDegenerateTrack(); testKnobGeometryInscribesCell(); testKnobHitTestCircle(); testKnobDefaultArcIsSixToFourOClock(); testKnobArcIsParameterized(); testKnobNeedlePointOnCircle(); testKnobDragUpIncreases(); testKnobDragClamps(); testControlAtPointRoutes(); testControlAtPointMisses(); if (g_fail == 0) std::printf("param_slider: all tests passed\n"); return g_fail != 0; }