Files
reasampler/tests/test_keyboard_strip.cpp
T

337 lines
16 KiB
C++

// 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, 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;
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: 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 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 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);
}
// --- the sharp one: same-class widths are uniform at every client width tested ---
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);
}
}
}
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());
}
}
}
// --- 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();
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 testEveryKeyIsReachableAtItsOwnCentre() {
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;
// 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 testBlackKeysWinInTheirZoneAndWhitesWinBelowIt() {
const StripLayout L = wideStrip();
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 testDragTracksThePointerAndClampsWhenItWanders() {
const StripLayout L = wideStrip();
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);
}
// --- noteName -----------------------------------------------------------------
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");
}
// --- the gutter at the shipped default window size -----------------------------
// 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);
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() {
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 0;
}
std::printf("keyboard_strip: %d failure(s)\n", g_fail);
return 1;
}