757e1585d6
Waveform overlay now resolves node/tab/marker click collisions by target area instead of check order; residue test now uses a distinguishing fixture; Gate-unavailable-while-drawn logic extracted to one pure helper shared by resolvePlay and applyControl.
571 lines
28 KiB
C++
571 lines
28 KiB
C++
// Standalone tests for reasampler::instrument::ui::waveform_view — no VST3, no REAPER, no framework.
|
|
// Same fast assert loop as the sibling pure tests. Assert the waveform band's drawn surface
|
|
// (lane split + the full-height overlay contract) and its frame<->pixel mapping, marker grab
|
|
// regions, drag-delta frame resolver (with clamps), and zero-crossing snap.
|
|
//
|
|
// Covers: frameToX / xToFrame (linear map + inverse, edge clamps, degenerate frameCount/width);
|
|
// markerAtPoint (grab band, first-match on overlap, off-area + null-array rejection);
|
|
// markerHandleRect (the top-strip tab that keeps coincident markers independently grabbable);
|
|
// resolveDragFrame (round-to-nearest-frame, clamp to [0,frameCount], zero-delta/zero-width
|
|
// no-ops); nearestZeroCrossing (nearest sign-change, sample-on-zero, equidistant-tie-to-lower,
|
|
// no-crossing keeps target, target clamp, degenerate buffers); waveformSurface (two stacked
|
|
// lanes L-over-R in stereo, one lane in mono AND for a mono source, overlay always the full
|
|
// stacked height, grabs reaching the lower lane); laneEnvelope (per-lane channel split).
|
|
|
|
#include "../src/core/instrument/ui/waveform_view.h"
|
|
#include "../src/core/instrument/engine/velocity_curve.h" // kCurveNodeGrabRadius, VelocityCurve::pointAtPixel
|
|
#include "../src/core/instrument/ui/sample_bands.h" // kWaveformMinHeight, kLaneGap
|
|
|
|
#include <cstddef>
|
|
#include <cstdio>
|
|
#include <vector>
|
|
|
|
using namespace reasampler;
|
|
using namespace reasampler::instrument::ui;
|
|
using namespace reasampler::instrument::engine;
|
|
using reasampler::audio::AudioSample;
|
|
|
|
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}; }
|
|
|
|
// A comfortable waveform area: 1000px wide, offset so left != 0 (catches origin bugs).
|
|
static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 90); } // width 1000
|
|
|
|
// --- frameToX / xToFrame ------------------------------------------------------
|
|
|
|
static void testFrameToXEndpoints() {
|
|
const Rect a = wideArea();
|
|
CHECK(frameToX(overlayOf(a), 1000, 0) == a.x); // frame 0 -> left edge
|
|
CHECK(frameToX(overlayOf(a), 1000, 1000) == a.right()); // frameCount -> right edge
|
|
CHECK(frameToX(overlayOf(a), 1000, 500) == a.x + 500); // midpoint (1:1 here)
|
|
}
|
|
|
|
static void testFrameToXClampsOutOfRange() {
|
|
const Rect a = wideArea();
|
|
CHECK(frameToX(overlayOf(a), 1000, -50) == a.x); // below 0 pins left
|
|
CHECK(frameToX(overlayOf(a), 1000, 5000) == a.right()); // above count pins right
|
|
}
|
|
|
|
static void testFrameToXDegenerate() {
|
|
const Rect a = wideArea();
|
|
CHECK(frameToX(overlayOf(a), 0, 100) == a.x); // no frames -> left
|
|
const Rect z = Rect::ltrb(5, 5, 5, 45); // zero width
|
|
CHECK(frameToX(overlayOf(z), 1000, 500) == z.x);
|
|
}
|
|
|
|
static void testXToFrameInverse() {
|
|
const Rect a = wideArea();
|
|
CHECK(xToFrame(overlayOf(a), 1000, a.x) == 0);
|
|
CHECK(xToFrame(overlayOf(a), 1000, a.right()) == 1000);
|
|
CHECK(xToFrame(overlayOf(a), 1000, a.x + 250) == 250); // 1:1 map here
|
|
}
|
|
|
|
static void testXToFrameClampsOutside() {
|
|
const Rect a = wideArea();
|
|
CHECK(xToFrame(overlayOf(a), 1000, a.x - 100) == 0); // left of area -> 0
|
|
CHECK(xToFrame(overlayOf(a), 1000, a.right() + 100) == 1000); // right of area -> frameCount
|
|
CHECK(xToFrame(overlayOf(a), 0, a.x + 10) == 0); // no frames -> 0
|
|
}
|
|
|
|
static void testFrameToXRoundTrip() {
|
|
// Round-trip at a non-1:1 scale: 800px area over 2000 frames (2.5 frames/px). frameToX then
|
|
// xToFrame should land within a couple frames (rounding both directions).
|
|
const Rect a = Rect::ltrb(0, 0, 800, 60);
|
|
for (std::int64_t f = 0; f <= 2000; f += 137) {
|
|
const int x = frameToX(overlayOf(a), 2000, f);
|
|
const std::int64_t back = xToFrame(overlayOf(a), 2000, x);
|
|
CHECK(back >= f - 3 && back <= f + 3);
|
|
}
|
|
}
|
|
|
|
// --- markerAtPoint ------------------------------------------------------------
|
|
|
|
static void testMarkerAtPointGrabsWithinBand() {
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
// Markers at frames 100, 500, 900 -> x = left+100, left+500, left+900.
|
|
const std::int64_t frames[3] = {100, 500, 900};
|
|
const int midY = a.y + a.height / 2;
|
|
CHECK(markerAtPoint(ov, 1000, frames, 3, a.x + 100, midY) == 0);
|
|
CHECK(markerAtPoint(ov, 1000, frames, 3, a.x + 500, midY) == 1);
|
|
CHECK(markerAtPoint(ov, 1000, frames, 3, a.x + 900, midY) == 2);
|
|
// Within the grab band on either side of the line.
|
|
CHECK(markerAtPoint(ov, 1000, frames, 3, a.x + 500 + kMarkerGrabWidth, midY) == 1);
|
|
CHECK(markerAtPoint(ov, 1000, frames, 3, a.x + 500 - kMarkerGrabWidth, midY) == 1);
|
|
}
|
|
|
|
static void testMarkerAtPointMissesBetween() {
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
const std::int64_t frames[3] = {100, 500, 900};
|
|
const int midY = a.y + a.height / 2;
|
|
// Well away from any marker line.
|
|
CHECK(markerAtPoint(ov, 1000, frames, 3, a.x + 300, midY) == -1);
|
|
// Off the area vertically.
|
|
CHECK(markerAtPoint(ov, 1000, frames, 3, a.x + 500, a.y - 5) == -1);
|
|
}
|
|
|
|
static void testMarkerAtPointFirstMatchOnOverlap() {
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
// Two markers at the same frame -> first in order wins.
|
|
const std::int64_t frames[2] = {400, 400};
|
|
const int midY = a.y + a.height / 2;
|
|
CHECK(markerAtPoint(ov, 1000, frames, 2, a.x + 400, midY) == 0);
|
|
}
|
|
|
|
static void testMarkerAtPointRejectsNullEmpty() {
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
const int midY = a.y + a.height / 2;
|
|
CHECK(markerAtPoint(ov, 1000, nullptr, 3, a.x + 100, midY) == -1);
|
|
const std::int64_t frames[1] = {100};
|
|
CHECK(markerAtPoint(ov, 1000, frames, 0, a.x + 100, midY) == -1);
|
|
}
|
|
|
|
// --- resolveDragFrame ---------------------------------------------------------
|
|
|
|
static void testResolveDragFrameShift() {
|
|
const Rect a = wideArea(); // 1:1 (1000px / 1000 frames)
|
|
const OverlayArea ov = overlayOf(a);
|
|
CHECK(resolveDragFrame(ov, 1000, 300, 0) == 300); // zero delta -> unchanged
|
|
CHECK(resolveDragFrame(ov, 1000, 300, 100) == 400); // +100px -> +100 frames
|
|
CHECK(resolveDragFrame(ov, 1000, 300, -50) == 250); // -50px -> -50 frames
|
|
}
|
|
|
|
static void testResolveDragFrameClamps() {
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
CHECK(resolveDragFrame(ov, 1000, 50, -500) == 0); // clamp low
|
|
CHECK(resolveDragFrame(ov, 1000, 950, 500) == 1000); // clamp high (== frameCount)
|
|
}
|
|
|
|
static void testResolveDragFrameRounds() {
|
|
// 500px area over 1000 frames -> 2 frames/px. A +3px drag -> round(6.0)=6; the rounding is
|
|
// at the frame centre. Use a scale where a fractional result appears.
|
|
const OverlayArea ov = overlayOf(Rect::ltrb(0, 0, 300, 60)); // 1000 frames / 300px = 3.33 frames/px
|
|
// +3px -> 3*1000/300 = 10.0 -> 10 frames.
|
|
CHECK(resolveDragFrame(ov, 1000, 100, 3) == 110);
|
|
// +1px -> 1000/300 = 3.33 -> rounds to 3.
|
|
CHECK(resolveDragFrame(ov, 1000, 100, 1) == 103);
|
|
}
|
|
|
|
static void testResolveDragFrameDegenerate() {
|
|
const Rect z = Rect::ltrb(0, 0, 0, 60); // zero width
|
|
CHECK(resolveDragFrame(overlayOf(z), 1000, 300, 100) == 300); // pinned to start
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
CHECK(resolveDragFrame(ov, 0, 300, 100) == 0); // no frames -> clamp(start)=0
|
|
// startFrame out of range is clamped first.
|
|
CHECK(resolveDragFrame(ov, 1000, 5000, 0) == 1000);
|
|
}
|
|
|
|
// --- nearestZeroCrossing ------------------------------------------------------
|
|
|
|
static void testZeroCrossingNearest() {
|
|
// Crossings (sign change from i-1 to i): i=4 (1->-1), i=5 (-1->1), i=10 (1->-1).
|
|
std::vector<AudioSample> pcm = {1, 1, 1, 1, -1, 1, 1, 1, 1, 1, -1, -1};
|
|
// Target 4 is itself a crossing -> 4.
|
|
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 4) == 4);
|
|
// Nearest to 6: crossing 5 (dist 1) beats 4 (dist 2) and 10 (dist 4) -> 5.
|
|
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 6) == 5);
|
|
// Nearest to 9: crossing 10 (dist 1) beats 5 (dist 4) -> 10.
|
|
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 9) == 10);
|
|
}
|
|
|
|
static void testZeroCrossingSampleOnZero() {
|
|
// A sample exactly 0 is its own crossing (frame index of the zero sample).
|
|
std::vector<AudioSample> pcm = {1, 1, 0, 1, 1};
|
|
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 2) == 2);
|
|
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 3) == 2);
|
|
}
|
|
|
|
static void testZeroCrossingEquidistantTieToLower() {
|
|
// Crossings at i=2 (1->-1) and i=6 (-1->1). Target 4 is equidistant (dist 2) -> lower (2).
|
|
std::vector<AudioSample> pcm = {1, 1, -1, -1, -1, -1, 1, 1};
|
|
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 4) == 2);
|
|
}
|
|
|
|
static void testZeroCrossingNoneKeepsTarget() {
|
|
// All one sign -> no crossing -> the (clamped) target comes back unchanged.
|
|
std::vector<AudioSample> pcm = {0.5f, 0.6f, 0.7f, 0.8f};
|
|
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 2) == 2);
|
|
}
|
|
|
|
static void testZeroCrossingClampsTarget() {
|
|
std::vector<AudioSample> pcm = {1, -1, 1, -1}; // crossings at 1,2,3
|
|
// Target beyond the end clamps to frames-1 (3) then finds crossing at 3.
|
|
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 999) == 3);
|
|
// Negative target clamps to 0; nearest crossing is 1.
|
|
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), -999) == 1);
|
|
}
|
|
|
|
static void testZeroCrossingDegenerate() {
|
|
CHECK(nearestZeroCrossing(nullptr, 0, 5) == 0);
|
|
std::vector<AudioSample> one = {1};
|
|
CHECK(nearestZeroCrossing(one.data(), 1, 0) == 0); // <2 frames -> clamped target
|
|
}
|
|
|
|
// --- waveformSurface: the lane split + the overlay contract --------------------
|
|
|
|
// A realistic waveform band: full-width, taller than the two-lane floor.
|
|
static Rect band() { return Rect::ltrb(8, 90, 832, 90 + kWaveformMinHeight); }
|
|
|
|
static void testSurfaceStereoStacksTwoLanes() {
|
|
const Rect b = band();
|
|
const WaveformSurface s = waveformSurface(b, /*stereoMode=*/true, /*sourceChannels=*/2);
|
|
CHECK(s.laneCount == 2);
|
|
CHECK(!s.upper.empty() && !s.lower.empty());
|
|
CHECK(s.upper.y == b.y); // L on top
|
|
CHECK(s.lower.y > s.upper.bottom()); // R below, seam between them
|
|
CHECK(s.lower.bottom() == b.bottom()); // together they reach the band's floor
|
|
CHECK(s.upper.x == b.x && s.upper.width == b.width);
|
|
CHECK(s.lower.x == b.x && s.lower.width == b.width);
|
|
// Non-overlapping, and the band is exactly lanes + the one seam gap.
|
|
CHECK(s.lower.y - s.upper.bottom() == kLaneGap);
|
|
CHECK(s.upper.height + kLaneGap + s.lower.height == b.height);
|
|
}
|
|
|
|
static void testSurfaceMonoIsOneLane() {
|
|
const Rect b = band();
|
|
const WaveformSurface s = waveformSurface(b, /*stereoMode=*/false, /*sourceChannels=*/2);
|
|
CHECK(s.laneCount == 1);
|
|
CHECK(s.upper == b); // the single lane spans the whole band
|
|
CHECK(s.lower.empty()); // no second lane to draw
|
|
}
|
|
|
|
static void testSurfaceMonoSourceInStereoModeStaysOneLane() {
|
|
// Dual-mono: a mono source under stereo mode has no second channel, so a second lane
|
|
// would be a redundant duplicate.
|
|
const Rect b = band();
|
|
const WaveformSurface s = waveformSurface(b, /*stereoMode=*/true, /*sourceChannels=*/1);
|
|
CHECK(s.laneCount == 1);
|
|
CHECK(s.upper == b);
|
|
CHECK(s.lower.empty());
|
|
}
|
|
|
|
static void testSurfaceOverlayIsFullStackedHeightInBothModes() {
|
|
const Rect b = band();
|
|
const WaveformSurface st = waveformSurface(b, /*stereoMode=*/true, 2);
|
|
const WaveformSurface mo = waveformSurface(b, /*stereoMode=*/false, 2);
|
|
// Stereo: ONE overlay rect spanning both lanes, not either lane.
|
|
CHECK(st.overlay.rect == b);
|
|
CHECK(st.overlay.rect.height == st.upper.height + kLaneGap + st.lower.height);
|
|
CHECK(st.overlay.rect != st.upper && st.overlay.rect != st.lower);
|
|
// Mono: the same rect, which is also the single lane.
|
|
CHECK(mo.overlay.rect == b);
|
|
CHECK(mo.overlay.rect == mo.upper);
|
|
// The standalone accessor the hit-test paths use agrees with the resolved surface.
|
|
CHECK(waveformOverlayArea(b) == st.overlay);
|
|
CHECK(waveformOverlayArea(b) == mo.overlay);
|
|
}
|
|
|
|
static void testSurfaceDegenerateBandDrawsNothing() {
|
|
const WaveformSurface s = waveformSurface(Rect{10, 10, 0, 0}, true, 2);
|
|
CHECK(s.laneCount == 0);
|
|
CHECK(s.upper.empty() && s.lower.empty() && s.overlay.rect.empty());
|
|
CHECK(waveformOverlayArea(Rect{10, 10, 0, 0}).rect.empty());
|
|
}
|
|
|
|
static void testSurfaceThinBandRoundsLowerLaneEmpty() {
|
|
// Height 3 is the edge where the stereo split's integer division rounds the lower lane to
|
|
// empty even though the band itself isn't degenerate — pins the laneCount derivation.
|
|
const WaveformSurface s = waveformSurface(Rect{0, 0, 100, 3}, true, 2);
|
|
CHECK(s.laneCount == 1);
|
|
CHECK(!s.upper.empty());
|
|
CHECK(s.lower.empty());
|
|
}
|
|
|
|
// --- Hit-testing across the stacked lanes -------------------------------------
|
|
|
|
static void testMarkerGrabReachesTheLowerStereoLane() {
|
|
const Rect b = band();
|
|
const WaveformSurface s = waveformSurface(b, /*stereoMode=*/true, 2);
|
|
const std::int64_t frames = 1000;
|
|
const std::int64_t markers[1] = {500};
|
|
const int mx = frameToX(s.overlay, frames, 500);
|
|
// The same marker answers a grab in either lane — overlays span the full stack.
|
|
const int upperY = s.upper.y + s.upper.height / 2;
|
|
const int lowerY = s.lower.y + s.lower.height / 2;
|
|
CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx, upperY) == 0);
|
|
CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx, lowerY) == 0);
|
|
// A lower-lane grab hit-tested against the UPPER LANE would be lost — the miss this
|
|
// contract exists to prevent. (Explicit OverlayArea{} wrap: production code can't do
|
|
// this by accident — markerAtPoint won't accept a bare lane Rect — but the geometry
|
|
// claim still needs proving.)
|
|
CHECK(markerAtPoint(overlayOf(s.upper), frames, markers, 1, mx, lowerY) == -1);
|
|
// Off the marker's x is still a miss at either height.
|
|
CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx + 40, lowerY) == -1);
|
|
}
|
|
|
|
static void testMarkerGrabInMonoSpansTheBand() {
|
|
const Rect b = band();
|
|
const WaveformSurface s = waveformSurface(b, /*stereoMode=*/false, 2);
|
|
const std::int64_t frames = 1000;
|
|
const std::int64_t markers[1] = {250};
|
|
const int mx = frameToX(s.overlay, frames, 250);
|
|
CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx, b.y) == 0);
|
|
CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx, b.bottom() - 1) == 0);
|
|
CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx, b.bottom() + 5) == -1);
|
|
}
|
|
|
|
// --- The marker grab handle ----------------------------------------------------
|
|
|
|
static void testMarkerHandleIsATopStripCentredOnTheMarker() {
|
|
const Rect a = wideArea();
|
|
const int mx = frameToX(overlayOf(a), 1000, 250);
|
|
const Rect h = markerHandleRect(overlayOf(a), 1000, 250);
|
|
CHECK(h.x == mx - kMarkerHandleHalfWidth);
|
|
CHECK(h.right() == mx + kMarkerHandleHalfWidth + 1);
|
|
CHECK(h.y == a.y);
|
|
CHECK(h.height == kMarkerHandleHeight);
|
|
CHECK(contains(h, mx, a.y));
|
|
CHECK(contains(h, mx, a.y + kMarkerHandleHeight - 1));
|
|
CHECK(!contains(h, mx, a.y + kMarkerHandleHeight)); // below the strip is the column's
|
|
}
|
|
|
|
// The whole reason the handle exists: two markers that share a frame both stay reachable —
|
|
// markerAtPoint gives its full-height column to the first in draw order, and the handle owns
|
|
// the strip above. Without the split, the loser could never be dragged apart again.
|
|
static void testCoincidentMarkersStayIndependentlyGrabbable() {
|
|
const Rect a = wideArea();
|
|
const std::int64_t markers[2] = {250, 250};
|
|
const int mx = frameToX(overlayOf(a), 1000, 250);
|
|
// The column resolves to the first marker at every height, including the top strip.
|
|
CHECK(markerAtPoint(overlayOf(a), 1000, markers, 2, mx, a.y) == 0);
|
|
CHECK(markerAtPoint(overlayOf(a), 1000, markers, 2, mx, a.bottom() - 1) == 0);
|
|
// The handle, asked first, resolves the second one in that same top strip.
|
|
CHECK(contains(markerHandleRect(overlayOf(a), 1000, 250), mx, a.y));
|
|
CHECK(!contains(markerHandleRect(overlayOf(a), 1000, 250), mx, a.bottom() - 1));
|
|
}
|
|
|
|
static void testMarkerHandleClipsIntoTheArea() {
|
|
const Rect a = wideArea();
|
|
// At the last frame the marker maps to right(); an unclipped tab would claim pixels
|
|
// outside the band the caller already hit-tested.
|
|
const Rect hi = markerHandleRect(overlayOf(a), 1000, 1000);
|
|
CHECK(hi.right() == a.right());
|
|
CHECK(!contains(hi, a.right(), a.y));
|
|
CHECK(contains(hi, a.right() - 1, a.y));
|
|
// And at frame 0 it cannot reach left of the band.
|
|
const Rect lo = markerHandleRect(overlayOf(a), 1000, 0);
|
|
CHECK(lo.x == a.x);
|
|
CHECK(!contains(lo, a.x - 1, a.y));
|
|
}
|
|
|
|
static void testMarkerHandleOnDegenerateAreas() {
|
|
CHECK(markerHandleRect(overlayOf(Rect{}), 1000, 0).empty());
|
|
// A band shorter than the strip yields a handle the height of the band, never taller.
|
|
const Rect thin = Rect{0, 0, 100, 4};
|
|
CHECK(markerHandleRect(overlayOf(thin), 1000, 500).height == 4);
|
|
}
|
|
|
|
// The shell (editor_input_waveform.cpp) checks the loop crossfade's own grab handle — at
|
|
// loopStart - crossfade — before it iterates the ordinary marker array, because a zero-length
|
|
// fade puts that handle exactly on the loop-start marker's frame. The same coincidence recurs
|
|
// whenever ANY marker shares that frame, most plausibly the START marker dragged up against the
|
|
// fade edge: this module can't exercise the shell's check-order itself, but it can prove the
|
|
// geometric ambiguity that makes the ordering load-bearing — the array's own first-match rule
|
|
// would otherwise resolve the top strip to the START marker, not the fade handle.
|
|
static void testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge() {
|
|
const Rect a = wideArea();
|
|
const std::int64_t loopStart = 400, crossfade = 30;
|
|
const std::int64_t fadeEdge = loopStart - crossfade; // where the crossfade handle sits
|
|
const std::int64_t markers[3] = {fadeEdge, loopStart, loopStart + 100}; // start dialled here
|
|
const int mx = frameToX(overlayOf(a), 1000, fadeEdge);
|
|
const int topY = a.y; // inside the handle's top strip
|
|
// Without the shell's priority check, the array's own first-match rule already resolves the
|
|
// column to the start marker (index 0) at this x/y...
|
|
CHECK(markerAtPoint(overlayOf(a), 1000, markers, 3, mx, topY) == 0);
|
|
// ...and the fade handle's rect claims the exact same pixel — the ambiguity the shell
|
|
// resolves by smallest-target-first (the handle's clipped tab is always the narrower
|
|
// target), same as it does for the zero-fade/loop-start case.
|
|
CHECK(contains(markerHandleRect(overlayOf(a), 1000, fadeEdge), mx, topY));
|
|
}
|
|
|
|
// --- Smallest-target-first: the three-way coincidence with a spline contour node -------
|
|
//
|
|
// 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 measuring each candidate's
|
|
// own target area and letting the smallest win — this module can't exercise the shell's
|
|
// arbitration itself (no shell test target wraps the editor), but it can pin the geometric facts
|
|
// that arbitration depends on, over the pure primitives it composes. 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}; }
|
|
|
|
// 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
|
|
|
|
constexpr std::int64_t nodeSide = 2 * kCurveNodeGrabRadius + 1;
|
|
constexpr std::int64_t nodeArea = nodeSide * nodeSide; // 169, fixed
|
|
const std::int64_t markerArea =
|
|
static_cast<std::int64_t>(2 * kMarkerGrabWidth + 1) * a.height; // 11 * band height
|
|
CHECK(nodeArea < markerArea); // the node wins: the endpoint stays a genuine grab target
|
|
}
|
|
|
|
// 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
|
|
|
|
constexpr std::int64_t nodeSide = 2 * kCurveNodeGrabRadius + 1;
|
|
constexpr std::int64_t nodeArea = nodeSide * nodeSide; // 169, fixed
|
|
const std::int64_t tabArea = static_cast<std::int64_t>(tabRect.width) * tabRect.height; // <= 110
|
|
CHECK(tabArea < nodeArea); // the tab wins: it stays the only affordance at zero crossfade
|
|
|
|
// 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
|
|
|
|
constexpr std::int64_t nodeSide = 2 * kCurveNodeGrabRadius + 1;
|
|
constexpr std::int64_t nodeArea = nodeSide * nodeSide; // 169, fixed
|
|
const std::int64_t markerArea =
|
|
static_cast<std::int64_t>(2 * kMarkerGrabWidth + 1) * a.height; // 11 * band height
|
|
CHECK(nodeArea < markerArea); // the node wins the shared pixel
|
|
|
|
// 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);
|
|
}
|
|
|
|
// --- Per-lane envelope content -------------------------------------------------
|
|
|
|
static void testAsymmetricStereoLanesCarryDifferentContent() {
|
|
// Left is full-scale, right is a tenth of it — the lanes must look materially different.
|
|
const std::size_t frames = 400;
|
|
std::vector<AudioSample> interleaved(frames * 2);
|
|
for (std::size_t f = 0; f < frames; ++f) {
|
|
const AudioSample v = (f % 2 == 0) ? 1.0f : -1.0f;
|
|
interleaved[f * 2 + 0] = v;
|
|
interleaved[f * 2 + 1] = v * 0.1f;
|
|
}
|
|
// ONE pass over the interleaved source, split per lane — what the painter does.
|
|
const reasampler::audio::Envelope env =
|
|
reasampler::audio::computeEnvelope(interleaved, 2, frames, 20);
|
|
const reasampler::audio::Envelope upper = laneEnvelope(env, 0);
|
|
const reasampler::audio::Envelope lower = laneEnvelope(env, 1);
|
|
CHECK(upper.size() == 1 && lower.size() == 1);
|
|
CHECK(upper[0].size() == 20 && lower[0].size() == 20);
|
|
for (std::size_t i = 0; i < 20; ++i) {
|
|
CHECK(upper[0][i].max > 0.9f); // left near full scale
|
|
CHECK(lower[0][i].max < 0.2f); // right an order of magnitude down
|
|
CHECK(!(upper[0][i] == lower[0][i])); // and materially different, bin for bin
|
|
}
|
|
}
|
|
|
|
static void testLaneEnvelopeRejectsOutOfRangeLane() {
|
|
const std::size_t frames = 16;
|
|
std::vector<AudioSample> mono(frames, 0.5f);
|
|
const reasampler::audio::Envelope env =
|
|
reasampler::audio::computeEnvelope(mono, 1, frames, 4);
|
|
CHECK(laneEnvelope(env, 0).size() == 1);
|
|
CHECK(laneEnvelope(env, 1).empty()); // a mono source has no lower lane
|
|
CHECK(laneEnvelope(env, -1).empty());
|
|
}
|
|
|
|
int main() {
|
|
testFrameToXEndpoints();
|
|
testFrameToXClampsOutOfRange();
|
|
testFrameToXDegenerate();
|
|
testXToFrameInverse();
|
|
testXToFrameClampsOutside();
|
|
testFrameToXRoundTrip();
|
|
|
|
testMarkerAtPointGrabsWithinBand();
|
|
testMarkerAtPointMissesBetween();
|
|
testMarkerAtPointFirstMatchOnOverlap();
|
|
testMarkerAtPointRejectsNullEmpty();
|
|
|
|
testResolveDragFrameShift();
|
|
testResolveDragFrameClamps();
|
|
testResolveDragFrameRounds();
|
|
testResolveDragFrameDegenerate();
|
|
|
|
testZeroCrossingNearest();
|
|
testZeroCrossingSampleOnZero();
|
|
testZeroCrossingEquidistantTieToLower();
|
|
testZeroCrossingNoneKeepsTarget();
|
|
testZeroCrossingClampsTarget();
|
|
testZeroCrossingDegenerate();
|
|
|
|
testSurfaceStereoStacksTwoLanes();
|
|
testSurfaceMonoIsOneLane();
|
|
testSurfaceMonoSourceInStereoModeStaysOneLane();
|
|
testSurfaceOverlayIsFullStackedHeightInBothModes();
|
|
testSurfaceDegenerateBandDrawsNothing();
|
|
testSurfaceThinBandRoundsLowerLaneEmpty();
|
|
|
|
testMarkerGrabReachesTheLowerStereoLane();
|
|
testMarkerGrabInMonoSpansTheBand();
|
|
|
|
testMarkerHandleIsATopStripCentredOnTheMarker();
|
|
testCoincidentMarkersStayIndependentlyGrabbable();
|
|
testMarkerHandleClipsIntoTheArea();
|
|
testMarkerHandleOnDegenerateAreas();
|
|
testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge();
|
|
testFreshRampDownEndpointBeatsTheStartMarkerAtFrameZero();
|
|
testCrossfadeTabBeatsAContourNodeNearItsTopStrip();
|
|
testContourNodeBeatsALoopMarkerAtTheirSharedPixelButNotElsewhere();
|
|
|
|
testAsymmetricStereoLanesCarryDifferentContent();
|
|
testLaneEnvelopeRejectsOutOfRangeLane();
|
|
|
|
if (g_fail == 0) std::printf("waveform_view: all tests passed\n");
|
|
else std::printf("waveform_view: %d FAILED\n", g_fail);
|
|
return g_fail == 0 ? 0 : 1;
|
|
}
|