fix: close round-2 review findings — Critical silent-note bug plus majors/minors

Fixes the 2-point spline-EG early-free bug causing silent fade-ins, the pitch/filter enable-toggle Trigger-forcing hole, the contour-node/marker pixel shadow, missing deck-residue test coverage, and stale comments in knob_deck and spline_edit.
This commit is contained in:
2026-07-31 23:12:08 -04:00
parent d8ffd860d1
commit c3d67bc3da
12 changed files with 150 additions and 30 deletions
+5
View File
@@ -788,6 +788,11 @@ static void testNonFiniteAhdSecondsLiftToZero() {
// the record is disturbed. Corrupts only the AMP curve's tail; FILTER/PITCH follow at their
// normal, byte-precise offsets, proving a mismatch on one curve does not cascade to its
// neighbours.
//
// Companion, not a regression guard: this in-bounds-mismatch path was already non-wiping
// before the r.ok fix below — it pins the documented promise, not the fix. The two tests that
// follow (OUT-OF-BOUNDS count, and a mid-count truncation) are what actually guard it — both
// tripped the old "reset the whole record to defaults" behavior.
static void testV13HardFlagInBoundsMismatchDropsFlagsOnly() {
ComponentState in;
in.selectionId = "pad";
+26
View File
@@ -565,6 +565,31 @@ 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
}
// 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.
// (Measured from the shipped descriptors, not copied out of a failing run.)
@@ -666,6 +691,7 @@ int main() {
testWrappedDeckHeightAtTheEditorFloorWidth();
testDeckFitsInsideTheEnforcedMinimumWindow();
testNoFaceLeavesSlackWhereItsDroppedControlsWere();
testASyntheticIndivisibleReserveSplitsItsResidueAcrossBothEnds();
testGateModeWidthsAndRowAssignmentAreUnchanged();
testGateSplineGateRoundTripsToTheSameLayout();
testHitTestResolvesTheNewFilterControls();
+34 -3
View File
@@ -156,9 +156,14 @@ static void testHitTest() {
const DeckGroupLayout& tg = tl.groups[0];
CHECK(tg.cells.size() == 3);
for (const DeckCellLayout& c : tg.cells) CHECK(c.id >= 0);
const DeckCellLayout& last = tg.cells.back();
for (int px = tg.cells[0].cell.x; px < last.cell.right(); ++px) {
const DeckHit rowHit = hitTestDeck(tl, px, last.cell.y + 5);
// Bound the sweep against the RESERVED run (5 slots, not the 3 present cells) rather than
// the cells' own extent — the cells are what's under test, so deriving the bound from them
// could never catch a layout that under-covers the run they were reserved out of.
const int runStart = tg.box.x + kDeckGroupPadX;
const int runEnd = runStart + static_cast<int>(trig[0].cellIds.size()) * kDeckCellW;
const int rowY = tg.cells.back().cell.y + 5;
for (int px = runStart; px < runEnd; ++px) {
const DeckHit rowHit = hitTestDeck(tl, px, rowY);
CHECK(rowHit.kind == DeckHitKind::Knob && rowHit.id >= 0);
}
@@ -214,6 +219,31 @@ static void testReservedCellWidthGoesToTheCellsPresent() {
layoutDeck(b, 0, 0, 824).groups[0].rowToggle.seg0);
}
// 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}, {}};
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 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 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();
CHECK(leadPad == expectedResidue / 2);
CHECK(trailPad == expectedResidue - leadPad); // both ends share it, not one absorbing it
}
// The corner radio widens the caption row, takes the far corner, and pushes the caption
// toggle left of itself — the three properties the overlay-select switch relies on.
static void testCaptionRadioGeometryAndHit() {
@@ -307,6 +337,7 @@ int main() {
testGroupInnerGeometry();
testHitTest();
testReservedCellWidthGoesToTheCellsPresent();
testIndivisibleResidueLandsInSymmetricEndMargins();
testCaptionRadioGeometryAndHit();
testInnerDialHit();
testCaptionToggle2();
+24
View File
@@ -321,6 +321,29 @@ static void testAContourReplaysProportionallyOnADifferentLengthSample() {
CHECK(a[shortLen / 2] > a[10] + 0.2);
}
// --- Regression: a 2-point contour starting at 0 is not a terminus at frame 0 -----------
//
// onFinalSegment() (seg_+2==n_) is trivially true for a 2-point contour. Gating the amp
// spline's early-free on that alone reads a contour's OWN opening value as the note's end,
// so the simplest fade-in (left knot dragged to the box floor) went silent at frame 0. The
// fix requires the TERMINAL value (the segment's right endpoint) to be 0, not just the
// segment index.
static void testTwoPointContourRisingFromZeroSoundsForItsFullSpan() {
const VelocityCurve contour =
VelocityCurve::fromPoints({{kVelMin, 0.0}, {kVelMax, 1.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)); // the contour's own value at phase 0 IS 0 ...
CHECK(v.soundingNote()); // ...but the note itself must not be over yet
for (std::size_t i = 1; i < frames / 2; ++i) v.renderFrame();
CHECK(v.soundingNote()); // still sounding at the midpoint, rising toward 1
}
// --- 9. A fresh spline EG opens on the smooth y = 1 - x ----------------------
static void testAFreshSplineEgDefaultsToTheSmoothDownwardSlope() {
@@ -467,6 +490,7 @@ int main() {
testStagedAndSplineStatesBothSurviveAFlipAndASaveReload();
testAV12PayloadLoadsWithoutLoss();
testAContourReplaysProportionallyOnADifferentLengthSample();
testTwoPointContourRisingFromZeroSoundsForItsFullSpan();
testAFreshSplineEgDefaultsToTheSmoothDownwardSlope();
testGateIsUnavailableWhileASplineEgIsActiveAndReturnsAfterwards();
testTheVelocityAmpCurveGainsTheToggleAndKeepsItsDelete();