fix: close round-3 review findings — smallest-target-first, residue test fix, extraction

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.
This commit is contained in:
2026-07-31 23:44:05 -04:00
parent c3d67bc3da
commit 757e1585d6
11 changed files with 255 additions and 87 deletions
+2 -25
View File
@@ -565,30 +565,8 @@ static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() {
}
}
// Every shipped face's reserve divides its present-cell count evenly (see
// testNoFaceLeavesSlackWhereItsDroppedControlsWere), so none of them exercises the
// "residue lands in symmetric end margins" rule knob_deck.cpp documents — only that the
// leftover is small, not where it goes. A synthetic 7-slot reserve with 5 present (336/5,
// remainder 1) forces a real residue and pins it split across BOTH ends.
static void testASyntheticIndivisibleReserveSplitsItsResidueAcrossBothEnds() {
const DeckGroupDesc g{0, 78, {}, {100, 44}, {}, {20, 21, 22, 23, 24, -1, -1}, {}};
const std::vector<DeckGroupDesc> gs{g};
const DeckLayout dl = layoutDeck(gs, 0, 0, kAvailAtMinWidth);
const DeckGroupLayout& lay = dl.groups[0];
CHECK(lay.cells.size() == 5);
const int run = 7 * kDeckCellW;
const int cellW = run / 5; // the same integer division layoutGroup uses
const int residue = run - cellW * 5; // 1: nonzero, unlike every shipped face's reserve
CHECK(residue > 0);
const int covered = lay.cells.back().cell.right() - lay.cells.front().cell.x;
CHECK(run - covered == residue);
const int leadPad = lay.cells.front().cell.x - (lay.box.x + kDeckGroupPadX);
const int trailPad = (lay.box.right() - kDeckGroupPadX) - lay.cells.back().cell.right();
CHECK(leadPad == residue / 2);
CHECK(trailPad == residue - leadPad); // both ends share it, not one cell absorbing it
}
// The "residue lands in symmetric end margins" rule is knob_deck's own (layoutGroup), pinned
// once by its synthetic residue>=2 fixture in test_knob_deck.cpp rather than restated here.
// Gate is the common face and it already packs correctly: pin its group widths and row
// assignment at the floor so a later edit anywhere in the deck cannot reflow it silently.
@@ -691,7 +669,6 @@ int main() {
testWrappedDeckHeightAtTheEditorFloorWidth();
testDeckFitsInsideTheEnforcedMinimumWindow();
testNoFaceLeavesSlackWhereItsDroppedControlsWere();
testASyntheticIndivisibleReserveSplitsItsResidueAcrossBothEnds();
testGateModeWidthsAndRowAssignmentAreUnchanged();
testGateSplineGateRoundTripsToTheSameLayout();
testHitTestResolvesTheNewFilterControls();
+21 -12
View File
@@ -148,8 +148,11 @@ static void testHitTest() {
h = hitTestDeck(dl, voice.rowToggle.seg1.x + 1, voice.rowToggle.seg1.y + 1);
CHECK(h.kind == DeckHitKind::RowToggle && h.id == 104 && h.segment == 1);
// A reserve (id -1) yields no cell of its own, so every point of the knob row lands on a
// real control: no dead rect survives for a grab to fall into.
// A reserve (id -1) yields no cell of its own. This fixture's reserve divides its present
// cells evenly (5 slots / 3 present -> 240/3, no residue), so every point of the knob row
// lands on a real control: no dead rect survives for a grab to fall into. That does NOT
// generalize to an indivisible reserve — a residue leaves a few uncovered margin pixels by
// design (testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds, below).
std::vector<DeckGroupDesc> trig;
trig.push_back({0, 78, {}, {100, 44}, {}, {20, 21, 22, -1, -1}, {}});
const DeckLayout tl = layoutDeck(trig, 0, 0, 824);
@@ -220,26 +223,32 @@ static void testReservedCellWidthGoesToTheCellsPresent() {
}
// The three faces above (240/3, 240/4, 240/1) all divide their run evenly, so none of them
// actually exercises "residue in symmetric end margins" — a 7-slot reserve with 5 present
// (336/5, remainder 1) does, and pins the residue split across BOTH ends rather than only
// the leading one.
static void testIndivisibleResidueLandsInSymmetricEndMargins() {
const DeckGroupDesc g{0, 78, {}, {100, 44}, {}, {20, 21, 22, 23, 24, -1, -1}, {}};
// actually exercises "residue in symmetric end margins". An 8-slot reserve with 5 present
// (384/5 = 76 r4) does: residue 4 is the smallest case that can tell a symmetric split (2/2)
// apart from a trailing-only one (0/4) — a residue of 1 (0/1 vs 1/0... i.e. 0/1) can't, since
// leadPad = residue/2 rounds to 0 either way, which is exactly why this seam's earlier test
// passed without pinning the rule it was named for.
static void testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds() {
const DeckGroupDesc g{0, 78, {}, {100, 44}, {}, {20, 21, 22, 23, 24, -1, -1, -1}, {}};
std::vector<DeckGroupDesc> gs{g};
const DeckLayout dl = layoutDeck(gs, 0, 0, 824);
const DeckGroupLayout& lay = dl.groups[0];
CHECK(lay.cells.size() == 5);
const int run = 7 * kDeckCellW;
const int run = 8 * kDeckCellW;
const int present = 5;
const int cellW = run / present; // 67: the same integer division the layout uses
const int expectedResidue = run - cellW * present; // 1: the case the even-dividing faces can't reach
CHECK(expectedResidue > 0);
const int cellW = run / present; // 76: the same integer division the layout uses
const int expectedResidue = run - cellW * present; // 4
CHECK(expectedResidue == 4);
const int covered = lay.cells.back().cell.right() - lay.cells.front().cell.x;
CHECK(run - covered == expectedResidue);
const int leadPad = lay.cells.front().cell.x - (lay.box.x + kDeckGroupPadX);
const int trailPad = (lay.box.right() - kDeckGroupPadX) - lay.cells.back().cell.right();
// Hard literals, not just the formula: this is the case that actually distinguishes
// symmetric (2/2) from trailing-only (0/4) — see the comment above.
CHECK(leadPad == 2);
CHECK(trailPad == 2);
CHECK(leadPad == expectedResidue / 2);
CHECK(trailPad == expectedResidue - leadPad); // both ends share it, not one absorbing it
}
@@ -337,7 +346,7 @@ int main() {
testGroupInnerGeometry();
testHitTest();
testReservedCellWidthGoesToTheCellsPresent();
testIndivisibleResidueLandsInSymmetricEndMargins();
testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds();
testCaptionRadioGeometryAndHit();
testInnerDialHit();
testCaptionToggle2();
+42
View File
@@ -344,6 +344,25 @@ static void testTwoPointContourRisingFromZeroSoundsForItsFullSpan() {
CHECK(v.soundingNote()); // still sounding at the midpoint, rising toward 1
}
// The positive direction of the fix above: a contour whose final segment is flat at 0 (here,
// the whole two-point span) DOES free the voice early, on its very first tick. Nothing exercises
// this without it — a future tightening of the gate (e.g. requiring more than onFinalSegment() +
// segmentEndValue()) could silently turn the early-free off, which is a performance regression
// (a ringing but silent voice) rather than an audible one, so nothing else would catch it.
static void testFlatZeroFinalSegmentStillFreesTheVoiceEarly() {
const VelocityCurve contour =
VelocityCurve::fromPoints({{kVelMin, 0.0}, {kVelMax, 0.0}}, CurveDomain::Unipolar);
const std::size_t frames = 1000;
const SampleData s = splineAmpSample(frames, contour);
Voice v;
v.start(60, 100, s);
CHECK(v.soundingNote()); // fresh note: sounding before anything is rendered
const double y0 = v.renderFrame();
CHECK(near(y0, 0.0, 1e-9));
CHECK(!v.soundingNote()); // a genuine permanent terminus, not a mid-contour dip
}
// --- 9. A fresh spline EG opens on the smooth y = 1 - x ----------------------
static void testAFreshSplineEgDefaultsToTheSmoothDownwardSlope() {
@@ -424,6 +443,27 @@ static void testGateIsUnavailableWhileASplineEgIsActiveAndReturnsAfterwards() {
CHECK(!deckKnobInert(DeckParam::kFilterModAmt, gates));
}
// enforceGateUnavailableWhileDrawn (play_params.h) is the ONE enforcement resolvePlay and the
// editor's applyControl both call — resolvePlay's own coverage above only exercises it through
// the frames mirror; pin it directly over BOTH representations it is shared between, closing the
// coverage gap the extraction was for (applyControl has no shell test target of its own).
static void testEnforceGateUnavailableWhileDrawnForcesTriggerOnBothRepresentations() {
PlaySeconds seconds;
seconds.playMode = PlayMode::Gate;
enforceGateUnavailableWhileDrawn(seconds);
CHECK(seconds.playMode == PlayMode::Gate); // not splineActive -> untouched
seconds.ampSpline.mode = EnvMode::Spline;
enforceGateUnavailableWhileDrawn(seconds);
CHECK(seconds.playMode == PlayMode::Trigger);
PlayParams frames;
frames.playMode = PlayMode::Gate;
frames.filter.enabled = true;
frames.filterSpline.mode = EnvMode::Spline;
enforceGateUnavailableWhileDrawn(frames);
CHECK(frames.playMode == PlayMode::Trigger);
}
// --- 11. The velocity->amp curve is the same grammar -------------------------
static void testTheVelocityAmpCurveGainsTheToggleAndKeepsItsDelete() {
@@ -491,8 +531,10 @@ int main() {
testAV12PayloadLoadsWithoutLoss();
testAContourReplaysProportionallyOnADifferentLengthSample();
testTwoPointContourRisingFromZeroSoundsForItsFullSpan();
testFlatZeroFinalSegmentStillFreesTheVoiceEarly();
testAFreshSplineEgDefaultsToTheSmoothDownwardSlope();
testGateIsUnavailableWhileASplineEgIsActiveAndReturnsAfterwards();
testEnforceGateUnavailableWhileDrawnForcesTriggerOnBothRepresentations();
testTheVelocityAmpCurveGainsTheToggleAndKeepsItsDelete();
testSplineCursorBinarySearchAgreesWithTheColdReaderOnAJumpingRead();
if (g_fail == 0) std::printf("spline_egs: all tests passed\n");
+103 -1
View File
@@ -13,6 +13,7 @@
// 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>
@@ -21,6 +22,7 @@
using namespace reasampler;
using namespace reasampler::instrument::ui;
using namespace reasampler::instrument::engine;
using reasampler::audio::AudioSample;
static int g_fail = 0;
@@ -379,10 +381,107 @@ static void testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge() {
// 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 asking the handle first, same as it does for the zero-fade/loop-start case.
// 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() {
@@ -458,6 +557,9 @@ int main() {
testMarkerHandleClipsIntoTheArea();
testMarkerHandleOnDegenerateAreas();
testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge();
testFreshRampDownEndpointBeatsTheStartMarkerAtFrameZero();
testCrossfadeTabBeatsAContourNodeNearItsTopStrip();
testContourNodeBeatsALoopMarkerAtTheirSharedPixelButNotElsewhere();
testAsymmetricStereoLanesCarryDifferentContent();
testLaneEnvelopeRejectsOutOfRangeLane();