fix: restore waveform symmetry about the midline, cut deck_values' link to the bank model, and unit-test the column arithmetic
The waveform column's vertical extents move to pure component_geometry so the shared primitive stops being untested; PlaySeconds hoists into a header-only play_seconds target.
This commit is contained in:
@@ -4,8 +4,10 @@
|
||||
// Covers (brief §L1 point 2 + §test cases): button box inset + graceful suppression; slider
|
||||
// track/filled/handle geometry for representative values incl. endpoints, value->px inverse,
|
||||
// too-small/degenerate suppression; list-row rect for representative indices, partial last
|
||||
// row, hover hit-test returns the right row and "no hit" outside/past the last row; and the
|
||||
// shared half-open box hit-test agrees with layout (no double-claimed pixel).
|
||||
// row, hover hit-test returns the right row and "no hit" outside/past the last row; the
|
||||
// shared half-open box hit-test agrees with layout (no double-claimed pixel); and the waveform
|
||||
// column's vertical extents — symmetry about the zero line, monotonicity, and the band clamp
|
||||
// on both the fill and the antialiased stroke, which draw_kit's shell cannot cover.
|
||||
|
||||
#include "../src/core/ui/component_geometry.h"
|
||||
|
||||
@@ -190,6 +192,70 @@ static void testWaveformColumnCount() {
|
||||
CHECK(waveformColumnCount(KitBox{0, 0, 0, 40}) == 0);
|
||||
}
|
||||
|
||||
// --- waveform column span ----------------------------------------------------
|
||||
|
||||
// The regression this exists to catch: rounding applied to the resulting y instead of to the
|
||||
// scaled amplitude draws a symmetric column one pixel taller above the zero line than below.
|
||||
static void testSymmetricColumnDrawsEqualHeightAboveAndBelowTheZeroLine() {
|
||||
const WaveformBand band = waveformBand(0, 41); // odd height -> fractional half-span
|
||||
// Sweep amplitudes whose scaled value is fractional, which is where the two edges can
|
||||
// round in opposite directions.
|
||||
for (int i = 1; i <= 20; ++i) {
|
||||
const double a = i / 20.0;
|
||||
const WaveformColumnSpan s = waveformColumnSpan(band, a, -a);
|
||||
CHECK(band.midY - s.top == s.bottom - band.midY);
|
||||
// The stroke's edges are symmetric to sub-ULP, not bit-exactly: midY +/- x rounds the
|
||||
// two sides independently. Any REAL asymmetry here would be a whole pixel.
|
||||
const double above = band.midY - s.topF;
|
||||
const double below = s.bottomF - band.midY;
|
||||
CHECK(above - below < 1e-9 && below - above < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
// A column with no signal collapses onto the zero line rather than spanning a pixel of it.
|
||||
static void testSilentColumnCollapsesOntoTheZeroLine() {
|
||||
const WaveformBand band = waveformBand(10, 40);
|
||||
const WaveformColumnSpan s = waveformColumnSpan(band, 0.0, 0.0);
|
||||
CHECK(s.top == band.midY && s.bottom == band.midY);
|
||||
CHECK(s.topF == band.midY && s.bottomF == band.midY);
|
||||
}
|
||||
|
||||
// Amplitude grows the span monotonically, and a bigger amplitude never draws shorter.
|
||||
static void testTallerAmplitudeNeverDrawsAShorterColumn() {
|
||||
const WaveformBand band = waveformBand(0, 40);
|
||||
int prevHeight = -1;
|
||||
for (int i = 0; i <= 20; ++i) {
|
||||
const WaveformColumnSpan s = waveformColumnSpan(band, i / 20.0, -(i / 20.0));
|
||||
const int h = s.bottom - s.top;
|
||||
CHECK(h >= prevHeight);
|
||||
prevHeight = h;
|
||||
}
|
||||
}
|
||||
|
||||
// The clamp is the band's boundary for BOTH the fill and the antialiased stroke — a stroke
|
||||
// vertex outside the band would draw into the neighbouring channel's lane.
|
||||
static void testBothEdgesAndTheStrokeClampToTheBand() {
|
||||
const WaveformBand band = waveformBand(100, 40);
|
||||
const int lo = 100;
|
||||
const int hi = 139;
|
||||
// Past full scale in both directions (the display curve's own range is [-1,1], so this is
|
||||
// the defensive case, not a reachable one).
|
||||
const WaveformColumnSpan s = waveformColumnSpan(band, 8.0, -8.0);
|
||||
CHECK(s.top == lo && s.bottom == hi);
|
||||
CHECK(s.topF == lo && s.bottomF == hi);
|
||||
// Full scale sits INSIDE the band by the half-span's 2px inset — the clamp is a guard,
|
||||
// not the thing that produces the normal drawn height.
|
||||
const WaveformColumnSpan full = waveformColumnSpan(band, 1.0, -1.0);
|
||||
CHECK(full.top > lo && full.bottom < hi);
|
||||
}
|
||||
|
||||
static void testBandMetricsMirrorTheDrawnInset() {
|
||||
const WaveformBand b = waveformBand(50, 40);
|
||||
CHECK(b.top == 50 && b.height == 40);
|
||||
CHECK(b.midY == 70);
|
||||
CHECK(b.halfSpan == 18.0); // half the band, less the 2px breathing room
|
||||
}
|
||||
|
||||
int main() {
|
||||
testHitTestBoxHalfOpen();
|
||||
testButtonBoxInset();
|
||||
@@ -208,6 +274,11 @@ int main() {
|
||||
testListRowHitTestBoundedByCount();
|
||||
testListRowLayoutHitAgreement();
|
||||
testWaveformColumnCount();
|
||||
testSymmetricColumnDrawsEqualHeightAboveAndBelowTheZeroLine();
|
||||
testSilentColumnCollapsesOntoTheZeroLine();
|
||||
testTallerAmplitudeNeverDrawsAShorterColumn();
|
||||
testBothEdgesAndTheStrokeClampToTheBand();
|
||||
testBandMetricsMirrorTheDrawnInset();
|
||||
|
||||
if (g_fail == 0) std::printf("component_geometry: all tests passed\n");
|
||||
else std::printf("component_geometry: %d CHECK(s) FAILED\n", g_fail);
|
||||
|
||||
@@ -38,8 +38,12 @@ static void testNormRoundTripsThroughEveryValueDomain() {
|
||||
CHECK(p.trigAhd.holdFraction == 0.75);
|
||||
CHECK(deckParamNorm(DeckParam::kTrigHold, p) == 0.75);
|
||||
|
||||
setDeckParam(DeckParam::kFilterCutoff, p, 0.5, 0);
|
||||
CHECK(deckParamNorm(DeckParam::kFilterCutoff, p) == 0.5);
|
||||
// Named field, not just a round trip: cutoff and morph are both normalized positions with
|
||||
// the same 1.0 default, so a getter+setter pair that swapped them would round-trip cleanly.
|
||||
setDeckParam(DeckParam::kFilterCutoff, p, 0.25, 0);
|
||||
CHECK(p.filter.settings.cutoffNorm == 0.25f);
|
||||
CHECK(p.filter.settings.morphNorm == 1.0f);
|
||||
CHECK(deckParamNorm(DeckParam::kFilterCutoff, p) == 0.25);
|
||||
|
||||
// Bipolar: the centre detent is exact in BOTH directions, so a knob parked at centre
|
||||
// persists no depth at all.
|
||||
@@ -117,8 +121,10 @@ static void testInnerResetLandsOnTheExactLinearNeutral() {
|
||||
CHECK(p.filter.trigEnv.decayCurve == 1.0);
|
||||
}
|
||||
|
||||
// A reset lands on the field's own stored default, exactly — the defaults are read off a fresh
|
||||
// PlaySeconds rather than from a second table.
|
||||
// A reset lands on the field's own stored default, EXACTLY — the defaults are read off a fresh
|
||||
// PlaySeconds and arrive through the norm round trip, so the two stage times whose defaults are
|
||||
// neither 0 nor 1 are the cases that actually exercise that exactness (see resetDeckParam's
|
||||
// note on what the seconds ceiling has to be for it to hold).
|
||||
static void testResetLandsOnTheStoredDefaultOfEachControl() {
|
||||
const PlaySeconds defaults;
|
||||
PlaySeconds p;
|
||||
@@ -126,16 +132,24 @@ static void testResetLandsOnTheStoredDefaultOfEachControl() {
|
||||
setDeckParam(DeckParam::kTrigLength, p, 0.3, 0);
|
||||
setDeckParam(DeckParam::kFilterKeyTrack, p, 0.9, 0);
|
||||
setDeckParam(DeckParam::kPitchEnvDepth, p, 1.0, 0);
|
||||
setDeckParam(DeckParam::kAttack, p, 0.5, 0);
|
||||
setDeckParam(DeckParam::kRelease, p, 0.5, 0);
|
||||
CHECK(p.adsr.attackSeconds != defaults.adsr.attackSeconds);
|
||||
CHECK(p.adsr.releaseSeconds != defaults.adsr.releaseSeconds);
|
||||
|
||||
resetDeckParam(DeckParam::kSustain, p);
|
||||
resetDeckParam(DeckParam::kTrigLength, p);
|
||||
resetDeckParam(DeckParam::kFilterKeyTrack, p);
|
||||
resetDeckParam(DeckParam::kPitchEnvDepth, p);
|
||||
resetDeckParam(DeckParam::kAttack, p);
|
||||
resetDeckParam(DeckParam::kRelease, p);
|
||||
|
||||
CHECK(p.adsr.sustainLevel == defaults.adsr.sustainLevel);
|
||||
CHECK(p.trigger.lengthFraction == defaults.trigger.lengthFraction);
|
||||
CHECK(p.filter.keyTrack == defaults.filter.keyTrack);
|
||||
CHECK(p.pitchEnv.peakSemitones == defaults.pitchEnv.peakSemitones);
|
||||
CHECK(p.adsr.attackSeconds == defaults.adsr.attackSeconds);
|
||||
CHECK(p.adsr.releaseSeconds == defaults.adsr.releaseSeconds);
|
||||
}
|
||||
|
||||
// One unit, everywhere, across the formatter's whole range: a sub-millisecond value keeps a
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// toolbar overlapping any other; degenerate bands yielding no inverted rects; and the preview
|
||||
// button's play-triangle glyph, which sits inside the button without changing its rect.
|
||||
|
||||
#include "../src/core/instrument/ui/knob_deck.h"
|
||||
#include "../src/core/instrument/ui/sample_bands.h"
|
||||
#include "../src/core/instrument/ui/sample_chrome.h"
|
||||
|
||||
@@ -21,7 +22,9 @@ 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 int kKnob = 40; // stands in for knob_deck's kDeckKnobSize
|
||||
// The real constant, not a copy: the shell passes kDeckKnobSize into chromeRects, and a
|
||||
// hand-copied stand-in here had already drifted from it once.
|
||||
static constexpr int kKnob = kDeckKnobSize;
|
||||
|
||||
static Rect chromeBand(int w = kEditorMinWidth, int h = kEditorMinHeight) {
|
||||
return computeSampleBands(w, h, 120).chrome;
|
||||
|
||||
Reference in New Issue
Block a user