Merge Θ-W2-T3: full-width piano strip with uniform key widths, note tooltips, one toolbar font
This commit is contained in:
+284
-139
@@ -1,18 +1,29 @@
|
||||
// Standalone tests for reasampler::instrument::ui::keyboard_strip — no VST3, no REAPER, no framework.
|
||||
// Same fast assert loop as the sibling pure tests. Assert the editor's keyboard-strip
|
||||
// layout, root marker, key mapping, and drag-delta note resolver directly — the geometry
|
||||
// that backs the root display and root-set.
|
||||
// Standalone tests for reasampler::instrument::ui::keyboard_strip — no VST3, no REAPER, no
|
||||
// framework. Same fast assert loop as the sibling pure tests.
|
||||
//
|
||||
// Covers: layoutStrip (normal + zero); keyLeftX monotonic across the 128-key span with the
|
||||
// boundary at 128 == band right; keyRect / rootMarkerRect (rootMarkerRect == keyRect);
|
||||
// keyAtPoint inverting the mapping and clamping/ missing off-band; resolveDragNote rounding
|
||||
// to the nearest key at the key centre, clamping to [0,127], and the zero-delta / zero-width
|
||||
// no-ops; isNaturalKey across a full octave (C4..B4), at boundary notes 0 and 127, and with
|
||||
// out-of-range inputs that clamp to [0,127].
|
||||
// Covers: layoutStrip (normal, degenerate, sub-key-width); same-class key-width uniformity
|
||||
// swept across editor client widths (including multiples of them, standing in for larger
|
||||
// client sizes — see the client-pixel-only note below); the tiled key area staying centred
|
||||
// inside a band that spans the full width it was handed; whiteIndexOf / isNaturalKey across
|
||||
// octave boundaries and the 0..127 extremes; keyRect tiling and black-over-white overlap;
|
||||
// keyAtPoint resolving black-over-white by zone and missing off-band; the root affordance's
|
||||
// hit-to-marker round trip; resolveDragNote clamping a wandering pointer; noteName under the
|
||||
// C4 (MIDI 60) DAW convention; and the gutter pinned at the shipped default window size.
|
||||
//
|
||||
// Client-pixel-only guarantee: every width swept below is a CLIENT-pixel width. Nothing in
|
||||
// the instrument implements IPlugViewContentScaleSupport, so if a host scales the plugin
|
||||
// window itself, uniform integer key widths get resampled at the physical-pixel level —
|
||||
// unverified by this suite (see src/core/instrument/CLAUDE.md).
|
||||
|
||||
#include "../src/core/instrument/ui/keyboard_strip.h"
|
||||
#include "../src/core/instrument/ui/sample_bands.h" // computeSampleBands, to derive the CHROME
|
||||
// band the shipped default window (840x620,
|
||||
// editor_session.cpp) hands the strip
|
||||
#include "../src/core/instrument/ui/sample_chrome.h" // chromeRects, to derive rootStrip's width
|
||||
// the same way the shell does
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::instrument::ui;
|
||||
@@ -21,171 +32,305 @@ 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 comfortable strip: 1280px wide (10px per key) so key math is exact and easy to reason
|
||||
// about.
|
||||
static StripLayout wideStrip() { return layoutStrip(1280, 40); }
|
||||
// A comfortable strip: wide enough that every class is several pixels across.
|
||||
static StripLayout wideStrip() { return layoutStrip(1280, 30); }
|
||||
|
||||
// The widths a real editor hands the strip — the 560px minimum client up to a wide window.
|
||||
static const int kBaseWidths[] = {544, 600, 640, 700, 749, 750, 751, 824, 900, 1000,
|
||||
1024, 1103, 1264, 1600, 1920, 2400};
|
||||
// Multiplies each base width to widen client-pixel coverage (e.g. a maximized/larger client
|
||||
// area) — NOT a host DPI/content-scale factor; see the client-pixel-only note above.
|
||||
static const double kWidthMultipliers[] = {1.0, 1.25, 1.5, 1.75, 2.0};
|
||||
|
||||
// --- layoutStrip --------------------------------------------------------------
|
||||
|
||||
static void testLayoutNormalArea() {
|
||||
const StripLayout L = layoutStrip(640, 40);
|
||||
CHECK(L.keys.x == 0 && L.keys.y == 0);
|
||||
CHECK(L.keys.right() == 640 && L.keys.bottom() == 40);
|
||||
static void testLayoutFillsTheBandAndCentresTheKeys() {
|
||||
const StripLayout L = layoutStrip(824, 30);
|
||||
CHECK(L.band == Rect::ltrb(0, 0, 824, 30));
|
||||
CHECK(L.whiteWidth == 824 / kStripWhiteKeyCount);
|
||||
CHECK(L.keys.width == L.whiteWidth * kStripWhiteKeyCount);
|
||||
CHECK(L.keys.y == 0 && L.keys.height == 30);
|
||||
// The residue an indivisible width leaves splits evenly between the two end margins.
|
||||
const int leftMargin = L.keys.x - L.band.x;
|
||||
const int rightMargin = L.band.right() - L.keys.right();
|
||||
CHECK(leftMargin >= 0 && rightMargin >= 0);
|
||||
CHECK(rightMargin - leftMargin >= 0 && rightMargin - leftMargin <= 1);
|
||||
}
|
||||
|
||||
static void testLayoutZeroArea() {
|
||||
const StripLayout L = layoutStrip(0, 0);
|
||||
CHECK(L.keys.width == 0 && L.keys.height == 0);
|
||||
static void testDegenerateSizesYieldNoKeys() {
|
||||
const StripLayout zero = layoutStrip(0, 0);
|
||||
CHECK(zero.band.empty());
|
||||
CHECK(zero.keys.empty());
|
||||
CHECK(keyAtPoint(zero, 0, 0) == -1);
|
||||
CHECK(keyRect(zero, 60).empty());
|
||||
CHECK(resolveDragNote(zero, 5, 5) == -1);
|
||||
|
||||
// Narrower than one pixel per white key: no keys at all, but the band still reports its
|
||||
// size so the caller can draw the empty surface.
|
||||
const StripLayout narrow = layoutStrip(kStripWhiteKeyCount - 1, 30);
|
||||
CHECK(narrow.band.width == kStripWhiteKeyCount - 1);
|
||||
CHECK(narrow.keys.empty());
|
||||
CHECK(keyAtPoint(narrow, 10, 10) == -1);
|
||||
}
|
||||
|
||||
// --- keyLeftX / keyRect / rootMarkerRect --------------------------------------
|
||||
// --- the sharp one: same-class widths are uniform at every client width tested ---
|
||||
|
||||
static void testKeyLeftMonotonicAndBounds() {
|
||||
const StripLayout L = wideStrip();
|
||||
// Key 0's left edge is the band left; the 128 boundary is the band right.
|
||||
CHECK(keyLeftX(L, 0) == L.keys.x);
|
||||
CHECK(keyLeftX(L, 128) == L.keys.right());
|
||||
// Strictly non-decreasing across the span.
|
||||
int prev = keyLeftX(L, 0);
|
||||
for (int n = 1; n <= 128; ++n) {
|
||||
const int x = keyLeftX(L, n);
|
||||
CHECK(x >= prev);
|
||||
prev = x;
|
||||
static void testSameClassKeysAreEqualWidthAcrossClientWidths() {
|
||||
for (const int base : kBaseWidths) {
|
||||
for (const double scale : kWidthMultipliers) {
|
||||
const int w = static_cast<int>(base * scale);
|
||||
const int h = static_cast<int>(30 * scale);
|
||||
const StripLayout L = layoutStrip(w, h);
|
||||
if (L.keys.empty()) continue; // covered by the degenerate test
|
||||
|
||||
int whiteW = -1;
|
||||
int blackW = -1;
|
||||
int blackH = -1;
|
||||
for (int n = 0; n < kStripKeyCount; ++n) {
|
||||
const Rect k = keyRect(L, n);
|
||||
CHECK(!k.empty());
|
||||
if (isNaturalKey(n)) {
|
||||
if (whiteW < 0) whiteW = k.width;
|
||||
CHECK(k.width == whiteW);
|
||||
CHECK(k.height == L.keys.height); // whites run the full band height
|
||||
} else {
|
||||
if (blackW < 0) { blackW = k.width; blackH = k.height; }
|
||||
CHECK(k.width == blackW);
|
||||
CHECK(k.height == blackH);
|
||||
}
|
||||
}
|
||||
CHECK(whiteW == L.whiteWidth);
|
||||
CHECK(blackW == L.blackWidth);
|
||||
CHECK(blackH == L.blackHeight);
|
||||
CHECK(blackW < whiteW); // the two classes stay visually distinct
|
||||
CHECK(blackH < L.keys.height);
|
||||
}
|
||||
}
|
||||
// At 10px/key, key 12 (one octave) starts at 120px.
|
||||
CHECK(keyLeftX(L, 12) == 120);
|
||||
}
|
||||
|
||||
static void testKeyRectHalfOpen() {
|
||||
const StripLayout L = wideStrip();
|
||||
const Rect k = keyRect(L, 60);
|
||||
CHECK(k.x == keyLeftX(L, 60));
|
||||
CHECK(k.right() == keyLeftX(L, 61));
|
||||
CHECK(k.y == L.keys.y && k.bottom() == L.keys.bottom());
|
||||
CHECK(k.width == 10); // 10px/key
|
||||
static void testKeyAreaSpansTheBandWithinOneKeyAtEveryTestedWidth() {
|
||||
for (const int base : kBaseWidths) {
|
||||
for (const double scale : kWidthMultipliers) {
|
||||
const int w = static_cast<int>(base * scale);
|
||||
const StripLayout L = layoutStrip(w, 30);
|
||||
CHECK(L.band.width == w); // the strip always spans the width it was handed
|
||||
if (L.keys.empty()) continue;
|
||||
// The keys cover all but w % 75 — the price of uniform integer key widths, and
|
||||
// the reason the residue is a margin rather than a per-key rounding wobble.
|
||||
const int margins = L.band.width - L.keys.width;
|
||||
CHECK(margins == w % kStripWhiteKeyCount);
|
||||
CHECK(margins >= 0 && margins < kStripWhiteKeyCount);
|
||||
const int leftMargin = L.keys.x - L.band.x;
|
||||
CHECK(leftMargin == margins / 2); // split evenly, odd pixel to the right
|
||||
CHECK(L.keys.x >= L.band.x && L.keys.right() <= L.band.right());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void testRootMarkerEqualsKeyRect() {
|
||||
// --- key classification + white ordinals --------------------------------------
|
||||
|
||||
static void testIsNaturalKeyAcrossAnOctaveAndTheExtremes() {
|
||||
// C4..B4 (MIDI 60..71).
|
||||
const bool expected[12] = {true, false, true, false, true, true,
|
||||
false, true, false, true, false, true};
|
||||
for (int i = 0; i < 12; ++i) CHECK(isNaturalKey(60 + i) == expected[i]);
|
||||
|
||||
CHECK(isNaturalKey(0) == true); // C-1
|
||||
CHECK(isNaturalKey(1) == false); // C#-1
|
||||
CHECK(isNaturalKey(127) == true); // G9
|
||||
CHECK(isNaturalKey(126) == false); // F#9
|
||||
// Out-of-range clamps rather than indexing off the table.
|
||||
CHECK(isNaturalKey(-100) == true);
|
||||
CHECK(isNaturalKey(200) == true);
|
||||
}
|
||||
|
||||
static void testWhiteIndexCountsNaturalsBelowTheNote() {
|
||||
CHECK(whiteIndexOf(0) == 0); // C-1 is the first white key
|
||||
CHECK(whiteIndexOf(1) == 1); // C#-1 straddles the C/D boundary
|
||||
CHECK(whiteIndexOf(2) == 1); // D-1 is the second white key
|
||||
CHECK(whiteIndexOf(4) == 2); // E-1
|
||||
CHECK(whiteIndexOf(5) == 3); // F-1 (no black between E and F)
|
||||
CHECK(whiteIndexOf(11) == 6); // B-1
|
||||
CHECK(whiteIndexOf(12) == 7); // C0 opens the next octave
|
||||
CHECK(whiteIndexOf(60) == 35); // C4
|
||||
CHECK(whiteIndexOf(127) == kStripWhiteKeyCount - 1); // G9 is the last white key
|
||||
}
|
||||
|
||||
// --- keyRect ------------------------------------------------------------------
|
||||
|
||||
static void testWhiteKeysTileTheKeyAreaGapFree() {
|
||||
const StripLayout L = wideStrip();
|
||||
const Rect m = rootMarkerRect(L, 64);
|
||||
const Rect k = keyRect(L, 64);
|
||||
CHECK(m.x == k.x && m.right() == k.right() && m.y == k.y && m.bottom() == k.bottom());
|
||||
int expectedLeft = L.keys.x;
|
||||
for (int n = 0; n < kStripKeyCount; ++n) {
|
||||
if (!isNaturalKey(n)) continue;
|
||||
const Rect k = keyRect(L, n);
|
||||
CHECK(k.x == expectedLeft);
|
||||
expectedLeft = k.right();
|
||||
}
|
||||
CHECK(expectedLeft == L.keys.right()); // the last white ends exactly on the key area
|
||||
}
|
||||
|
||||
static void testBlackKeysStraddleTheirWhiteBoundary() {
|
||||
const StripLayout L = wideStrip();
|
||||
for (int n = 1; n < kStripKeyCount - 1; ++n) {
|
||||
if (isNaturalKey(n)) continue;
|
||||
const Rect black = keyRect(L, n);
|
||||
const Rect below = keyRect(L, n - 1); // the natural under the accidental
|
||||
const Rect above = keyRect(L, n + 1);
|
||||
CHECK(black.x > below.x && black.right() < above.right());
|
||||
CHECK(black.x < below.right()); // overlaps the white on its left
|
||||
CHECK(black.right() > above.x); // and the white on its right
|
||||
}
|
||||
}
|
||||
|
||||
static void testRootMarkerIsTheRootKey() {
|
||||
const StripLayout L = wideStrip();
|
||||
CHECK(rootMarkerRect(L, 64) == keyRect(L, 64));
|
||||
CHECK(rootMarkerRect(L, 61) == keyRect(L, 61));
|
||||
// Out-of-range roots clamp instead of producing a stray rect.
|
||||
CHECK(rootMarkerRect(L, -5) == keyRect(L, 0));
|
||||
CHECK(rootMarkerRect(L, 999) == keyRect(L, 127));
|
||||
}
|
||||
|
||||
// --- keyAtPoint ---------------------------------------------------------------
|
||||
|
||||
static void testKeyAtPointInverts() {
|
||||
static void testEveryKeyIsReachableAtItsOwnCentre() {
|
||||
const StripLayout L = wideStrip();
|
||||
// A point in the middle of key 60's cell resolves to 60.
|
||||
const Rect k = keyRect(L, 60);
|
||||
CHECK(keyAtPoint(L, k.x + 5, k.y + 2) == 60);
|
||||
// The very left of the band is key 0; just inside the right edge is key 127.
|
||||
CHECK(keyAtPoint(L, L.keys.x, 2) == 0);
|
||||
CHECK(keyAtPoint(L, L.keys.right() - 1, 2) == 127);
|
||||
for (int n = 0; n < kStripKeyCount; ++n) {
|
||||
const Rect k = keyRect(L, n);
|
||||
const int cx = k.x + k.width / 2;
|
||||
// A white key only answers below the black zone, where the accidentals end.
|
||||
const int cy = isNaturalKey(n) ? L.keys.bottom() - 1 : k.y + k.height / 2;
|
||||
CHECK(keyAtPoint(L, cx, cy) == n);
|
||||
}
|
||||
}
|
||||
|
||||
static void testKeyAtPointOffBand() {
|
||||
static void testBlackKeysWinInTheirZoneAndWhitesWinBelowIt() {
|
||||
const StripLayout L = wideStrip();
|
||||
CHECK(keyAtPoint(L, -5, 2) == -1); // left of band
|
||||
CHECK(keyAtPoint(L, L.keys.right() + 5, 2) == -1); // right of band
|
||||
CHECK(keyAtPoint(L, 100, L.keys.bottom() + 5) == -1); // below band
|
||||
const Rect cSharp = keyRect(L, 61); // C#4
|
||||
const int cx = cSharp.x + cSharp.width / 2;
|
||||
CHECK(keyAtPoint(L, cx, cSharp.y) == 61); // in the black zone
|
||||
const int below = keyAtPoint(L, cx, L.keys.bottom() - 1);
|
||||
CHECK(below != 61); // below it, a white answers
|
||||
CHECK(below == 60 || below == 62); // C4 or D4, whichever it overlaps
|
||||
// E-F and B-C have no accidental between them: the top row there is still white.
|
||||
const Rect e4 = keyRect(L, 64);
|
||||
CHECK(keyAtPoint(L, e4.right() - 1, e4.y) == 64);
|
||||
}
|
||||
|
||||
static void testKeyAtPointMissesOffBandAndInTheEndMargins() {
|
||||
const StripLayout L = layoutStrip(824, 30);
|
||||
CHECK(keyAtPoint(L, -5, 5) == -1);
|
||||
CHECK(keyAtPoint(L, L.band.right() + 5, 5) == -1);
|
||||
CHECK(keyAtPoint(L, 100, L.band.bottom() + 5) == -1);
|
||||
CHECK(keyAtPoint(L, 100, -1) == -1);
|
||||
if (L.keys.x > L.band.x) CHECK(keyAtPoint(L, L.band.x, 5) == -1); // left cheek margin
|
||||
if (L.keys.right() < L.band.right())
|
||||
CHECK(keyAtPoint(L, L.band.right() - 1, 5) == -1); // right cheek margin
|
||||
}
|
||||
|
||||
// --- the root affordance ------------------------------------------------------
|
||||
|
||||
static void testHitTestingAKeyMarksThatSameKey() {
|
||||
// The pure half of "click a key, the displayed root moves there": whatever keyAtPoint
|
||||
// resolves, the root marker lands exactly on that key — no off-by-one between the key
|
||||
// the pointer hit and the key drawn lit.
|
||||
const StripLayout L = wideStrip();
|
||||
for (int n = 0; n < kStripKeyCount; ++n) {
|
||||
const Rect k = keyRect(L, n);
|
||||
const int cx = k.x + k.width / 2;
|
||||
const int cy = isNaturalKey(n) ? L.keys.bottom() - 1 : k.y + k.height / 2;
|
||||
const int hit = keyAtPoint(L, cx, cy);
|
||||
CHECK(hit == n);
|
||||
CHECK(rootMarkerRect(L, hit) == k);
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolveDragNote ----------------------------------------------------------
|
||||
|
||||
static void testResolveDragRoundsToNearestKey() {
|
||||
const StripLayout L = wideStrip(); // 10px/key
|
||||
// A +25px drag from key 60 = +2.5 keys -> rounds to +3 (half-key flips at the centre).
|
||||
CHECK(resolveDragNote(L, 60, 25) == 63);
|
||||
// A +24px drag = +2.4 keys -> rounds to +2.
|
||||
CHECK(resolveDragNote(L, 60, 24) == 62);
|
||||
// Symmetric for negative deltas.
|
||||
CHECK(resolveDragNote(L, 60, -25) == 57);
|
||||
CHECK(resolveDragNote(L, 60, -24) == 58);
|
||||
}
|
||||
|
||||
static void testResolveDragClampsAndNoOps() {
|
||||
static void testDragTracksThePointerAndClampsWhenItWanders() {
|
||||
const StripLayout L = wideStrip();
|
||||
CHECK(resolveDragNote(L, 60, 0) == 60); // zero delta -> unchanged
|
||||
CHECK(resolveDragNote(L, 2, -1000) == 0); // clamps at 0
|
||||
CHECK(resolveDragNote(L, 120, 1000) == 127); // clamps at 127
|
||||
// Zero-width band -> no motion (pins to startNote, clamped).
|
||||
const StripLayout Z = layoutStrip(0, 40);
|
||||
CHECK(resolveDragNote(Z, 60, 500) == 60);
|
||||
const Rect g4 = keyRect(L, 67);
|
||||
const int cx = g4.x + g4.width / 2;
|
||||
CHECK(resolveDragNote(L, cx, L.keys.bottom() - 1) == 67);
|
||||
|
||||
// Wandering off the strip keeps tracking at the clamped edge rather than dropping out.
|
||||
CHECK(resolveDragNote(L, -500, L.keys.bottom() - 1) == 0);
|
||||
CHECK(resolveDragNote(L, L.band.right() + 500, L.keys.bottom() - 1) == 127);
|
||||
// Above the strip clamps into the black zone; G4's centre is clear of both flanking
|
||||
// accidentals, so it still answers G4 rather than F#4 or G#4.
|
||||
CHECK(resolveDragNote(L, cx, -400) == 67);
|
||||
CHECK(resolveDragNote(L, cx, 4000) == 67);
|
||||
}
|
||||
|
||||
// --- isNaturalKey -------------------------------------------------------------
|
||||
// --- noteName -----------------------------------------------------------------
|
||||
|
||||
static void testIsNaturalKeyFullOctave() {
|
||||
// Semitone positions 0..11 starting at C4 (MIDI 60):
|
||||
// C=60(nat) C#=61(acc) D=62(nat) D#=63(acc) E=64(nat) F=65(nat)
|
||||
// F#=66(acc) G=67(nat) G#=68(acc) A=69(nat) A#=70(acc) B=71(nat)
|
||||
const bool expected[12] = {
|
||||
true, false, true, false, true, true,
|
||||
false, true, false, true, false, true,
|
||||
};
|
||||
for (int i = 0; i < 12; ++i) {
|
||||
CHECK(isNaturalKey(60 + i) == expected[i]);
|
||||
}
|
||||
static void testNoteNamesFollowTheC4Convention() {
|
||||
CHECK(noteName(60) == "C4"); // middle C, the convention REAPER uses
|
||||
CHECK(noteName(61) == "C#4");
|
||||
CHECK(noteName(59) == "B3"); // the octave rolls at B->C, not at A->B
|
||||
CHECK(noteName(72) == "C5");
|
||||
CHECK(noteName(0) == "C-1"); // the low extreme
|
||||
CHECK(noteName(11) == "B-1");
|
||||
CHECK(noteName(12) == "C0");
|
||||
CHECK(noteName(127) == "G9"); // the high extreme
|
||||
CHECK(noteName(126) == "F#9");
|
||||
// Out-of-range clamps to the extremes rather than naming an unplayable note.
|
||||
CHECK(noteName(-1) == "C-1");
|
||||
CHECK(noteName(500) == "G9");
|
||||
}
|
||||
|
||||
static void testIsNaturalKeyBoundaryNotes() {
|
||||
// Note 0 is C (natural); note 127 is G (natural); note 1 is C# (accidental).
|
||||
CHECK(isNaturalKey(0) == true); // C0 — natural
|
||||
CHECK(isNaturalKey(1) == false); // C#0 — accidental
|
||||
CHECK(isNaturalKey(127) == true); // G9 — natural (127 % 12 == 7)
|
||||
CHECK(isNaturalKey(126) == false); // F#9 — accidental (126 % 12 == 6)
|
||||
}
|
||||
// --- the gutter at the shipped default window size -----------------------------
|
||||
|
||||
static void testIsNaturalKeyOutOfRangeClamped() {
|
||||
// Values outside [0,127] clamp to [0,127]; must not crash/UB.
|
||||
// note -1 clamps to 0 (C, natural); note 128 clamps to 127 (G, natural).
|
||||
CHECK(isNaturalKey(-1) == true);
|
||||
CHECK(isNaturalKey(128) == true);
|
||||
CHECK(isNaturalKey(-100) == true);
|
||||
CHECK(isNaturalKey(200) == true);
|
||||
}
|
||||
// The rule-based sweep above pins `margins == w % 75` and `leftMargin == margins/2` at every
|
||||
// width, but pins no concrete number — Daniel is making a visual call on the specific gutter
|
||||
// at the shipped default, and neither kPad nor the 840 default is covered by another test
|
||||
// firing if either ever changes. The strip is a sawtooth with period kStripWhiteKeyCount (75)
|
||||
// px of window width, and the shipped 840 default lands on residue 74 — the cycle's maximum:
|
||||
// one pixel of resize (840->841) collapses both gutters to zero and grows every white key
|
||||
// from 10px to 11px.
|
||||
static void testGutterAtTheShippedDefaultWindowSize() {
|
||||
constexpr int kShippedDefaultWindowW = 840; // editor_session.cpp's ViewRect default
|
||||
constexpr int kShippedDefaultWindowH = 620; // editor_session.cpp's ViewRect default
|
||||
// Derive rootStrip's width the same way the shell does, through the real allocator +
|
||||
// chrome layout, rather than re-deriving the inset formula — so a change to either one
|
||||
// fails this test instead of silently moving the shipped gutter out from under it.
|
||||
const SampleBands bands =
|
||||
computeSampleBands(kShippedDefaultWindowW, kShippedDefaultWindowH, 0);
|
||||
const ChromeRects chrome = chromeRects(bands.chrome, /*knobSize=*/24);
|
||||
const int stripW = chrome.rootStrip.width;
|
||||
CHECK(stripW == 824);
|
||||
|
||||
static void testResolveDragProportionalNonDivisibleWidth() {
|
||||
// THE REVIEW FINDING: 544px / 128 = 4.25 (non-integer). Old uniform-keyW math used
|
||||
// keyW = 4 (floor), accumulating ~7 keys of drift at the far end. The proportional fix
|
||||
// must agree with keyAtPoint at every point — specifically the far-end invariant:
|
||||
// a drag from note 0 by (width-1) pixels must land at keyAtPoint(width-1), which is 127.
|
||||
const int width = 544;
|
||||
const StripLayout L = layoutStrip(width, 40);
|
||||
CHECK(keyAtPoint(L, width - 1, L.keys.y + 1) == 127);
|
||||
CHECK(resolveDragNote(L, 0, width - 1) == 127);
|
||||
|
||||
// Also verify mid-strip coherence: for each key N, a drag from 0 by N's left-edge
|
||||
// pixel offset should land at N (or N-1 at worst — left-edge pixel is a boundary, so
|
||||
// rounding may round down). The critical direction is that it must NOT over-shoot by
|
||||
// more than 0 (it must reach at least the right key).
|
||||
for (int n = 1; n < kStripKeyCount; ++n) {
|
||||
const int leftPx = keyRect(L, n).x;
|
||||
const int resolved = resolveDragNote(L, 0, leftPx);
|
||||
// The left edge of key N is the first pixel "in" that key, so we expect resolved == N.
|
||||
// Allow resolved == N-1 only when the pixel is at the exact boundary (keyEdgeToX may
|
||||
// produce the same x for adjacent keys when keys share a pixel). Disallow over-shoot.
|
||||
const int expected = keyAtPoint(L, leftPx, L.keys.y + 1);
|
||||
CHECK(resolved >= expected - 1 && resolved <= expected + 1);
|
||||
}
|
||||
const StripLayout L = layoutStrip(stripW, 30);
|
||||
CHECK(L.whiteWidth == 10);
|
||||
const int margins = L.band.width - L.keys.width;
|
||||
CHECK(margins == 74);
|
||||
const int leftMargin = L.keys.x - L.band.x;
|
||||
CHECK(leftMargin == 37);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testLayoutNormalArea();
|
||||
testLayoutZeroArea();
|
||||
testKeyLeftMonotonicAndBounds();
|
||||
testKeyRectHalfOpen();
|
||||
testRootMarkerEqualsKeyRect();
|
||||
testKeyAtPointInverts();
|
||||
testKeyAtPointOffBand();
|
||||
testResolveDragRoundsToNearestKey();
|
||||
testResolveDragClampsAndNoOps();
|
||||
testResolveDragProportionalNonDivisibleWidth();
|
||||
testIsNaturalKeyFullOctave();
|
||||
testIsNaturalKeyBoundaryNotes();
|
||||
testIsNaturalKeyOutOfRangeClamped();
|
||||
testLayoutFillsTheBandAndCentresTheKeys();
|
||||
testDegenerateSizesYieldNoKeys();
|
||||
testSameClassKeysAreEqualWidthAcrossClientWidths();
|
||||
testKeyAreaSpansTheBandWithinOneKeyAtEveryTestedWidth();
|
||||
testIsNaturalKeyAcrossAnOctaveAndTheExtremes();
|
||||
testWhiteIndexCountsNaturalsBelowTheNote();
|
||||
testWhiteKeysTileTheKeyAreaGapFree();
|
||||
testBlackKeysStraddleTheirWhiteBoundary();
|
||||
testRootMarkerIsTheRootKey();
|
||||
testEveryKeyIsReachableAtItsOwnCentre();
|
||||
testBlackKeysWinInTheirZoneAndWhitesWinBelowIt();
|
||||
testKeyAtPointMissesOffBandAndInTheEndMargins();
|
||||
testHitTestingAKeyMarksThatSameKey();
|
||||
testDragTracksThePointerAndClampsWhenItWanders();
|
||||
testNoteNamesFollowTheC4Convention();
|
||||
testGutterAtTheShippedDefaultWindowSize();
|
||||
|
||||
if (g_fail == 0) std::printf("keyboard_strip: all tests passed\n");
|
||||
return g_fail != 0;
|
||||
if (g_fail == 0) {
|
||||
std::printf("keyboard_strip: all tests passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("keyboard_strip: %d failure(s)\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
// Standalone tests for reasampler::instrument::ui::sample_chrome — no VST3, no REAPER, no
|
||||
// test framework.
|
||||
//
|
||||
// Covers: the chrome band's two rows (toolbar over control row, tiling the band exactly);
|
||||
// the Browse button right-anchored inside the toolbar; the control row's fixed
|
||||
// right-anchored run in order (preview, velocity cell, curve button, Mono|Stereo) with the
|
||||
// root strip taking the remainder; the velocity knob centred in its cell above its label;
|
||||
// and degenerate bands yielding no inverted rects.
|
||||
// Covers: the chrome band's two rows (toolbar over strip row, tiling the band exactly); the
|
||||
// toolbar's fixed right-anchored run in order (preview, velocity cell, curve button,
|
||||
// Mono|Stereo, Browse) with the title taking the remainder; the velocity knob centred in its
|
||||
// cell above its label; the piano strip owning its whole row at every width; no rect on the
|
||||
// toolbar overlapping any other; and degenerate bands yielding no inverted rects.
|
||||
|
||||
#include "../src/core/instrument/ui/sample_bands.h"
|
||||
#include "../src/core/instrument/ui/sample_chrome.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <initializer_list>
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::instrument::ui;
|
||||
@@ -25,47 +26,83 @@ static Rect chromeBand(int w = 840, int h = 620) {
|
||||
return computeSampleBands(w, h, 120).chrome;
|
||||
}
|
||||
|
||||
// True when the two rects share at least one pixel.
|
||||
static bool overlaps(const Rect& a, const Rect& b) {
|
||||
if (a.empty() || b.empty()) return false;
|
||||
return a.x < b.right() && b.x < a.right() && a.y < b.bottom() && b.y < a.bottom();
|
||||
}
|
||||
|
||||
static void testRowsTileTheBandExactly() {
|
||||
const Rect band = chromeBand();
|
||||
const ChromeRects r = chromeRects(band, kKnob);
|
||||
CHECK(r.toolbar.y == band.y);
|
||||
CHECK(r.toolbar.height == kTitleHeight);
|
||||
CHECK(r.controls.y == r.toolbar.bottom());
|
||||
CHECK(r.controls.bottom() == band.bottom());
|
||||
CHECK(r.toolbar.x == band.x && r.toolbar.right() == band.right());
|
||||
CHECK(r.controls.x == band.x && r.controls.right() == band.right());
|
||||
// The toolbar has to be tall enough for the velocity knob cell it now carries.
|
||||
CHECK(r.toolbar.height >= kKnob);
|
||||
CHECK(r.controls.height > 0);
|
||||
}
|
||||
|
||||
static void testBrowseIsRightAnchoredInsideTheToolbar() {
|
||||
static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() {
|
||||
const Rect band = chromeBand();
|
||||
const ChromeRects r = chromeRects(band, kKnob);
|
||||
// Rightmost first: Browse, stereo, mono, curve button, velocity cell, preview, title.
|
||||
CHECK(r.navBrowse.right() == band.right() - kPad);
|
||||
CHECK(r.navBrowse.width == kNavButtonWidth);
|
||||
CHECK(r.navBrowse.y >= r.toolbar.y);
|
||||
CHECK(r.navBrowse.bottom() <= r.toolbar.bottom());
|
||||
}
|
||||
|
||||
static void testControlRunIsOrderedRightToLeftWithoutOverlap() {
|
||||
const Rect band = chromeBand();
|
||||
const ChromeRects r = chromeRects(band, kKnob);
|
||||
// Rightmost first: stereo, mono, curve button, velocity cell, preview, then the strip.
|
||||
CHECK(r.chanStereo.right() == band.right() - kPad);
|
||||
CHECK(r.chanStereo.right() <= r.navBrowse.x);
|
||||
CHECK(r.chanMono.right() == r.chanStereo.x);
|
||||
CHECK(r.curveBtn.right() <= r.chanMono.x);
|
||||
CHECK(r.velCell.right() <= r.curveBtn.x);
|
||||
CHECK(r.preview.right() <= r.velCell.x);
|
||||
CHECK(r.rootStrip.right() <= r.preview.x);
|
||||
CHECK(r.rootStrip.x == band.x + kPad);
|
||||
CHECK(r.rootStrip.width > 0);
|
||||
CHECK(r.title.right() <= r.preview.x);
|
||||
CHECK(r.title.x == band.x + kPad);
|
||||
CHECK(r.title.width > 0);
|
||||
|
||||
// Every toolbar rect sits inside the toolbar row.
|
||||
const Rect items[] = {r.title, r.preview, r.velCell, r.curveBtn, r.chanMono,
|
||||
r.chanStereo, r.navBrowse};
|
||||
for (const Rect& it : items) {
|
||||
CHECK(it.y >= r.toolbar.y && it.bottom() <= r.toolbar.bottom());
|
||||
}
|
||||
}
|
||||
|
||||
static void testRootStripTakesTheRemainderWidth() {
|
||||
static void testChromePartsNeverOverlapAtAnyWidth() {
|
||||
for (int w = 560; w <= 2400; w += 37) {
|
||||
const ChromeRects r = chromeRects(chromeBand(w, 620), kKnob);
|
||||
// The strip row and the toolbar row are disjoint by construction; the strip must
|
||||
// stay inside its own row, clear of every control.
|
||||
CHECK(!overlaps(r.toolbar, r.rootStrip));
|
||||
CHECK(r.rootStrip.y >= r.controls.y && r.rootStrip.bottom() <= r.controls.bottom());
|
||||
const Rect items[] = {r.preview, r.velCell, r.curveBtn, r.chanMono, r.chanStereo,
|
||||
r.navBrowse};
|
||||
for (const Rect& it : items) {
|
||||
CHECK(!overlaps(it, r.rootStrip));
|
||||
CHECK(!overlaps(it, r.title));
|
||||
}
|
||||
// The run's own members are pairwise disjoint (velKnob/velLabel are inside velCell,
|
||||
// so they are checked against the cell's neighbours, not the cell).
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
for (int j = i + 1; j < 6; ++j) CHECK(!overlaps(items[i], items[j]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void testStripOwnsItsWholeRowAndGrowsWithTheWindow() {
|
||||
const ChromeRects narrow = chromeRects(chromeBand(600, 620), kKnob);
|
||||
const ChromeRects wide = chromeRects(chromeBand(1000, 620), kKnob);
|
||||
// The fixed run keeps its size; every extra pixel goes to the strip.
|
||||
for (const ChromeRects* r : {&narrow, &wide}) {
|
||||
// Inset only by the shared band pad — the same inset the waveform band beneath uses,
|
||||
// so the two line up. No control shortens it.
|
||||
CHECK(r->rootStrip.x == r->controls.x + kPad);
|
||||
CHECK(r->rootStrip.right() == r->controls.right() - kPad);
|
||||
}
|
||||
CHECK(wide.rootStrip.width == narrow.rootStrip.width + 400);
|
||||
// The fixed run keeps its size; every extra pixel goes to the title, not the run.
|
||||
CHECK(wide.preview.width == narrow.preview.width);
|
||||
CHECK(wide.velCell.width == narrow.velCell.width);
|
||||
CHECK(wide.rootStrip.width == narrow.rootStrip.width + 400);
|
||||
CHECK(wide.title.width == narrow.title.width + 400);
|
||||
}
|
||||
|
||||
static void testVelocityKnobIsCentredInItsCellAboveTheLabel() {
|
||||
@@ -77,6 +114,7 @@ static void testVelocityKnobIsCentredInItsCellAboveTheLabel() {
|
||||
CHECK(leftGap == rightGap); // horizontally centred in the cell
|
||||
CHECK(r.velLabel.y == r.velKnob.bottom());
|
||||
CHECK(r.velLabel.bottom() == r.velCell.bottom());
|
||||
CHECK(r.velLabel.height > 0);
|
||||
CHECK(r.velLabel.x == r.velCell.x && r.velLabel.right() == r.velCell.right());
|
||||
}
|
||||
|
||||
@@ -85,28 +123,30 @@ static void testDegenerateBandYieldsNoInvertedRects() {
|
||||
CHECK(empty.toolbar.empty() && empty.controls.empty());
|
||||
CHECK(empty.rootStrip.empty() && empty.preview.empty());
|
||||
|
||||
// A band far too narrow for the fixed run: the strip collapses, nothing inverts.
|
||||
// A band far too narrow for the fixed run: everything collapses left, nothing inverts.
|
||||
const ChromeRects tiny = chromeRects(Rect::ltrb(0, 0, 40, kTitleHeight + kChromeRowHeight),
|
||||
kKnob);
|
||||
CHECK(tiny.rootStrip.right() >= tiny.rootStrip.x);
|
||||
CHECK(tiny.navBrowse.right() >= tiny.navBrowse.x);
|
||||
CHECK(tiny.preview.right() >= tiny.preview.x || tiny.preview.width < 0);
|
||||
const Rect items[] = {tiny.title, tiny.preview, tiny.velCell, tiny.velKnob, tiny.velLabel,
|
||||
tiny.curveBtn, tiny.chanMono, tiny.chanStereo, tiny.navBrowse,
|
||||
tiny.rootStrip};
|
||||
for (const Rect& it : items) CHECK(it.right() >= it.x && it.bottom() >= it.y);
|
||||
}
|
||||
|
||||
static void testToolbarOnlyBandStillPlacesTheNav() {
|
||||
// A band clipped to just the toolbar row: the control row is empty but Browse still
|
||||
// A band clipped to just the toolbar row: the strip row is empty but Browse still
|
||||
// resolves, so the empty state's call-to-action is never unreachable.
|
||||
const ChromeRects r = chromeRects(Rect::ltrb(0, 0, 400, kTitleHeight), kKnob);
|
||||
CHECK(r.toolbar.height == kTitleHeight);
|
||||
CHECK(r.controls.empty());
|
||||
CHECK(r.rootStrip.empty());
|
||||
CHECK(r.navBrowse.width == kNavButtonWidth);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testRowsTileTheBandExactly();
|
||||
testBrowseIsRightAnchoredInsideTheToolbar();
|
||||
testControlRunIsOrderedRightToLeftWithoutOverlap();
|
||||
testRootStripTakesTheRemainderWidth();
|
||||
testToolbarRunIsOrderedRightToLeftWithoutOverlap();
|
||||
testChromePartsNeverOverlapAtAnyWidth();
|
||||
testStripOwnsItsWholeRowAndGrowsWithTheWindow();
|
||||
testVelocityKnobIsCentredInItsCellAboveTheLabel();
|
||||
testDegenerateBandYieldsNoInvertedRects();
|
||||
testToolbarOnlyBandStillPlacesTheNav();
|
||||
|
||||
Reference in New Issue
Block a user