// Standalone tests for reasampler::component_geometry — no REAPER, no LICE, no framework. // Same fast assert loop as the sibling pure tests (prune_button / mode_switch). // // 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; 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" #include using namespace reasampler; using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) // --- hitTestBox (the shared primitive) --------------------------------------- static void testHitTestBoxHalfOpen() { KitBox b{10, 20, 30, 40}; CHECK(hitTestBox(10, 20, b)); // top-left inclusive CHECK(hitTestBox(39, 59, b)); // bottom-right inclusive (x+w-1, y+h-1) CHECK(!hitTestBox(40, 20, b)); // right edge excluded CHECK(!hitTestBox(10, 60, b)); // bottom edge excluded CHECK(!hitTestBox(9, 20, b)); // just left CHECK(!hitTestBox(10, 19, b)); // just above KitBox empty{}; CHECK(!hitTestBox(0, 0, empty)); // empty claims nothing } // --- Button box -------------------------------------------------------------- static void testButtonBoxInset() { KitBox cell{0, 0, 100, 40}; const KitButtonBox b = computeButtonBox(cell, 4); CHECK(!b.box.empty()); CHECK((b.box == KitBox{4, 4, 92, 32})); } static void testButtonBoxZeroPadding() { KitBox cell{5, 6, 20, 10}; const KitButtonBox b = computeButtonBox(cell, 0); CHECK((b.box == cell)); // no inset -> the whole cell } static void testButtonBoxNegativePaddingTreatedAsZero() { KitBox cell{5, 6, 20, 10}; CHECK((computeButtonBox(cell, -8).box == cell)); } static void testButtonBoxSuppressedWhenPaddingCollapses() { KitBox cell{0, 0, 10, 10}; CHECK(computeButtonBox(cell, 5).box.empty()); // 10 - 2*5 = 0 width -> suppressed CHECK(computeButtonBox(cell, 6).box.empty()); // negative -> suppressed CHECK(computeButtonBox(KitBox{}, 2).box.empty()); // degenerate cell -> suppressed } // --- Slider ------------------------------------------------------------------ // control 200x20, handle 12, track thickness 4. half = 6. track.x = 6, track.width = // 200-12 = 188, track.y = (20-4)/2 = 8. At value 0 the handle centre is track.x=6 -> // handle.x = 0; filled.width = 0. At value 1 the centre is 6+188=194 -> handle.x=188. static void testSliderEndpoints() { KitBox ctrl{0, 0, 200, 20}; const SliderGeometry lo = computeSlider(ctrl, 0.0, 12, 4); CHECK(!lo.track.empty()); CHECK((lo.track == KitBox{6, 8, 188, 4})); CHECK(lo.handle.x == 0); // flush left CHECK(lo.filled.width == 0); // nothing filled at 0 const SliderGeometry hi = computeSlider(ctrl, 1.0, 12, 4); CHECK(hi.handle.x == 188); // flush right (200-12) CHECK(hi.filled.width == 188); // fully filled at 1 } static void testSliderMidpoint() { KitBox ctrl{0, 0, 200, 20}; const SliderGeometry m = computeSlider(ctrl, 0.5, 12, 4); // centre = 6 + round(0.5*188) = 6 + 94 = 100; handle.x = 100 - 6 = 94. CHECK(m.handle.x == 94); CHECK(m.filled.width == 94); } static void testSliderClampsValue() { KitBox ctrl{0, 0, 200, 20}; CHECK((computeSlider(ctrl, -1.0, 12, 4).handle.x == computeSlider(ctrl, 0.0, 12, 4).handle.x)); CHECK((computeSlider(ctrl, 5.0, 12, 4).handle.x == computeSlider(ctrl, 1.0, 12, 4).handle.x)); } static void testSliderSuppressedWhenTooSmallOrDegenerate() { CHECK(computeSlider(KitBox{0, 0, 8, 20}, 0.5, 12, 4).track.empty()); // width < handle CHECK(computeSlider(KitBox{0, 0, 200, 8}, 0.5, 12, 4).track.empty()); // height < handle CHECK(computeSlider(KitBox{}, 0.5, 12, 4).track.empty()); // degenerate CHECK(computeSlider(KitBox{0, 0, 200, 20}, 0.5, 0, 4).track.empty()); // no handle CHECK(computeSlider(KitBox{0, 0, 200, 20}, 0.5, 12, 0).track.empty()); // no track } // value->px is the inverse of the handle placement (a track jump). static void testSliderValueAtInverse() { KitBox ctrl{0, 0, 200, 20}; CHECK(sliderValueAt(6, ctrl, 12) == 0.0); // at/left of track start CHECK(sliderValueAt(0, ctrl, 12) == 0.0); // left of the control -> 0 CHECK(sliderValueAt(194, ctrl, 12) == 1.0); // at track end -> 1 CHECK(sliderValueAt(300, ctrl, 12) == 1.0); // past the end -> clamped const double mid = sliderValueAt(100, ctrl, 12); // (100-6)/188 CHECK(mid > 0.49 && mid < 0.51); CHECK(sliderValueAt(50, KitBox{0, 0, 8, 20}, 12) == 0.0); // too small -> 0 } // --- List row ---------------------------------------------------------------- static void testListRowStacking() { KitBox list{0, 100, 220, 90}; // 3 full rows of 30 fit CHECK((computeListRow(list, 0, 30).box == KitBox{0, 100, 220, 30})); CHECK((computeListRow(list, 1, 30).box == KitBox{0, 130, 220, 30})); CHECK((computeListRow(list, 2, 30).box == KitBox{0, 160, 220, 30})); CHECK(computeListRow(list, 0, 30).index == 0); CHECK(computeListRow(list, 2, 30).index == 2); } // A row that starts inside the list but overhangs the bottom keeps full height (caller // clips the draw); a row starting AT/BELOW the bottom is suppressed. static void testListRowPartialAndClipped() { KitBox list{0, 0, 100, 50}; // rows of 30: row 0 [0,30), row 1 [30,60) overhangs const ListRowBox partial = computeListRow(list, 1, 30); CHECK(!partial.box.empty()); CHECK(partial.box.y == 30); CHECK(partial.box.height == 30); // full height; caller clips CHECK(computeListRow(list, 2, 30).box.empty()); // starts at y=60 >= bottom -> none } static void testListRowDegenerate() { CHECK(computeListRow(KitBox{}, 0, 30).box.empty()); CHECK(computeListRow(KitBox{0, 0, 100, 90}, -1, 30).box.empty()); CHECK(computeListRow(KitBox{0, 0, 100, 90}, 0, 0).box.empty()); } static void testListRowHitTest() { KitBox list{0, 100, 220, 90}; // 3 rows of 30, rowCount = 3 CHECK(hitTestListRow(10, 100, list, 30, 3) == 0); // top of row 0 CHECK(hitTestListRow(10, 129, list, 30, 3) == 0); // bottom of row 0 (inclusive) CHECK(hitTestListRow(10, 130, list, 30, 3) == 1); // top of row 1 CHECK(hitTestListRow(10, 189, list, 30, 3) == 2); // last pixel of row 2 // Misses. CHECK(hitTestListRow(10, 99, list, 30, 3) == -1); // above the list CHECK(hitTestListRow(10, 190, list, 30, 3) == -1); // below the list band CHECK(hitTestListRow(-1, 100, list, 30, 3) == -1); // left of the list CHECK(hitTestListRow(220, 100, list, 30, 3) == -1); // right edge excluded } // rowCount bounds the hit: a taller list with fewer rows than fit reports the empty tail // as a miss (no phantom row past the data). static void testListRowHitTestBoundedByCount() { KitBox list{0, 0, 100, 200}; // room for 6 rows of 30, but only 2 exist CHECK(hitTestListRow(10, 10, list, 30, 2) == 0); CHECK(hitTestListRow(10, 40, list, 30, 2) == 1); CHECK(hitTestListRow(10, 70, list, 30, 2) == -1); // row 2 is empty tail -> miss CHECK(hitTestListRow(10, 10, list, 30, 0) == -1); // zero rows -> always miss } // Draw/hit-test agreement: every pixel inside a computed row hit-tests to that row. static void testListRowLayoutHitAgreement() { KitBox list{3, 7, 97, 120}; const int rh = 24, rowCount = 4; for (int idx = 0; idx < rowCount; ++idx) { const ListRowBox r = computeListRow(list, idx, rh); if (r.box.empty()) continue; for (int py = r.box.y; py < r.box.y + r.box.height && py < list.y + list.height; ++py) CHECK(hitTestListRow(list.x + 1, py, list, rh, rowCount) == idx); } } // --- waveformColumnCount ----------------------------------------------------- static void testWaveformColumnCount() { // Normal box: width minus the two 2px side insets. CHECK(waveformColumnCount(KitBox{0, 0, 100, 40}) == 96); CHECK(waveformColumnCount(KitBox{10, 5, 50, 20}) == 46); // Minimum: a 5-wide box yields exactly 1 drawable column. CHECK(waveformColumnCount(KitBox{0, 0, 5, 10}) == 1); // Too narrow to have any drawable columns: 4 or fewer columns returns 0. CHECK(waveformColumnCount(KitBox{0, 0, 4, 10}) == 0); CHECK(waveformColumnCount(KitBox{0, 0, 1, 10}) == 0); // Degenerate (empty) box returns 0 — graceful suppression. CHECK(waveformColumnCount(KitBox{}) == 0); CHECK(waveformColumnCount(KitBox{0, 0, 0, 40}) == 0); } // The instrument's waveform overlay rides this exact column band, and derives its own left // inset by halving what this leaves (waveform_view's waveformOverlayArea) rather than keeping a // second copy of the inset. That halving is only correct while the inset is symmetric, so pin // it: every drawable width loses exactly 4, two per side. static void testTheColumnBandIsInsetSymmetrically() { for (int w = 5; w <= 400; ++w) { CHECK(w - waveformColumnCount(KitBox{0, 0, w, 40}) == 4); } } // --- 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() { // Odd height so midY sits equidistant from lo/hi — the fractional half-span below comes // from the amplitude product, not the height's parity. const WaveformBand band = waveformBand(0, 41); // 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 } // A band too short for the 2px breathing room (height <= 4, so height/2 - 2 <= 0) must clamp // halfSpan to 0 rather than go negative and mirror every positive column below the zero line. static void testDegenerateBandClampsHalfSpanToZero() { CHECK(waveformBand(0, 4).halfSpan == 0.0); CHECK(waveformBand(0, 3).halfSpan == 0.0); CHECK(waveformBand(0, 0).halfSpan == 0.0); // A zero half-span collapses every column onto the zero line regardless of amplitude sign. // height 3 (not 4): pre-clamp this rawHalfSpan is -1, so this is the case that pins the // clamp itself, not just a halfSpan-already-zero band. const WaveformBand b = waveformBand(10, 3); const WaveformColumnSpan s = waveformColumnSpan(b, 1.0, -1.0); CHECK(s.top == b.midY && s.bottom == b.midY); // Domain note, not a bug: waveformBand(t, 0) still has lo=t, hi=t-1 (an inverted span) even // though halfSpan clamps to 0 — unreachable via the band allocator (draw_kit.cpp never // divides down to a 0-height band) and harmless if it were (LICE clips a reversed 1px line). } int main() { testHitTestBoxHalfOpen(); testButtonBoxInset(); testButtonBoxZeroPadding(); testButtonBoxNegativePaddingTreatedAsZero(); testButtonBoxSuppressedWhenPaddingCollapses(); testSliderEndpoints(); testSliderMidpoint(); testSliderClampsValue(); testSliderSuppressedWhenTooSmallOrDegenerate(); testSliderValueAtInverse(); testListRowStacking(); testListRowPartialAndClipped(); testListRowDegenerate(); testListRowHitTest(); testListRowHitTestBoundedByCount(); testListRowLayoutHitAgreement(); testWaveformColumnCount(); testTheColumnBandIsInsetSymmetrically(); testSymmetricColumnDrawsEqualHeightAboveAndBelowTheZeroLine(); testSilentColumnCollapsesOntoTheZeroLine(); testTallerAmplitudeNeverDrawsAShorterColumn(); testBothEdgesAndTheStrokeClampToTheBand(); testBandMetricsMirrorTheDrawnInset(); testDegenerateBandClampsHalfSpanToZero(); if (g_fail == 0) std::printf("component_geometry: all tests passed\n"); else std::printf("component_geometry: %d CHECK(s) FAILED\n", g_fail); return g_fail == 0 ? 0 : 1; }