Files
reasampler/tests/test_loop_marks.cpp
daniel 1b4d0e67b7 Loop-crossfade-ux review fixes: parked-drag no longer fakes LOOP OFF, waveform label contrast fixed, hover memoizes its bank read
Also corrects the cap-area, em-dash, glyph-overhang and heuristic-comment findings noted in review.
2026-08-02 13:52:32 -04:00

225 lines
9.3 KiB
C++

// Standalone tests for reasampler::instrument::ui::loop_marks — no VST3, no REAPER, no
// framework. Asserts the loop enable's whole state machine: the resolve's park rule and its two
// OFF states, the write's collapse fold and crossfade retention, and the four gestures that
// reach `hasLoop` composed end to end (resolve -> edit -> apply -> resolve), which is exactly
// how the editor drives it.
#include "../src/core/instrument/ui/loop_marks.h"
#include "../src/core/instrument/engine/loop/loop_span.h" // defaultLoopBounds (the park target)
#include <cstdio>
using namespace reasampler::instrument::ui;
using reasampler::SampleLoop;
using reasampler::instrument::engine::loop::LoopBounds;
using reasampler::instrument::engine::loop::defaultLoopBounds;
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 constexpr std::int64_t kFrames = 10000;
// The editor's own round trip: what the band shows after a marker set is written back.
static LoopMarks writeThenRead(const LoopMarks& edited, std::int64_t frames = kFrames) {
const LoopWrite w = applyLoopMarks(edited);
StoredLoop s;
s.override_ = w.loop;
s.crossfade = w.crossfade;
s.startPoint = w.start;
return resolveLoopMarks(s, frames);
}
static StoredLoop storedSpan(bool on, std::int64_t start, std::int64_t end,
std::int64_t crossfade) {
StoredLoop s;
s.override_ = SampleLoop{on, start, end};
s.crossfade = crossfade;
return s;
}
// --- resolve: the two OFF states -----------------------------------------------
static void testNothingSetParksOnTheDefaultBoundsAndReadsAsNeverSet() {
const LoopMarks m = resolveLoopMarks(StoredLoop{}, kFrames);
const LoopBounds d = defaultLoopBounds(kFrames);
CHECK(!m.hasLoop);
CHECK(m.parked); // the "DRAG TO SET LOOP" state
CHECK(m.loopStart == d.start && m.loopEnd == d.end);
}
static void testAValidSpanSwitchedOffKeepsItsOwnPositions() {
const LoopMarks m = resolveLoopMarks(storedSpan(false, 4000, 6000, 300), kFrames);
CHECK(!m.hasLoop);
CHECK(!m.parked); // the "LOOP OFF" state — there is nothing to "set"
CHECK(m.loopStart == 4000 && m.loopEnd == 6000);
CHECK(m.crossfade == 300);
// And it is NOT the park position, which is what the off->on restore depends on.
const LoopBounds d = defaultLoopBounds(kFrames);
CHECK(!(m.loopStart == d.start && m.loopEnd == d.end));
}
static void testAnUnusableSpanParksWhateverTheEnableSays() {
const LoopBounds d = defaultLoopBounds(kFrames);
// Collapsed, inverted, past the PCM, and negative — the four the engine refuses.
const StoredLoop bad[4] = {storedSpan(true, 500, 500, 0), storedSpan(true, 900, 400, 0),
storedSpan(true, 500, kFrames + 1, 0),
storedSpan(true, -5, 400, 0)};
for (const StoredLoop& s : bad) {
const LoopMarks m = resolveLoopMarks(s, kFrames);
CHECK(!m.hasLoop);
CHECK(m.parked);
CHECK(m.loopStart == d.start && m.loopEnd == d.end);
}
}
static void testTheOverrideSupersedesTheBankIntrinsic() {
StoredLoop s;
s.intrinsic = SampleLoop{true, 100, 200};
s.override_ = SampleLoop{true, 4000, 6000};
const LoopMarks m = resolveLoopMarks(s, kFrames);
CHECK(m.hasLoop && m.loopStart == 4000 && m.loopEnd == 6000);
// With no override the intrinsic is what the band shows.
s.override_.reset();
const LoopMarks i = resolveLoopMarks(s, kFrames);
CHECK(i.hasLoop && i.loopStart == 100 && i.loopEnd == 200);
// An intrinsic that itself says "no loop" is not a span to adopt.
s.intrinsic = SampleLoop{false, 100, 200};
CHECK(resolveLoopMarks(s, kFrames).parked);
}
// --- the four gestures onto hasLoop --------------------------------------------
// Gesture 1: the enable clicked ON. Span and crossfade retained as-is.
static void testEnableOnKeepsTheSpanAndCrossfade() {
LoopMarks m = resolveLoopMarks(storedSpan(false, 4000, 6000, 300), kFrames);
m.hasLoop = true;
const LoopMarks after = writeThenRead(m);
CHECK(after.hasLoop);
CHECK(after.loopStart == 4000 && after.loopEnd == 6000);
CHECK(after.crossfade == 300);
}
// Gesture 2: the enable clicked OFF. Span and crossfade retained — the whole point of it being
// a toggle rather than a delete button.
static void testEnableOffRetainsTheSpanAndCrossfade() {
LoopMarks m = resolveLoopMarks(storedSpan(true, 4000, 6000, 300), kFrames);
m.hasLoop = false;
const LoopMarks after = writeThenRead(m);
CHECK(!after.hasLoop);
CHECK(!after.parked);
CHECK(after.loopStart == 4000 && after.loopEnd == 6000);
CHECK(after.crossfade == 300);
}
// The acceptance criterion in one assertion: off then on restores the loop EXACTLY.
static void testOffThenOnRestoresTheLoopExactly() {
const LoopMarks before = resolveLoopMarks(storedSpan(true, 4000, 6000, 300), kFrames);
LoopMarks off = before;
off.hasLoop = false;
LoopMarks mid = writeThenRead(off);
mid.hasLoop = true;
const LoopMarks back = writeThenRead(mid);
CHECK(back.hasLoop == before.hasLoop);
CHECK(back.loopStart == before.loopStart && back.loopEnd == before.loopEnd);
CHECK(back.crossfade == before.crossfade);
CHECK(back.parked == before.parked);
}
// Gesture 3: the span collapsed onto itself. OFF, span destroyed and re-parked, crossfade zeroed.
static void testCollapsingTheSpanTurnsItOffReparksAndZeroesTheCrossfade() {
LoopMarks m = resolveLoopMarks(storedSpan(true, 4000, 6000, 300), kFrames);
m.loopEnd = m.loopStart; // the drag that lands one mark on the other
const LoopWrite w = applyLoopMarks(m);
CHECK(!w.loop.hasLoop);
CHECK(w.crossfade == 0);
const LoopMarks after = writeThenRead(m);
const LoopBounds d = defaultLoopBounds(kFrames);
CHECK(!after.hasLoop && after.parked);
CHECK(after.loopStart == d.start && after.loopEnd == d.end);
CHECK(after.crossfade == 0);
}
// Gesture 4: dragging a loop mark while OFF turns it on, in BOTH off-states.
static void testDraggingALoopMarkWhileOffTurnsItOn() {
// Never set: the pair is parked, and the drag takes it off the park.
LoopMarks parked = resolveLoopMarks(StoredLoop{}, kFrames);
CHECK(parked.parked && !parked.hasLoop);
parked.loopStart = 3000; // the drag
parked.hasLoop = true;
const LoopMarks fromParked = writeThenRead(parked);
CHECK(fromParked.hasLoop && !fromParked.parked);
CHECK(fromParked.loopStart == 3000);
// Span retained: the drag turns it on at the dragged positions, crossfade retained.
LoopMarks retained = resolveLoopMarks(storedSpan(false, 4000, 6000, 300), kFrames);
CHECK(!retained.parked && !retained.hasLoop);
retained.loopEnd = 7000;
retained.hasLoop = true;
const LoopMarks fromRetained = writeThenRead(retained);
CHECK(fromRetained.hasLoop);
CHECK(fromRetained.loopStart == 4000 && fromRetained.loopEnd == 7000);
CHECK(fromRetained.crossfade == 300);
}
// --- the write's own rules ------------------------------------------------------
// Dragging the START marker with the enable off must not turn the loop on, and must not
// destroy the crossfade travelling with the retained span.
static void testEditingTheStartMarkerWhileOffLeavesTheEnableAndCrossfadeAlone() {
LoopMarks m = resolveLoopMarks(storedSpan(false, 4000, 6000, 300), kFrames);
m.start = 512;
const LoopWrite w = applyLoopMarks(m);
CHECK(!w.loop.hasLoop);
CHECK(w.loop.start == 4000 && w.loop.end == 6000);
CHECK(w.crossfade == 300);
CHECK(w.start == 512);
}
// Dragging the START marker while parked (never set) must not turn "DRAG TO SET LOOP" into
// "LOOP OFF" — the ordinary gesture that exposed the bug, distinct from the retained-span case
// above.
static void testEditingTheStartMarkerWhileParkedStaysParked() {
LoopMarks m = resolveLoopMarks(StoredLoop{}, kFrames);
CHECK(m.parked && !m.hasLoop);
m.start = 512;
const LoopMarks after = writeThenRead(m);
CHECK(!after.hasLoop);
CHECK(after.parked);
const LoopBounds d = defaultLoopBounds(kFrames);
CHECK(after.loopStart == d.start && after.loopEnd == d.end);
CHECK(after.start == 512);
}
static void testANegativeCrossfadeNeverReachesTheStore() {
LoopMarks m = resolveLoopMarks(storedSpan(true, 4000, 6000, 0), kFrames);
m.crossfade = -1;
CHECK(applyLoopMarks(m).crossfade == 0);
StoredLoop s = storedSpan(true, 4000, 6000, -1);
CHECK(resolveLoopMarks(s, kFrames).crossfade == 0);
}
int main() {
testNothingSetParksOnTheDefaultBoundsAndReadsAsNeverSet();
testAValidSpanSwitchedOffKeepsItsOwnPositions();
testAnUnusableSpanParksWhateverTheEnableSays();
testTheOverrideSupersedesTheBankIntrinsic();
testEnableOnKeepsTheSpanAndCrossfade();
testEnableOffRetainsTheSpanAndCrossfade();
testOffThenOnRestoresTheLoopExactly();
testCollapsingTheSpanTurnsItOffReparksAndZeroesTheCrossfade();
testDraggingALoopMarkWhileOffTurnsItOn();
testEditingTheStartMarkerWhileOffLeavesTheEnableAndCrossfadeAlone();
testEditingTheStartMarkerWhileParkedStaysParked();
testANegativeCrossfadeNeverReachesTheStore();
if (g_fail == 0) {
std::printf("loop_marks: all tests passed\n");
return 0;
}
std::printf("loop_marks: %d failure(s)\n", g_fail);
return 1;
}