Files
reasampler/tests/test_bank_grid.cpp
T

415 lines
17 KiB
C++

// Standalone tests for reasampler::bank_grid — no REAPER, no test framework.
// Same fast loop as the sibling pure tests: assert the grid-layout math and the
// thumbnail cache-key stringification directly.
//
// Covers (M5 Wave A brief §Test cases): column count for a given panel width;
// cell rects for a full grid (row/column wrapping); the partial-last-row case;
// itemCount == 0; a single item; a panel too narrow for even one cell (clamp to
// one column); content-height for exact and partial rows; cache-key stability,
// width/generation/id sensitivity, and length-prefix collision resistance.
//
// Wave B adds: hit-testing (inside / gap / out-of-range / half-open bounds);
// selection updates (plain / ctrl-toggle / shift-range, invariants preserved); and
// keyboard nav (arrow clamp, row moves, shift-extend, partial-last-row clamp,
// fresh-panel focus).
#include "../src/core/ui/bank_grid.h"
#include <cmath>
#include <cstdio>
#include <string>
#include <vector>
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)
// A spec with round numbers so expected rects are trivial to hand-compute:
// cell 100x50, gap 10. Column pitch = 110, row pitch = 60, margin = 10.
static GridSpec spec() {
GridSpec s;
s.cellWidth = 100;
s.cellHeight = 50;
s.gap = 10;
return s;
}
// gap(10) + n*(100+10): 1 col needs 120, 2 need 230, 3 need 340.
static void testColumnsForWidth() {
const GridSpec s = spec();
CHECK(columnsForWidth(120, s) == 1); // exactly one cell + margins
CHECK(columnsForWidth(229, s) == 1); // one pixel short of two columns
CHECK(columnsForWidth(230, s) == 2); // exactly two
CHECK(columnsForWidth(345, s) == 3); // three plus slack
CHECK(columnsForWidth(1000, s) == 9); // many
}
// A panel narrower than a single cell still yields one column (clipped by the
// window, never zero — that would drop every sample).
static void testTooNarrowClampsToOneColumn() {
const GridSpec s = spec();
CHECK(columnsForWidth(50, s) == 1);
CHECK(columnsForWidth(0, s) == 1);
CHECK(columnsForWidth(-20, s) == 1);
}
// Zero items -> no rects (empty state is the panel's concern, not the layout's).
static void testZeroItems() {
const GridSpec s = spec();
auto rects = computeCellRects(0, 500, s);
CHECK(rects.empty());
CHECK(contentHeight(0, 500, s) == 0);
}
// One item sits at the top-left margin.
static void testSingleItem() {
const GridSpec s = spec();
auto rects = computeCellRects(1, 500, s);
CHECK(rects.size() == 1);
CHECK(rects[0] == (CellRect{10, 10, 100, 50}));
}
// A full 2x2: width forces exactly two columns, four items fill two rows.
static void testFullGridWrapping() {
const GridSpec s = spec();
// Width 230 -> exactly 2 columns.
auto rects = computeCellRects(4, 230, s);
CHECK(rects.size() == 4);
// Row 0: x = 10, 120 ; y = 10.
CHECK(rects[0] == (CellRect{10, 10, 100, 50}));
CHECK(rects[1] == (CellRect{120, 10, 100, 50}));
// Row 1: y = 70 (10 + 60).
CHECK(rects[2] == (CellRect{10, 70, 100, 50}));
CHECK(rects[3] == (CellRect{120, 70, 100, 50}));
}
// Partial last row: 5 items in a 2-column grid -> rows of 2,2,1. The lone last
// cell is left-aligned in its row (no centering), same x as column 0.
static void testPartialLastRow() {
const GridSpec s = spec();
auto rects = computeCellRects(5, 230, s); // 2 columns
CHECK(rects.size() == 5);
CHECK(rects[4] == (CellRect{10, 130, 100, 50})); // row 2, col 0: y = 10 + 2*60
// content height spans 3 rows: 10 + 3*(50+10) = 190.
CHECK(contentHeight(5, 230, s) == 190);
}
// Content height for an exact-fill grid: 4 items / 2 cols = 2 rows.
static void testContentHeightExactRows() {
const GridSpec s = spec();
CHECK(contentHeight(4, 230, s) == 130); // 10 + 2*60
CHECK(contentHeight(2, 230, s) == 70); // 10 + 1*60
}
// The cache key is stable and sensitive to every field.
static void testCacheKeyStabilityAndSensitivity() {
ThumbnailKey a{"sample-1", 120, 7};
ThumbnailKey aSame{"sample-1", 120, 7};
CHECK(thumbnailKeyString(a) == thumbnailKeyString(aSame)); // deterministic
ThumbnailKey diffWidth{"sample-1", 121, 7};
ThumbnailKey diffGen{"sample-1", 120, 8};
ThumbnailKey diffId{"sample-2", 120, 7};
CHECK(thumbnailKeyString(a) != thumbnailKeyString(diffWidth));
CHECK(thumbnailKeyString(a) != thumbnailKeyString(diffGen));
CHECK(thumbnailKeyString(a) != thumbnailKeyString(diffId));
}
// A sampleId containing the delimiter byte must not forge a collision with a
// different key. Without the length prefix, id "x|9" with width 0 and id "x" with
// width 9 would both tail-concatenate through the '|' delimiter and could match;
// the length prefix on the id disambiguates them.
static void testCacheKeyDelimiterCollisionResistance() {
// The canonical would-be collision: moving a "|9" fragment from the id into
// the width field. Length-prefixing the id makes the two forms distinct.
ThumbnailKey a{"x|9", 0, 0};
ThumbnailKey b{"x", 9, 0};
CHECK(thumbnailKeyString(a) != thumbnailKeyString(b));
// A second pair in the same spirit, delimiters in both id and adjacent fields.
ThumbnailKey k1{"1|2", 3, 0};
ThumbnailKey k2{"1", 23, 0};
CHECK(thumbnailKeyString(k1) != thumbnailKeyString(k2));
}
// --- Hit-testing --------------------------------------------------------------
// A 2x2 grid of the round spec: rects at (10,10),(120,10),(10,70),(120,70), each
// 100x50. Gaps sit at x in [110,120) and y in [60,70) and the outer margin < 10.
static std::vector<CellRect> grid2x2() {
return computeCellRects(4, 230, spec()); // 2 columns, 4 items
}
// A point inside a cell returns that cell's index; the top-left corner is inside
// (half-open lower bound), the bottom-right corner is NOT (half-open upper bound).
static void testHitTestInside() {
auto rects = grid2x2();
CHECK(hitTestCell(10, 10, rects) == 0); // top-left corner of cell 0: inside
CHECK(hitTestCell(60, 35, rects) == 0); // center of cell 0
CHECK(hitTestCell(120, 10, rects) == 1); // top-left of cell 1
CHECK(hitTestCell(10, 70, rects) == 2); // top-left of cell 2
CHECK(hitTestCell(120, 70, rects) == 3); // top-left of cell 3
// One pixel inside the far edge of cell 0 (x=109,y=59) still hits it.
CHECK(hitTestCell(109, 59, rects) == 0);
}
// The exclusive far edge (x+width, y+height) is a MISS — belongs to no cell, so
// adjacent gapless rects would never double-claim it.
static void testHitTestHalfOpenBounds() {
auto rects = grid2x2();
CHECK(hitTestCell(110, 35, rects) == -1); // x == cell0.x+width: past cell 0
CHECK(hitTestCell(60, 60, rects) == -1); // y == cell0.y+height: past cell 0
}
// A click in the inter-cell gap or the outer margin hits nothing.
static void testHitTestGapAndMargin() {
auto rects = grid2x2();
CHECK(hitTestCell(115, 35, rects) == -1); // horizontal gap between cols
CHECK(hitTestCell(60, 65, rects) == -1); // vertical gap between rows
CHECK(hitTestCell(0, 0, rects) == -1); // top-left margin
CHECK(hitTestCell(5, 35, rects) == -1); // left margin
}
// A click well outside the grid (below the last row / right of the last col) and
// an empty rect list both miss.
static void testHitTestOutOfRange() {
auto rects = grid2x2();
CHECK(hitTestCell(1000, 1000, rects) == -1);
CHECK(hitTestCell(60, 35, {}) == -1); // no cells at all
CHECK(hitTestCell(-5, -5, rects) == -1); // negative coords
}
// --- Selection updates --------------------------------------------------------
static bool selEq(const Selection& s, std::vector<int> idx, int focus, int anchor) {
return s.indices == idx && s.focus == focus && s.anchor == anchor;
}
// A plain click selects only that cell; focus and anchor both land on it,
// replacing any prior multi-selection.
static void testClickPlainReplaces() {
Selection start{{0, 1, 2}, 2, 0};
Selection s = applyClick(start, 4, /*ctrl=*/false, /*shift=*/false, 6);
CHECK(selEq(s, {4}, 4, 4));
}
// Ctrl-click adds an unselected cell (keeping the set sorted) and moves focus.
static void testClickCtrlAdds() {
Selection start{{1, 3}, 3, 1};
Selection s = applyClick(start, 2, /*ctrl=*/true, /*shift=*/false, 6);
CHECK(selEq(s, {1, 2, 3}, 2, 2)); // inserted in sorted position
}
// Ctrl-click on an already-selected cell removes it (toggle out); focus still
// moves to the clicked cell even though it left the set.
static void testClickCtrlRemoves() {
Selection start{{1, 2, 3}, 3, 1};
Selection s = applyClick(start, 2, /*ctrl=*/true, /*shift=*/false, 6);
CHECK(selEq(s, {1, 3}, 2, 2));
}
// Shift-click selects the inclusive range from the existing anchor to the click,
// leaving the anchor put; order-agnostic (anchor above or below the click).
static void testClickShiftRange() {
Selection start{{2}, 2, 2}; // anchor at 2
Selection s = applyClick(start, 5, /*ctrl=*/false, /*shift=*/true, 8);
CHECK(selEq(s, {2, 3, 4, 5}, 5, 2));
// Downward range (click above the anchor) yields the same inclusive set.
Selection s2 = applyClick(start, 0, false, true, 8);
CHECK(selEq(s2, {0, 1, 2}, 0, 2));
}
// Shift-click with no prior anchor behaves like a plain click (anchor seeds at the
// clicked cell).
static void testClickShiftNoAnchor() {
Selection start{}; // focus/anchor == -1
Selection s = applyClick(start, 3, false, true, 6);
CHECK(selEq(s, {3}, 3, 3));
}
// Shift takes precedence over ctrl when both are held (range select, documented).
static void testClickShiftBeatsCtrl() {
Selection start{{1}, 1, 1};
Selection s = applyClick(start, 3, /*ctrl=*/true, /*shift=*/true, 6);
CHECK(selEq(s, {1, 2, 3}, 3, 1)); // range, not toggle
}
// An out-of-range index (or empty grid) returns the selection unchanged.
static void testClickOutOfRangeNoop() {
Selection start{{1, 2}, 2, 1};
CHECK(applyClick(start, 9, false, false, 6) == start);
CHECK(applyClick(start, -1, false, false, 6) == start);
CHECK(applyClick(start, 0, false, false, 0) == start);
}
// --- Keyboard navigation ------------------------------------------------------
// Right/Left move by one in linear order; Down/Up move by a row (cols cells).
static void testNavArrowsMoveOneAndRow() {
// 6 items, 3 columns: rows [0,1,2],[3,4,5]. Focus at 1.
Selection start{{1}, 1, 1};
CHECK(selEq(navigate(start, NavKey::Right, 3, 6, false), {2}, 2, 2));
CHECK(selEq(navigate(start, NavKey::Left, 3, 6, false), {0}, 0, 0));
CHECK(selEq(navigate(start, NavKey::Down, 3, 6, false), {4}, 4, 4));
// Up from row 1 back to row 0.
Selection row1{{4}, 4, 4};
CHECK(selEq(navigate(row1, NavKey::Up, 3, 6, false), {1}, 1, 1));
}
// Movement clamps at every edge (no wrap): Left on cell 0, Right on the last cell,
// Up on the top row, Down past the last cell all stay put.
static void testNavClampsAtEdges() {
CHECK(selEq(navigate(Selection{{0}, 0, 0}, NavKey::Left, 3, 6, false), {0}, 0, 0));
CHECK(selEq(navigate(Selection{{5}, 5, 5}, NavKey::Right, 3, 6, false), {5}, 5, 5));
CHECK(selEq(navigate(Selection{{2}, 2, 2}, NavKey::Up, 3, 6, false), {2}, 2, 2));
CHECK(selEq(navigate(Selection{{5}, 5, 5}, NavKey::Down, 3, 6, false), {5}, 5, 5));
}
// Down from a cell above a MISSING last-row cell clamps to the last cell rather
// than overshooting past itemCount. 5 items, 3 cols: rows [0,1,2],[3,4]. Down from
// 2 would be 5 (absent) -> clamps to 4.
static void testNavDownPartialLastRowClamps() {
Selection start{{2}, 2, 2};
CHECK(selEq(navigate(start, NavKey::Down, 3, 5, false), {4}, 4, 4));
}
// Home/End jump to the first/last cell.
static void testNavHomeEnd() {
Selection start{{3}, 3, 3};
CHECK(selEq(navigate(start, NavKey::Home, 3, 6, false), {0}, 0, 0));
CHECK(selEq(navigate(start, NavKey::End, 3, 6, false), {5}, 5, 5));
}
// Shift+arrow extends the range from the anchor; the anchor stays put as focus
// walks. Repeated shift-right grows the set.
static void testNavShiftExtends() {
Selection start{{1}, 1, 1}; // anchor at 1
Selection s1 = navigate(start, NavKey::Right, 3, 6, true);
CHECK(selEq(s1, {1, 2}, 2, 1));
Selection s2 = navigate(s1, NavKey::Right, 3, 6, true);
CHECK(selEq(s2, {1, 2, 3}, 3, 1));
// Shift-down from the grown range extends by a whole row from the anchor.
Selection s3 = navigate(s1, NavKey::Down, 3, 6, true); // focus 2 -> 5
CHECK(selEq(s3, {1, 2, 3, 4, 5}, 5, 1));
}
// Shift-extending back toward the anchor shrinks the range (focus crosses the
// anchor without moving it).
static void testNavShiftShrinksAndCrosses() {
Selection start{{1, 2, 3}, 3, 1}; // anchor 1, focus 3
Selection s = navigate(start, NavKey::Left, 3, 6, true); // focus 3 -> 2
CHECK(selEq(s, {1, 2}, 2, 1));
Selection s2 = navigate(s, NavKey::Left, 3, 6, true); // focus 2 -> 1 (anchor)
CHECK(selEq(s2, {1}, 1, 1));
Selection s3 = navigate(s2, NavKey::Left, 3, 6, true); // cross below anchor
CHECK(selEq(s3, {0, 1}, 0, 1));
}
// A fresh panel (empty selection) focuses cell 0 on the first arrow without
// stepping, with and without shift.
static void testNavFromEmptyFocusesFirst() {
Selection empty{};
CHECK(selEq(navigate(empty, NavKey::Down, 3, 6, false), {0}, 0, 0));
CHECK(selEq(navigate(empty, NavKey::Right, 3, 6, true), {0}, 0, 0));
}
// itemCount <= 0 returns the selection unchanged; cols < 1 is treated as 1.
static void testNavDegenerate() {
Selection start{{1}, 1, 1};
CHECK(navigate(start, NavKey::Right, 3, 0, false) == start);
// cols coerced to 1: Down moves by 1 in a single-column grid.
CHECK(selEq(navigate(Selection{{0}, 0, 0}, NavKey::Down, 0, 4, false), {1}, 1, 1));
}
// --- compressAmplitudeForDisplay ----------------------------------------------
// Full scale: magnitude 1.0 must reach the full display fraction exactly.
static void testCompressFullScale() {
CHECK(compressAmplitudeForDisplay(1.0f) == 1.0f);
CHECK(compressAmplitudeForDisplay(-1.0f) == -1.0f);
}
// Exact zero must stay on the midline (no log of zero; guards the singularity).
static void testCompressZeroIsMidline() {
CHECK(compressAmplitudeForDisplay(0.0f) == 0.0f);
}
// -20 dB (0.1 linear) and -40 dB (0.01 linear) must both produce clearly visible
// (non-zero) fractions, with -20 dB > -40 dB (monotonic), and both well above
// the midline (arbitrary threshold of 0.15 chosen conservatively — at a -60 dB
// floor, -20 dB normalizes to 2/3 and -40 dB to 1/3).
static void testCompressMidValuesVisible() {
const float f20 = compressAmplitudeForDisplay(0.1f); // -20 dBFS
const float f40 = compressAmplitudeForDisplay(0.01f); // -40 dBFS
CHECK(f20 > 0.15f); // clearly non-zero
CHECK(f40 > 0.15f); // clearly non-zero
CHECK(f20 > f40); // monotonic: louder -> taller bar
}
// At and below the floor (-60 dB = 0.001 linear) the result is ~0 (silence).
// We test at exactly the floor magnitude and well below it.
static void testCompressAtAndBelowFloor() {
// 0.001 == 10^(-60/20) is the floor ratio. Magnitude at or below it -> 0.
const float floorMag = std::pow(10.0f, kDisplayFloorDb / 20.0f); // ~0.001
CHECK(compressAmplitudeForDisplay(floorMag) == 0.0f);
CHECK(compressAmplitudeForDisplay(floorMag * 0.5f) == 0.0f);
CHECK(compressAmplitudeForDisplay(0.0001f) == 0.0f);
}
// Sign is preserved: negative input produces a negative fraction of the same
// magnitude as its positive counterpart.
static void testCompressSignPreserved() {
const float pos = compressAmplitudeForDisplay(0.1f);
const float neg = compressAmplitudeForDisplay(-0.1f);
CHECK(neg < 0.0f);
// Magnitudes must be equal (sign-symmetric).
const float diff = pos + neg; // pos - |neg|
CHECK(diff > -0.001f && diff < 0.001f);
}
int main() {
testColumnsForWidth();
testTooNarrowClampsToOneColumn();
testZeroItems();
testSingleItem();
testFullGridWrapping();
testPartialLastRow();
testContentHeightExactRows();
testCacheKeyStabilityAndSensitivity();
testCacheKeyDelimiterCollisionResistance();
testHitTestInside();
testHitTestHalfOpenBounds();
testHitTestGapAndMargin();
testHitTestOutOfRange();
testClickPlainReplaces();
testClickCtrlAdds();
testClickCtrlRemoves();
testClickShiftRange();
testClickShiftNoAnchor();
testClickShiftBeatsCtrl();
testClickOutOfRangeNoop();
testNavArrowsMoveOneAndRow();
testNavClampsAtEdges();
testNavDownPartialLastRowClamps();
testNavHomeEnd();
testNavShiftExtends();
testNavShiftShrinksAndCrosses();
testNavFromEmptyFocusesFirst();
testNavDegenerate();
testCompressFullScale();
testCompressZeroIsMidline();
testCompressMidValuesVisible();
testCompressAtAndBelowFloor();
testCompressSignPreserved();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}