aedcc6976c
Extracts resolveWaveformClaim (core/instrument/ui/spline_edit) so the shell's node/tab/marker click resolution is unit-tested directly, not just its input geometry; folds the staged-envelope node into it; fixes comment accuracy, a cost regression, and test fidelity issues.
280 lines
14 KiB
C++
280 lines
14 KiB
C++
// Standalone tests for reasampler::instrument::ui::spline_edit — no VST3, no REAPER, no
|
|
// framework. Asserts the ONE point-editing grammar both spline consumers route through:
|
|
// left-click grabs a node and adds in empty space, right-click deletes, control-click toggles
|
|
// hard/smooth, and a click outside the mapping box resolves to nothing unless it lands on a
|
|
// node's pick radius (so the popup's inset ring can grab but never add). Also
|
|
// resolveWaveformClaim, the waveform overlay's node/tab/marker cross-affordance arbitration —
|
|
// waveform_view is a test-only link so those tests can build the real geometry
|
|
// editor_input_waveform.cpp's mouseDownWaveform composes.
|
|
|
|
#include "../src/core/instrument/ui/spline_edit.h"
|
|
#include "../src/core/instrument/ui/waveform_view.h"
|
|
#include "../src/core/instrument/ui/sample_bands.h" // kWaveformMinHeight
|
|
|
|
#include <cstdio>
|
|
|
|
using namespace reasampler::instrument::ui;
|
|
using namespace reasampler::instrument::engine;
|
|
|
|
static OverlayArea overlayOf(const Rect& r) { return OverlayArea{r}; }
|
|
|
|
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 const VelocityCurve::Box kBox{100, 50, 127, 101};
|
|
|
|
// A three-point curve whose interior node sits well away from both endpoints.
|
|
static VelocityCurve threePoint() {
|
|
VelocityCurve c = VelocityCurve::rampDown();
|
|
c.addPoint(64.0, 0.5);
|
|
return c;
|
|
}
|
|
|
|
static void testLeftClickOnANodeGrabsIt() {
|
|
const VelocityCurve c = threePoint();
|
|
const auto px = c.pixelFromPoint(kBox, c.points()[1]);
|
|
const SplineEdit e = resolveSplineEdit(c, kBox, SplineGesture::kLeft, px.x, px.y);
|
|
CHECK(e.kind == SplineEditKind::kGrab);
|
|
CHECK(e.index == 1);
|
|
}
|
|
|
|
static void testLeftClickInEmptySpaceAdds() {
|
|
const VelocityCurve c = VelocityCurve::rampDown();
|
|
// Well away from either endpoint's drawn position and from the traced line's nodes.
|
|
const SplineEdit e = resolveSplineEdit(c, kBox, SplineGesture::kLeft, 160, 60);
|
|
CHECK(e.kind == SplineEditKind::kAdd);
|
|
CHECK(e.index == -1); // no point yet — the caller creates it
|
|
}
|
|
|
|
static void testRightClickOnANodeDeletesAndElsewhereDoesNothing() {
|
|
const VelocityCurve c = threePoint();
|
|
const auto px = c.pixelFromPoint(kBox, c.points()[1]);
|
|
const SplineEdit hit = resolveSplineEdit(c, kBox, SplineGesture::kRight, px.x, px.y);
|
|
CHECK(hit.kind == SplineEditKind::kDelete);
|
|
CHECK(hit.index == 1);
|
|
// Right-click on empty canvas must NOT add — delete is the only thing the gesture means.
|
|
const SplineEdit miss = resolveSplineEdit(c, kBox, SplineGesture::kRight, 160, 60);
|
|
CHECK(miss.kind == SplineEditKind::kNone);
|
|
}
|
|
|
|
static void testControlClickOnANodeTogglesHardAndElsewhereDoesNothing() {
|
|
const VelocityCurve c = threePoint();
|
|
const auto px = c.pixelFromPoint(kBox, c.points()[1]);
|
|
const SplineEdit hit = resolveSplineEdit(c, kBox, SplineGesture::kControlLeft, px.x, px.y);
|
|
CHECK(hit.kind == SplineEditKind::kToggleHard);
|
|
CHECK(hit.index == 1);
|
|
const SplineEdit miss = resolveSplineEdit(c, kBox, SplineGesture::kControlLeft, 160, 60);
|
|
CHECK(miss.kind == SplineEditKind::kNone);
|
|
}
|
|
|
|
// The popup draws its box inset inside a border; a click in that ring must be able to GRAB an
|
|
// endpoint handle (which is drawn on the box edge, within the pick radius of the ring) but must
|
|
// never ADD — an added point there clamps onto an endpoint's x and stacks an undeletable
|
|
// duplicate.
|
|
static void testOutsideTheBoxGrabsButNeverAdds() {
|
|
const VelocityCurve c = VelocityCurve::rampDown();
|
|
const auto first = c.pixelFromPoint(kBox, c.points()[0]);
|
|
const SplineEdit ring =
|
|
resolveSplineEdit(c, kBox, SplineGesture::kLeft, first.x - 3, first.y - 3);
|
|
CHECK(ring.kind == SplineEditKind::kGrab);
|
|
CHECK(ring.index == 0);
|
|
// Far outside, on no node at all.
|
|
const SplineEdit away = resolveSplineEdit(c, kBox, SplineGesture::kLeft, kBox.left - 60,
|
|
kBox.top - 40);
|
|
CHECK(away.kind == SplineEditKind::kNone);
|
|
}
|
|
|
|
static void testDegenerateBoxResolvesToNothing() {
|
|
const VelocityCurve c = threePoint();
|
|
CHECK(resolveSplineEdit(c, VelocityCurve::Box{0, 0, 0, 40}, SplineGesture::kLeft, 0, 0)
|
|
.kind == SplineEditKind::kNone);
|
|
CHECK(resolveSplineEdit(c, VelocityCurve::Box{0, 0, 40, 1}, SplineGesture::kLeft, 0, 0)
|
|
.kind == SplineEditKind::kNone);
|
|
}
|
|
|
|
// The overlay's box is the FULL area — no inset — so the contour spans the sample's whole
|
|
// drawn width 1:1 with its time axis.
|
|
static void testOverlayBoxIsTheWholeArea() {
|
|
const OverlayArea area{Rect::ltrb(12, 40, 812, 240)};
|
|
const VelocityCurve::Box box = splineOverlayBox(area);
|
|
CHECK(box.left == 12);
|
|
CHECK(box.top == 40);
|
|
CHECK(box.width == 800);
|
|
CHECK(box.height == 200);
|
|
}
|
|
|
|
// --- Smallest-target-first: resolveWaveformClaim, the shell's own comparison chain -----
|
|
//
|
|
// editor_input_waveform.cpp's mouseDownWaveform resolves a click among a contour node (a fixed
|
|
// pick box), the crossfade tab, and a marker's full-height column by calling
|
|
// resolveWaveformClaim with each candidate's own target area; the smallest hit wins. These
|
|
// tests build the real geometry over the pure primitives the shell composes, then feed it into
|
|
// resolveWaveformClaim itself, so a reverted node-first/marker-first/tab-first ordering fails
|
|
// them — pinning the mechanism, not just the input geometry it acts on. A realistic band height
|
|
// (kWaveformMinHeight, the product's own floor) is used throughout so these numbers are the
|
|
// worst case for the node, not a favourable one.
|
|
static VelocityCurve::Box boxOf(const Rect& r) { return VelocityCurve::Box{r.x, r.y, r.width, r.height}; }
|
|
|
|
constexpr std::int64_t kNodeSide = 2 * kCurveNodeGrabRadius + 1;
|
|
constexpr std::int64_t kNodeArea = kNodeSide * kNodeSide; // 169, fixed
|
|
|
|
// Case (a): a fresh Spline default (rampDown) puts its endpoint 0 at (box.left, box.top) — the
|
|
// exact pixel the start marker draws at frame 0. The node's fixed 169px pick box is far smaller
|
|
// than a kWaveformMinHeight-tall marker column, so the endpoint stays reachable.
|
|
static void testFreshRampDownEndpointBeatsTheStartMarkerAtFrameZero() {
|
|
const Rect a = Rect{20, 10, 1000, kWaveformMinHeight};
|
|
const OverlayArea overlay = overlayOf(a);
|
|
const std::int64_t frames = 100000;
|
|
const VelocityCurve contour = VelocityCurve::rampDown();
|
|
const VelocityCurve::Box box = boxOf(a);
|
|
|
|
CHECK(contour.pointAtPixel(box, a.x, a.y) == 0); // endpoint 0 sits at (box.left, box.top)
|
|
const std::int64_t markers[1] = {0};
|
|
CHECK(markerAtPoint(overlay, frames, markers, 1, a.x, a.y) == 0); // the coincidence
|
|
|
|
const std::int64_t markerArea =
|
|
static_cast<std::int64_t>(2 * kMarkerGrabWidth + 1) * a.height; // 11 * band height
|
|
CHECK(kNodeArea < markerArea); // the node wins: the endpoint stays a genuine grab target
|
|
|
|
const WaveformClaim node{true, kNodeArea};
|
|
const WaveformClaim marker{true, markerArea};
|
|
CHECK(resolveWaveformClaim(node, WaveformClaim{}, marker, SplineGesture::kLeft) ==
|
|
WaveformClaimant::kNode);
|
|
}
|
|
|
|
// Case (b): the crossfade tab at zero crossfade sits on loopStart's own pixel column; a node
|
|
// dragged to value ~0.98 lands a couple of rows below the box top — inside the tab's own
|
|
// top-strip band, where the review found the tab fully shadowed by a node-first pass.
|
|
static void testCrossfadeTabBeatsAContourNodeNearItsTopStrip() {
|
|
const Rect a = Rect{20, 10, 1000, kWaveformMinHeight};
|
|
const OverlayArea overlay = overlayOf(a);
|
|
const std::int64_t frames = 100000;
|
|
const std::int64_t loopStart = 40000, crossfade = 0; // zero crossfade -> tab sits on loopStart
|
|
const int mx = frameToX(overlay, frames, loopStart - crossfade);
|
|
const Rect tabRect = markerHandleRect(overlay, frames, loopStart - crossfade);
|
|
CHECK(!tabRect.empty());
|
|
|
|
const VelocityCurve::Box box = boxOf(a);
|
|
const int ny = a.y + 3; // ~0.98 up a kWaveformMinHeight-tall box; inside the tab's top strip
|
|
VelocityCurve c = VelocityCurve::flat();
|
|
const VelocityPoint p = c.pointFromPixel(box, mx, ny);
|
|
c.addPoint(p.velocity, p.value);
|
|
CHECK(c.pointAtPixel(box, mx, ny) >= 0);
|
|
CHECK(contains(tabRect, mx, ny)); // the coincidence: both claim the same pixel
|
|
|
|
const std::int64_t tabArea = static_cast<std::int64_t>(tabRect.width) * tabRect.height; // <= 110
|
|
CHECK(tabArea < kNodeArea); // the tab wins: it stays the only affordance at zero crossfade
|
|
|
|
const WaveformClaim node{true, kNodeArea};
|
|
const WaveformClaim tab{true, tabArea};
|
|
CHECK(resolveWaveformClaim(node, tab, WaveformClaim{}, SplineGesture::kLeft) ==
|
|
WaveformClaimant::kTab);
|
|
|
|
// The residual the review names: the node keeps its OUTER columns, one pixel past the tab's
|
|
// clipped edge but still inside its own pick radius.
|
|
const int outerX = mx + kMarkerHandleHalfWidth + 1;
|
|
CHECK(!contains(tabRect, outerX, ny));
|
|
CHECK(c.pointAtPixel(box, outerX, ny) >= 0);
|
|
}
|
|
|
|
// Case (c): a contour node coincident with a loop marker. At kWaveformMinHeight (the product's
|
|
// own floor) the column is already an order of magnitude larger than the node's fixed pick box,
|
|
// so the node wins the shared pixel while the column stays reachable everywhere the node isn't.
|
|
static void testContourNodeBeatsALoopMarkerAtTheirSharedPixelButNotElsewhere() {
|
|
const Rect a = Rect{20, 10, 1000, kWaveformMinHeight};
|
|
const OverlayArea overlay = overlayOf(a);
|
|
const std::int64_t frames = 100000;
|
|
const std::int64_t loopEnd = 70000;
|
|
const int mx = frameToX(overlay, frames, loopEnd);
|
|
const std::int64_t markers[1] = {loopEnd};
|
|
|
|
const VelocityCurve::Box box = boxOf(a);
|
|
const int ny = a.y + a.height / 2; // mid-height, well clear of any tab
|
|
VelocityCurve c = VelocityCurve::flat();
|
|
const VelocityPoint p = c.pointFromPixel(box, mx, ny);
|
|
c.addPoint(p.velocity, p.value);
|
|
CHECK(c.pointAtPixel(box, mx, ny) >= 0);
|
|
CHECK(markerAtPoint(overlay, frames, markers, 1, mx, ny) == 0); // the coincidence
|
|
|
|
const std::int64_t markerArea =
|
|
static_cast<std::int64_t>(2 * kMarkerGrabWidth + 1) * a.height; // 11 * band height
|
|
CHECK(kNodeArea < markerArea); // the node wins the shared pixel
|
|
|
|
const WaveformClaim node{true, kNodeArea};
|
|
const WaveformClaim marker{true, markerArea};
|
|
CHECK(resolveWaveformClaim(node, WaveformClaim{}, marker, SplineGesture::kLeft) ==
|
|
WaveformClaimant::kNode);
|
|
|
|
// A few rows clear of the node (outside its 13px pick box, still on the marker's column)
|
|
// the marker alone claims the click.
|
|
const int farY = ny + kCurveNodeGrabRadius + 4;
|
|
CHECK(c.pointAtPixel(box, mx, farY) < 0);
|
|
CHECK(markerAtPoint(overlay, frames, markers, 1, mx, farY) == 0);
|
|
CHECK(resolveWaveformClaim(WaveformClaim{}, WaveformClaim{}, marker, SplineGesture::kLeft) ==
|
|
WaveformClaimant::kMarker);
|
|
}
|
|
|
|
// The only live tie: the crossfade tab (<=110) can equal the node (169) only off-geometry, but
|
|
// tab-vs-marker ties at overlay height 10 (kMarkerHandleHeight), where the tab's 11x10 strip
|
|
// (110) equals a marker column's 11 * 10 (110) — the tab wins, matching check order.
|
|
static void testTabWinsAGenuineTabVersusMarkerTie() {
|
|
CHECK(resolveWaveformClaim(WaveformClaim{}, WaveformClaim{true, 110}, WaveformClaim{true, 110},
|
|
SplineGesture::kLeft) == WaveformClaimant::kTab);
|
|
}
|
|
|
|
// No claimant hit at all falls through to kNone — the caller's cue to let the drawn contour take
|
|
// empty space (addOnEmptySpace) rather than starting any drag.
|
|
static void testNoHitAnywhereFallsThroughToNone() {
|
|
CHECK(resolveWaveformClaim(WaveformClaim{}, WaveformClaim{}, WaveformClaim{}, SplineGesture::kLeft) ==
|
|
WaveformClaimant::kNone);
|
|
}
|
|
|
|
// A candidate that reports hit == false must never win merely because its (unused, default)
|
|
// area of 0 looks "smallest" — hit gates a candidate out before its area is ever compared. Real
|
|
// call sites never produce hit == false with area != 0, but the arbitration still owes one
|
|
// well-defined answer to every input, not just the ones live geometry happens to produce.
|
|
static void testAMissedCandidateNeverWinsOnADegenerateZeroArea() {
|
|
const WaveformClaim missedNode{false, 0};
|
|
const WaveformClaim tab{true, 50};
|
|
const WaveformClaim marker{true, 100};
|
|
CHECK(resolveWaveformClaim(missedNode, tab, marker, SplineGesture::kLeft) ==
|
|
WaveformClaimant::kTab);
|
|
}
|
|
|
|
// A control-click has no tab/marker meaning (only the node's hard/smooth toggle answers it), so
|
|
// it resolves to the node whenever the node is in the running, even where a plain left-click at
|
|
// the same pixel would hand the tab or marker the win on area alone.
|
|
static void testControlClickAlwaysTakesTheNodeOverASmallerTabOrMarker() {
|
|
const WaveformClaim node{true, kNodeArea};
|
|
const WaveformClaim smallerTab{true, 50}; // would beat the node on a plain left-click
|
|
CHECK(resolveWaveformClaim(node, smallerTab, WaveformClaim{}, SplineGesture::kLeft) ==
|
|
WaveformClaimant::kTab);
|
|
CHECK(resolveWaveformClaim(node, smallerTab, WaveformClaim{}, SplineGesture::kControlLeft) ==
|
|
WaveformClaimant::kNode);
|
|
// No node in the running: control-click has nothing to fall back to, so the tab still wins.
|
|
CHECK(resolveWaveformClaim(WaveformClaim{}, smallerTab, WaveformClaim{},
|
|
SplineGesture::kControlLeft) == WaveformClaimant::kTab);
|
|
}
|
|
|
|
int main() {
|
|
testLeftClickOnANodeGrabsIt();
|
|
testLeftClickInEmptySpaceAdds();
|
|
testRightClickOnANodeDeletesAndElsewhereDoesNothing();
|
|
testControlClickOnANodeTogglesHardAndElsewhereDoesNothing();
|
|
testOutsideTheBoxGrabsButNeverAdds();
|
|
testDegenerateBoxResolvesToNothing();
|
|
testOverlayBoxIsTheWholeArea();
|
|
|
|
testFreshRampDownEndpointBeatsTheStartMarkerAtFrameZero();
|
|
testCrossfadeTabBeatsAContourNodeNearItsTopStrip();
|
|
testContourNodeBeatsALoopMarkerAtTheirSharedPixelButNotElsewhere();
|
|
testTabWinsAGenuineTabVersusMarkerTie();
|
|
testNoHitAnywhereFallsThroughToNone();
|
|
testAMissedCandidateNeverWinsOnADegenerateZeroArea();
|
|
testControlClickAlwaysTakesTheNodeOverASmallerTabOrMarker();
|
|
|
|
if (g_fail == 0) std::printf("spline_edit: all tests passed\n");
|
|
return g_fail == 0 ? 0 : 1;
|
|
}
|