Merge Γ-W2-T2: an explicit loop enable, four named marks with grabbable caps, and the crossfade painted where it is heard
This commit is contained in:
@@ -1105,6 +1105,27 @@ static void testLoopSpanAndCrossfadeRoundTrip() {
|
||||
CHECK(out.params.rootOverride && *out.params.rootOverride == 55);
|
||||
}
|
||||
|
||||
// The loop ENABLE is `hasLoop`, and the block writes start/end unconditionally — so the wire
|
||||
// already carries "off, with a span remembered." Nothing about the format changes to make the
|
||||
// enable user-owned; this pins that the off state and its retained span both survive a reload,
|
||||
// because an off that came back as a re-parked default would be a delete button, not a toggle.
|
||||
static void testAnOffLoopRoundTripsWithItsSpanAndCrossfadeRetained() {
|
||||
ComponentState in;
|
||||
in.selectionId = "pad";
|
||||
SampleLoop lp;
|
||||
lp.hasLoop = false;
|
||||
lp.start = 4096;
|
||||
lp.end = 65536;
|
||||
in.params.loopOverride = lp;
|
||||
in.params.loopCrossfadeFrames = 1024;
|
||||
|
||||
const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0);
|
||||
CHECK(out.params.loopOverride && !out.params.loopOverride->hasLoop);
|
||||
CHECK(out.params.loopOverride && out.params.loopOverride->start == 4096);
|
||||
CHECK(out.params.loopOverride && out.params.loopOverride->end == 65536);
|
||||
CHECK(out.params.loopCrossfadeFrames == 1024);
|
||||
}
|
||||
|
||||
// A negative fade cannot mean anything and would only reach resolveLoop's clamp; refusing it
|
||||
// at the wire keeps the parameter set the editor reads back sane.
|
||||
static void testNegativeCrossfadeOnTheWireLiftsToZero() {
|
||||
@@ -2155,6 +2176,7 @@ int main() {
|
||||
testDefaultStateRoundTripsToDefaults();
|
||||
testEnvelopePrefixBytesFrozen();
|
||||
testLoopSpanAndCrossfadeRoundTrip();
|
||||
testAnOffLoopRoundTripsWithItsSpanAndCrossfadeRetained();
|
||||
testNegativeCrossfadeOnTheWireLiftsToZero();
|
||||
testPriorPayloadVersionsLiftToAHardSeam();
|
||||
testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact();
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
//
|
||||
// Covers: the chrome band's two rows (toolbar over strip row, tiling the band exactly); the
|
||||
// toolbar's fixed right-anchored run in order (Hold, bake, preview, velocity cell,
|
||||
// Mono|Stereo, Browse) with the title taking the remainder; the velocity and Hold knobs
|
||||
// Loop Off|On, Mono|Stereo, Browse) with the title taking the remainder and still holding its
|
||||
// text at the editor's floor; the velocity and Hold knobs
|
||||
// centred in their cells above their labels; the piano strip owning its whole row at every
|
||||
// width; no rect on the
|
||||
// toolbar overlapping any other; degenerate bands yielding no inverted rects; and the preview
|
||||
@@ -58,7 +59,10 @@ static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() {
|
||||
CHECK(r.navBrowse.width == kNavButtonWidth);
|
||||
CHECK(r.chanStereo.right() <= r.navBrowse.x);
|
||||
CHECK(r.chanMono.right() == r.chanStereo.x);
|
||||
CHECK(r.velCell.right() <= r.chanMono.x);
|
||||
CHECK(r.loopOn.right() <= r.chanMono.x); // the enable is immediately left of Mono|Stereo
|
||||
CHECK(r.loopOff.right() == r.loopOn.x); // its two segments abut, like the channel pair
|
||||
CHECK(r.loopOff.y == r.chanMono.y && r.loopOff.height == r.chanMono.height);
|
||||
CHECK(r.velCell.right() <= r.loopOff.x);
|
||||
CHECK(r.preview.right() <= r.velCell.x);
|
||||
CHECK(r.bake.right() <= r.preview.x);
|
||||
CHECK(r.bake.width == kBakeButtonWidth);
|
||||
@@ -70,8 +74,8 @@ static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() {
|
||||
CHECK(r.title.width > 0);
|
||||
|
||||
// Every toolbar rect sits inside the toolbar row.
|
||||
const Rect items[] = {r.title, r.holdCell, r.bake, r.preview, r.velCell, r.chanMono,
|
||||
r.chanStereo, r.navBrowse};
|
||||
const Rect items[] = {r.title, r.holdCell, r.bake, r.preview, r.velCell, r.loopOff,
|
||||
r.loopOn, r.chanMono, r.chanStereo, r.navBrowse};
|
||||
for (const Rect& it : items) {
|
||||
CHECK(it.y >= r.toolbar.y && it.bottom() <= r.toolbar.bottom());
|
||||
}
|
||||
@@ -84,8 +88,8 @@ static void testChromePartsNeverOverlapAtAnyWidth() {
|
||||
// stay inside its own row, clear of every control.
|
||||
CHECK(!overlaps(r.toolbar, r.rootStrip));
|
||||
CHECK(r.rootStrip.y >= r.controls.y && r.rootStrip.bottom() <= r.controls.bottom());
|
||||
const Rect items[] = {r.holdCell, r.bake, r.preview, r.velCell, r.chanMono,
|
||||
r.chanStereo, r.navBrowse};
|
||||
const Rect items[] = {r.holdCell, r.bake, r.preview, r.velCell, r.loopOff, r.loopOn,
|
||||
r.chanMono, r.chanStereo, r.navBrowse};
|
||||
for (const Rect& it : items) {
|
||||
CHECK(!overlaps(it, r.rootStrip));
|
||||
CHECK(!overlaps(it, r.title));
|
||||
@@ -147,6 +151,21 @@ static void testHoldCellIsReservedAndFollowsTheVelocityCellGrammar() {
|
||||
CHECK(wide.holdCell.width == r.holdCell.width);
|
||||
}
|
||||
|
||||
// The enable joins a RIGHT-ANCHORED run, so it is charged to the title slot and not to the
|
||||
// window. kEditorMinWidth itself is guarded by test_deck_groups' derived-floor assertion — this
|
||||
// is the other half of that contract: the title must still hold its text AT that floor, because
|
||||
// the agreed remedy if it cannot is to narrow the enable's segments, never to move the floor.
|
||||
static void testTheControlRunLeavesTheTitleReadableAtTheEditorFloor() {
|
||||
const ChromeRects r = chromeRects(chromeBand(), kKnob);
|
||||
// "ReaSampler 9000" (15 chars) plus a bracketed 20-char capture name, at an assumed 7 px/char
|
||||
// for the toolbar font: 38 * 7. A HEURISTIC, not a measured Segoe UI metric — no font-metric
|
||||
// measurement backs this number; it gates a phase-wide acceptance criterion regardless.
|
||||
constexpr int kTitleTextFloorPx = 266;
|
||||
CHECK(r.title.width >= kTitleTextFloorPx);
|
||||
// Nothing in the run reaches into the title's slot.
|
||||
CHECK(r.title.right() <= r.holdCell.x);
|
||||
}
|
||||
|
||||
static void testDegenerateBandYieldsNoInvertedRects() {
|
||||
const ChromeRects empty = chromeRects(Rect{}, kKnob);
|
||||
CHECK(empty.toolbar.empty() && empty.controls.empty());
|
||||
@@ -157,8 +176,8 @@ static void testDegenerateBandYieldsNoInvertedRects() {
|
||||
kKnob);
|
||||
const Rect items[] = {tiny.title, tiny.holdCell, tiny.holdKnob, tiny.holdLabel,
|
||||
tiny.bake, tiny.preview, tiny.velCell, tiny.velKnob, tiny.velLabel,
|
||||
tiny.chanMono, tiny.chanStereo, tiny.navBrowse,
|
||||
tiny.rootStrip};
|
||||
tiny.loopOff, tiny.loopOn, tiny.chanMono, tiny.chanStereo,
|
||||
tiny.navBrowse, tiny.rootStrip};
|
||||
for (const Rect& it : items) CHECK(it.right() >= it.x && it.bottom() >= it.y);
|
||||
}
|
||||
|
||||
@@ -206,6 +225,7 @@ int main() {
|
||||
testStripOwnsItsWholeRowAndGrowsWithTheWindow();
|
||||
testVelocityKnobIsCentredInItsCellAboveTheLabel();
|
||||
testHoldCellIsReservedAndFollowsTheVelocityCellGrammar();
|
||||
testTheControlRunLeavesTheTitleReadableAtTheEditorFloor();
|
||||
testDegenerateBandYieldsNoInvertedRects();
|
||||
testPreviewGlyphSitsInsideTheButtonAndPointsRight();
|
||||
testPreviewGlyphDegradesRatherThanOverflowing();
|
||||
|
||||
@@ -107,7 +107,7 @@ static void testOverlayBoxIsTheWholeArea() {
|
||||
// --- 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
|
||||
// pick box), a mark's CAP, and a mark'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
|
||||
@@ -215,7 +215,45 @@ static void testContourNodeBeatsALoopMarkerAtTheirSharedPixelButNotElsewhere() {
|
||||
WaveformClaimant::kMarker);
|
||||
}
|
||||
|
||||
// The only live tie: the crossfade tab (<=110) can equal the node (169) only off-geometry, but
|
||||
// Case (d): every mark now carries a cap, which RESOLVES the long-open "staged-envelope-node
|
||||
// shadow at zero-attack" wart. A zero-attack AttackEnd node sits at the canvas's top-left — the
|
||||
// same pixel a START marker at frame 0 draws at — and used to win the click outright, because
|
||||
// the marker's only target there was its full-height column. START's cap is the same 11x10 tab
|
||||
// the crossfade always had, so the node no longer shadows it. The cap slot's nominal area is
|
||||
// unchanged by the change (every mark's cap is one markerHandleRect), which is why the
|
||||
// arbitration itself needed no re-tuning: cap < node < column still holds.
|
||||
static void testAMarkCapOutranksACoincidentEnvelopeNodeInTheTopStrip() {
|
||||
const Rect a = Rect{20, 10, 1000, kWaveformMinHeight};
|
||||
const OverlayArea overlay = overlayOf(a);
|
||||
const std::int64_t frames = 100000;
|
||||
// START at frame 0: its cap clips against the band's left edge, and a zero-attack node is
|
||||
// drawn on that same corner.
|
||||
const Rect cap = markerHandleRect(overlay, frames, 0);
|
||||
CHECK(!cap.empty());
|
||||
CHECK(contains(cap, a.x, a.y));
|
||||
|
||||
// NOMINAL, matching what the shell feeds the arbitration — the clipped tab at frame 0 is
|
||||
// the worst case for the cap, and it still wins on the nominal number the shell uses.
|
||||
const std::int64_t capArea =
|
||||
static_cast<std::int64_t>(2 * kMarkerHandleHalfWidth + 1) * kMarkerHandleHeight;
|
||||
const std::int64_t columnArea =
|
||||
static_cast<std::int64_t>(2 * kMarkerGrabWidth + 1) * a.height;
|
||||
CHECK(capArea < kNodeArea);
|
||||
CHECK(kNodeArea < columnArea);
|
||||
|
||||
const WaveformClaim node{true, kNodeArea};
|
||||
const WaveformClaim capClaim{true, capArea};
|
||||
const WaveformClaim column{true, columnArea};
|
||||
CHECK(resolveWaveformClaim(node, capClaim, column, SplineGesture::kLeft) ==
|
||||
WaveformClaimant::kTab);
|
||||
// And the node keeps everything below the cap strip, which is where it is actually drawn
|
||||
// for any non-degenerate envelope.
|
||||
CHECK(!contains(cap, a.x, a.y + kMarkerHandleHeight));
|
||||
CHECK(resolveWaveformClaim(node, WaveformClaim{}, column, SplineGesture::kLeft) ==
|
||||
WaveformClaimant::kNode);
|
||||
}
|
||||
|
||||
// The only live tie: a mark's cap (<=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() {
|
||||
@@ -269,6 +307,7 @@ int main() {
|
||||
testFreshRampDownEndpointBeatsTheStartMarkerAtFrameZero();
|
||||
testCrossfadeTabBeatsAContourNodeNearItsTopStrip();
|
||||
testContourNodeBeatsALoopMarkerAtTheirSharedPixelButNotElsewhere();
|
||||
testAMarkCapOutranksACoincidentEnvelopeNodeInTheTopStrip();
|
||||
testTabWinsAGenuineTabVersusMarkerTie();
|
||||
testNoHitAnywhereFallsThroughToNone();
|
||||
testAMissedCandidateNeverWinsOnADegenerateZeroArea();
|
||||
|
||||
@@ -269,6 +269,25 @@ static void testCardNameScrimClearsBodyFloorOnItsWorstBackground() {
|
||||
< textFloor(TextClass::Body));
|
||||
}
|
||||
|
||||
// The waveform band's state caption and its four mark labels (editor_paint_waveform.cpp) draw
|
||||
// text/dim (the caption, the non-promoted labels) and text/primary (the promoted label) — both
|
||||
// Font::Micro, both body class — over a bg/base scrim at kWaveformLabelScrimAlpha, itself drawn
|
||||
// over whatever drawEnvelope left in that rect. Unlike the card name strip, the worst case here
|
||||
// is text/DIM, not text/primary, so the alpha is higher than kCardNameScrimAlpha's — pin both
|
||||
// roles against the worst background (a full-scale envelope peak, accent/primary) and pin the
|
||||
// defect the scrim exists to close.
|
||||
static void testWaveformLabelScrimClearsBodyFloorOnItsWorstBackground() {
|
||||
const KitColor scrim = roleColor(Role::BgBase);
|
||||
const KitColor onFill = compositeOver(scrim, roleColor(Role::AccentPrimary),
|
||||
kWaveformLabelScrimAlpha);
|
||||
CHECK(contrastRatio(roleColor(Role::TextDim), onFill) >= textFloor(TextClass::Body));
|
||||
CHECK(contrastRatio(roleColor(Role::TextPrimary), onFill) >= textFloor(TextClass::Body));
|
||||
// Without the scrim, text/dim on the bare accent-lime fill is UNDER floor (~1.58:1) — pins
|
||||
// the defect the scrim exists to close, so a future removal of the scrim fails this first.
|
||||
CHECK(contrastRatio(roleColor(Role::TextDim), roleColor(Role::AccentPrimary))
|
||||
< textFloor(TextClass::Body));
|
||||
}
|
||||
|
||||
// --- Single point of change (structural guarantee) ----------------------------
|
||||
//
|
||||
// roleColor is the ONLY color source; there is no other public accessor that yields a
|
||||
@@ -366,6 +385,7 @@ int main() {
|
||||
testTextOnPastelFillClearsBodyFloor();
|
||||
testTextOnHoverSurfaceClearsFloor();
|
||||
testCardNameScrimClearsBodyFloorOnItsWorstBackground();
|
||||
testWaveformLabelScrimClearsBodyFloorOnItsWorstBackground();
|
||||
testCurveTraceOnHoverSurfaceClearsFloor();
|
||||
testSecondaryTertiaryAreDistinguishable();
|
||||
testOverlayTraceClearsIndicatorFloorOnTheWaveform();
|
||||
|
||||
+204
-12
@@ -8,7 +8,10 @@
|
||||
// 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
|
||||
// no-crossing keeps target, target clamp, degenerate buffers); the four marks (per-mark cap
|
||||
// resolve, the reverse cap order that keeps a coincident pair separable, label sides/nudging,
|
||||
// the suppression rule and its promoted-first placement, the crossfade wedge ramp);
|
||||
// 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).
|
||||
|
||||
@@ -361,18 +364,18 @@ static void testMarkerHandleOnDegenerateAreas() {
|
||||
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.
|
||||
// The shell (editor_input_waveform.cpp) resolves a mark's CAP before it iterates the ordinary
|
||||
// marker array, because a zero-length fade puts the crossfade cap exactly on the loop-end
|
||||
// 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 std::int64_t loopEnd = 400, crossfade = 30;
|
||||
const std::int64_t fadeEdge = loopEnd - crossfade; // where the crossfade cap sits
|
||||
const std::int64_t markers[3] = {fadeEdge, 200, loopEnd}; // start dialled onto the fade edge
|
||||
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
|
||||
@@ -380,10 +383,188 @@ static void testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge() {
|
||||
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.
|
||||
// target), same as it does for the zero-fade/loop-end case.
|
||||
CHECK(contains(markerHandleRect(overlayOf(a), 1000, fadeEdge), mx, topY));
|
||||
}
|
||||
|
||||
// --- The four marks: cap resolve, labels, suppression, crossfade wedge ----------
|
||||
|
||||
static WaveMarks marksAt(std::int64_t start, std::int64_t loopStart, std::int64_t loopEnd,
|
||||
std::int64_t xfade, bool loopPresent) {
|
||||
WaveMarks m;
|
||||
m.frame[0] = start;
|
||||
m.frame[1] = loopStart;
|
||||
m.frame[2] = loopEnd;
|
||||
m.frame[3] = xfade;
|
||||
m.present[0] = true;
|
||||
m.present[1] = m.present[2] = m.present[3] = loopPresent;
|
||||
return m;
|
||||
}
|
||||
|
||||
static void testEveryMarkAnswersItsOwnCap() {
|
||||
const Rect a = wideArea();
|
||||
const OverlayArea ov = overlayOf(a);
|
||||
const WaveMarks m = marksAt(50, 300, 700, 620, true);
|
||||
for (int i = 0; i < kWaveMarkCount; ++i) {
|
||||
const int mx = frameToX(ov, 1000, m.frame[i]);
|
||||
CHECK(capAtPoint(ov, 1000, m, mx, a.y) == i);
|
||||
CHECK(capAtPoint(ov, 1000, m, mx, a.y + kMarkerHandleHeight - 1) == i);
|
||||
// Below the cap strip is the column's, never the cap's.
|
||||
CHECK(capAtPoint(ov, 1000, m, mx, a.y + kMarkerHandleHeight) == -1);
|
||||
}
|
||||
}
|
||||
|
||||
static void testAMarkThatIsNotPresentAnswersNoCap() {
|
||||
const Rect a = wideArea();
|
||||
const OverlayArea ov = overlayOf(a);
|
||||
const WaveMarks m = marksAt(50, 300, 700, 620, /*loopPresent=*/false);
|
||||
CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 300), a.y) == -1);
|
||||
CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 620), a.y) == -1);
|
||||
CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 50), a.y) == 0); // START stays live
|
||||
}
|
||||
|
||||
// The separability argument the reverse cap order exists for: for a coincident PAIR, one mark
|
||||
// answers the cap and the OTHER answers the full-height column, so neither is ever stranded.
|
||||
static void testACoincidentPairStaysSeparableAcrossCapAndColumn() {
|
||||
const Rect a = wideArea();
|
||||
const OverlayArea ov = overlayOf(a);
|
||||
const int midY = a.y + a.height / 2;
|
||||
// Zero-length fade: the crossfade mark sits at loopEnd - 0, i.e. exactly on the END marker.
|
||||
// This is the live case — the crossfade is anchored to the seam it closes.
|
||||
{
|
||||
const WaveMarks m = marksAt(50, 300, 700, 700, true);
|
||||
const int mx = frameToX(ov, 1000, 700);
|
||||
CHECK(capAtPoint(ov, 1000, m, mx, a.y) == static_cast<int>(WaveMark::kCrossfade));
|
||||
const std::int64_t cols[3] = {m.frame[0], m.frame[1], m.frame[2]};
|
||||
CHECK(markerAtPoint(ov, 1000, cols, 3, mx, midY) == static_cast<int>(WaveMark::kLoopEnd));
|
||||
}
|
||||
// START dragged onto the loop start: the cap goes to LOOP, the column to START.
|
||||
{
|
||||
const WaveMarks m = marksAt(300, 300, 700, 100, true);
|
||||
const int mx = frameToX(ov, 1000, 300);
|
||||
CHECK(capAtPoint(ov, 1000, m, mx, a.y) == static_cast<int>(WaveMark::kLoopStart));
|
||||
const std::int64_t cols[3] = {m.frame[0], m.frame[1], m.frame[2]};
|
||||
CHECK(markerAtPoint(ov, 1000, cols, 3, mx, midY) == static_cast<int>(WaveMark::kStart));
|
||||
}
|
||||
// START dragged onto the loop end: the cap goes to END, the column to START.
|
||||
{
|
||||
const WaveMarks m = marksAt(700, 300, 700, 100, true);
|
||||
const int mx = frameToX(ov, 1000, 700);
|
||||
CHECK(capAtPoint(ov, 1000, m, mx, a.y) == static_cast<int>(WaveMark::kLoopEnd));
|
||||
const std::int64_t cols[3] = {m.frame[0], m.frame[1], m.frame[2]};
|
||||
CHECK(markerAtPoint(ov, 1000, cols, 3, mx, midY) == static_cast<int>(WaveMark::kStart));
|
||||
}
|
||||
}
|
||||
|
||||
// The crossfade is the one mark with NO full-height column, so it must never lose a cap tie.
|
||||
static void testTheCrossfadeCapOutranksEveryOtherMark() {
|
||||
const Rect a = wideArea();
|
||||
const OverlayArea ov = overlayOf(a);
|
||||
const WaveMarks m = marksAt(400, 400, 400, 400, true); // every mark on one frame
|
||||
CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 400), a.y) ==
|
||||
static_cast<int>(WaveMark::kCrossfade));
|
||||
}
|
||||
|
||||
static void testLabelSidesKeepEachLabelOutOfTheSpanItBounds() {
|
||||
CHECK(!markLabelLeftOfLine(WaveMark::kStart));
|
||||
CHECK(!markLabelLeftOfLine(WaveMark::kLoopStart));
|
||||
CHECK(markLabelLeftOfLine(WaveMark::kLoopEnd));
|
||||
CHECK(markLabelLeftOfLine(WaveMark::kCrossfade));
|
||||
|
||||
const Rect a = wideArea();
|
||||
const OverlayArea ov = overlayOf(a);
|
||||
const int mx = frameToX(ov, 1000, 500);
|
||||
const Rect right = markLabelRect(ov, 1000, 500, /*leftOfLine=*/false, 30);
|
||||
const Rect left = markLabelRect(ov, 1000, 500, /*leftOfLine=*/true, 30);
|
||||
CHECK(right.x == mx + kMarkLabelGap && right.width == 30);
|
||||
CHECK(left.right() == mx - kMarkLabelGap && left.width == 30);
|
||||
// Directly under the cap strip, so caps and labels never fight for the same pixels.
|
||||
CHECK(right.y == a.y + kMarkerHandleHeight && right.height == kMarkLabelHeight);
|
||||
CHECK(left.y == right.y);
|
||||
}
|
||||
|
||||
static void testALabelIsNudgedInsideTheAreaRatherThanClipped() {
|
||||
const Rect a = wideArea();
|
||||
const OverlayArea ov = overlayOf(a);
|
||||
// At frame 0 a right-side label would still fit; at the last frame it would overhang.
|
||||
const Rect atEnd = markLabelRect(ov, 1000, 1000, /*leftOfLine=*/false, 40);
|
||||
CHECK(atEnd.width == 40);
|
||||
CHECK(atEnd.right() == a.right());
|
||||
const Rect atStart = markLabelRect(ov, 1000, 0, /*leftOfLine=*/true, 40);
|
||||
CHECK(atStart.width == 40);
|
||||
CHECK(atStart.x == a.x);
|
||||
// Wider than the whole band, or no band to draw in: nothing placed.
|
||||
CHECK(markLabelRect(ov, 1000, 500, false, a.width + 1).empty());
|
||||
CHECK(markLabelRect(overlayOf(Rect{0, 0, 200, kMarkerHandleHeight}), 1000, 500, false, 20)
|
||||
.empty());
|
||||
}
|
||||
|
||||
static void testOverlappingLabelsAreSuppressedInPlacementOrder() {
|
||||
const Rect a = wideArea();
|
||||
const OverlayArea ov = overlayOf(a);
|
||||
// LOOP labels right of its line at 500, XFADE left of its line at 520: the two boxes point
|
||||
// at each other and cannot both fit. (LOOP and END never collide however close they get —
|
||||
// their labels point away from the span they bound.)
|
||||
const WaveMarks m = marksAt(50, 500, 900, 520, true);
|
||||
const int w[kWaveMarkCount] = {36, 32, 26, 40};
|
||||
const WaveMarkLabels lab = layoutMarkLabels(ov, 1000, m, w, /*promoted=*/-1);
|
||||
CHECK(!lab.box[0].empty()); // START, far away, always placed
|
||||
CHECK(!lab.box[1].empty()); // LOOP placed before XFADE, so LOOP wins
|
||||
CHECK(!lab.box[2].empty()); // END, far away, always placed
|
||||
CHECK(lab.box[3].empty()); // XFADE suppressed
|
||||
// Every placed box is disjoint from every other.
|
||||
for (int i = 0; i < kWaveMarkCount; ++i) {
|
||||
for (int j = i + 1; j < kWaveMarkCount; ++j) {
|
||||
if (lab.box[i].empty() || lab.box[j].empty()) continue;
|
||||
CHECK(lab.box[i].x >= lab.box[j].right() || lab.box[j].x >= lab.box[i].right());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The promoted mark is placed FIRST, so grabbing or hovering a mark always shows its label —
|
||||
// even the one the resting layout suppresses.
|
||||
static void testThePromotedMarkIsNeverTheSuppressedOne() {
|
||||
const Rect a = wideArea();
|
||||
const OverlayArea ov = overlayOf(a);
|
||||
const WaveMarks m = marksAt(50, 500, 900, 520, true);
|
||||
const int w[kWaveMarkCount] = {36, 32, 26, 40};
|
||||
CHECK(layoutMarkLabels(ov, 1000, m, w, -1).box[3].empty()); // XFADE suppressed at rest
|
||||
const WaveMarkLabels grabbed =
|
||||
layoutMarkLabels(ov, 1000, m, w, static_cast<int>(WaveMark::kCrossfade));
|
||||
CHECK(!grabbed.box[3].empty()); // and placed when it is the one being grabbed
|
||||
CHECK(grabbed.box[1].empty()); // LOOP yields to it instead
|
||||
}
|
||||
|
||||
static void testAbsentMarksTakeNoLabel() {
|
||||
const Rect a = wideArea();
|
||||
const OverlayArea ov = overlayOf(a);
|
||||
const WaveMarks m = marksAt(50, 300, 700, 620, /*loopPresent=*/false);
|
||||
const int w[kWaveMarkCount] = {36, 32, 26, 40};
|
||||
const WaveMarkLabels lab = layoutMarkLabels(ov, 1000, m, w, -1);
|
||||
CHECK(!lab.box[0].empty());
|
||||
CHECK(lab.box[1].empty() && lab.box[2].empty() && lab.box[3].empty());
|
||||
}
|
||||
|
||||
static void testTheCrossfadeWedgeRampsToItsPeakAtTheSeam() {
|
||||
// Zero at the fade's start, the peak at its last column, monotone in between.
|
||||
CHECK(crossfadeWedgeHeight(100, 200, 100) == 0);
|
||||
CHECK(crossfadeWedgeHeight(100, 200, 199) == kCrossfadeWedgePx);
|
||||
int prev = -1;
|
||||
for (int x = 100; x < 200; ++x) {
|
||||
const int h = crossfadeWedgeHeight(100, 200, x);
|
||||
CHECK(h >= prev);
|
||||
CHECK(h >= 0 && h <= kCrossfadeWedgePx);
|
||||
prev = h;
|
||||
}
|
||||
// Outside the span it contributes nothing, so a caller can sweep a wider range safely.
|
||||
CHECK(crossfadeWedgeHeight(100, 200, 99) == 0);
|
||||
CHECK(crossfadeWedgeHeight(100, 200, 200) == 0);
|
||||
// Degenerate spans: an empty one draws nothing, a one-column one is all peak.
|
||||
CHECK(crossfadeWedgeHeight(100, 100, 100) == 0);
|
||||
CHECK(crossfadeWedgeHeight(100, 99, 100) == 0);
|
||||
CHECK(crossfadeWedgeHeight(100, 101, 100) == kCrossfadeWedgePx);
|
||||
}
|
||||
|
||||
// --- Per-lane envelope content -------------------------------------------------
|
||||
|
||||
static void testAsymmetricStereoLanesCarryDifferentContent() {
|
||||
@@ -460,6 +641,17 @@ int main() {
|
||||
testMarkerHandleOnDegenerateAreas();
|
||||
testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge();
|
||||
|
||||
testEveryMarkAnswersItsOwnCap();
|
||||
testAMarkThatIsNotPresentAnswersNoCap();
|
||||
testACoincidentPairStaysSeparableAcrossCapAndColumn();
|
||||
testTheCrossfadeCapOutranksEveryOtherMark();
|
||||
testLabelSidesKeepEachLabelOutOfTheSpanItBounds();
|
||||
testALabelIsNudgedInsideTheAreaRatherThanClipped();
|
||||
testOverlappingLabelsAreSuppressedInPlacementOrder();
|
||||
testThePromotedMarkIsNeverTheSuppressedOne();
|
||||
testAbsentMarksTakeNoLabel();
|
||||
testTheCrossfadeWedgeRampsToItsPeakAtTheSeam();
|
||||
|
||||
testAsymmetricStereoLanesCarryDifferentContent();
|
||||
testLaneEnvelopeRejectsOutOfRangeLane();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user