268 lines
14 KiB
C++
268 lines
14 KiB
C++
// Standalone tests for reasampler::instrument::ui::deck_groups — no VST3, no REAPER, no
|
|
// framework. knob_deck's own tests pin how a descriptor list LAYS OUT; these pin WHICH
|
|
// descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp),
|
|
// the Filter group's contents, the wrapped deck height at the editor's floor width and its fit
|
|
// inside the floor window, the hit-test reaching the new filter controls, the bipolar knob
|
|
// law's inverse pair, and the commit-tier routing — which controls are live, and which drags
|
|
// take the live tier.
|
|
|
|
#include "../src/core/instrument/ui/deck_groups.h"
|
|
#include "../src/core/instrument/ui/sample_bands.h"
|
|
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <vector>
|
|
|
|
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)
|
|
|
|
// The editor's floor width, which is also its default (checkSizeConstraint clamps to it), less
|
|
// the band allocator's kPad inset on each side.
|
|
static constexpr int kAvailAtMinWidth = kEditorMinWidth - 2 * kPad;
|
|
|
|
static int indexOfGroup(const std::vector<DeckGroupDesc>& g, int id) {
|
|
for (std::size_t i = 0; i < g.size(); ++i) {
|
|
if (g[i].id == id) return static_cast<int>(i);
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
static int cell(DeckParam p) { return static_cast<int>(p); }
|
|
|
|
static void testDeckReadsPitchThenFilterThenAmpLeftToRight() {
|
|
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
|
const int pitch = indexOfGroup(g, kGroupPitch);
|
|
const int penv = indexOfGroup(g, kGroupPitchEnv);
|
|
const int filt = indexOfGroup(g, kGroupFilter);
|
|
const int fenv = indexOfGroup(g, kGroupFilterEnv);
|
|
const int amp = indexOfGroup(g, kGroupAmpEnv);
|
|
CHECK(pitch >= 0 && penv >= 0 && filt >= 0 && fenv >= 0 && amp >= 0);
|
|
// The signal flow, left to right. Each envelope group trails its own stage.
|
|
CHECK(pitch < penv);
|
|
CHECK(penv < filt);
|
|
CHECK(filt < fenv);
|
|
CHECK(fenv < amp);
|
|
// The two instance-wide groups stay at the end.
|
|
CHECK(amp < indexOfGroup(g, kGroupVoice));
|
|
CHECK(indexOfGroup(g, kGroupVoice) < indexOfGroup(g, kGroupMaster));
|
|
}
|
|
}
|
|
|
|
static void testFilterGroupCarriesItsFiveToneControlsPlusModulation() {
|
|
const std::vector<DeckGroupDesc>& g = sampleDeckGroups(PlayMode::Gate);
|
|
const DeckGroupDesc& f = g[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
|
|
const std::vector<int> expected = {
|
|
cell(DeckParam::kFilterMorph), cell(DeckParam::kFilterCutoff),
|
|
cell(DeckParam::kFilterQ), cell(DeckParam::kFilterDrive),
|
|
cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterVel),
|
|
cell(DeckParam::kFilterKeyTrack)};
|
|
CHECK(f.cellIds == expected);
|
|
// Off by default is a state question, but reachability is a layout one: the enable
|
|
// toggle is in the caption row and the morph law in the knob row.
|
|
CHECK(f.captionToggle.id == cell(DeckParam::kFilterEnable));
|
|
CHECK(f.rowToggle.id == cell(DeckParam::kFilterLaw));
|
|
|
|
const DeckGroupDesc& fe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupFilterEnv))];
|
|
const std::vector<int> env = {
|
|
cell(DeckParam::kFilterEnvAttack), cell(DeckParam::kFilterEnvHold),
|
|
cell(DeckParam::kFilterEnvDecay), cell(DeckParam::kFilterEnvSustain),
|
|
cell(DeckParam::kFilterEnvRelease)};
|
|
CHECK(fe.cellIds == env);
|
|
// The filter envelope has no enable of its own — the FILTER group's toggle governs both.
|
|
CHECK(fe.captionToggle.id == -1);
|
|
CHECK(fe.rowToggle.id == -1);
|
|
}
|
|
|
|
static void testAmpGroupWidthSurvivesAGateTriggerFlip() {
|
|
// The reserved blanks are what stop a mode flip reflowing the groups beside AMP.
|
|
const std::vector<DeckGroupDesc> gate = sampleDeckGroups(PlayMode::Gate);
|
|
const std::vector<DeckGroupDesc> trig = sampleDeckGroups(PlayMode::Trigger);
|
|
const DeckGroupDesc& a = gate[static_cast<std::size_t>(indexOfGroup(gate, kGroupAmpEnv))];
|
|
const DeckGroupDesc& b = trig[static_cast<std::size_t>(indexOfGroup(trig, kGroupAmpEnv))];
|
|
CHECK(deckGroupWidth(a) == deckGroupWidth(b));
|
|
CHECK(a.cellIds.size() == b.cellIds.size());
|
|
CHECK(b.cellIds[3] == -1 && b.cellIds[4] == -1);
|
|
// Every other group is mode-independent, so the whole deck's height is too.
|
|
CHECK(deckHeight(gate, kAvailAtMinWidth) == deckHeight(trig, kAvailAtMinWidth));
|
|
}
|
|
|
|
static void testWrappedDeckHeightAtTheEditorFloorWidth() {
|
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
|
// At the floor (== default) 840 the deck takes two rows: PITCH + PITCH ENV + FILTER fill
|
|
// the first, the remaining four fit the second.
|
|
CHECK(deckRowCount(g, kAvailAtMinWidth) == 2);
|
|
CHECK(deckHeight(g, kAvailAtMinWidth) == 2 * kDeckGroupH + kDeckRowGap);
|
|
|
|
// Whole groups only, never split: every group's box lies inside the available width or is
|
|
// the first of its row.
|
|
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
|
|
CHECK(dl.groups.size() == g.size());
|
|
for (const DeckGroupLayout& gl : dl.groups) {
|
|
CHECK(gl.box.x >= kPad);
|
|
CHECK(gl.box.height == kDeckGroupH);
|
|
}
|
|
}
|
|
|
|
// The guard the raised floor exists to provide: at the smallest window the host can produce,
|
|
// the deck band still lands inside the client area AND the waveform still gets its two-lane
|
|
// floor. Growing the deck past what 620 px can hold fails HERE instead of silently pushing
|
|
// FILTER ENV / AMP / VOICE / MASTER off-screen, where there is no scroll to reach them.
|
|
static void testDeckFitsInsideTheEnforcedMinimumWindow() {
|
|
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
|
const int h = deckHeight(g, kAvailAtMinWidth);
|
|
const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h);
|
|
CHECK(b.decks.height == h);
|
|
// Bottom-anchored INSIDE the pad is the whole assertion: the degrade path pushes the
|
|
// deck down until the waveform hits its floor, so any deck too tall to fit stops
|
|
// landing on this exact line. A `<= kEditorMinHeight` bound would not catch it — the
|
|
// degrade can still leave the deck ending at the window edge.
|
|
CHECK(b.decks.bottom() == kEditorMinHeight - kPad);
|
|
CHECK(b.waveform.height >= kWaveformMinHeight);
|
|
}
|
|
}
|
|
|
|
static void testHitTestResolvesTheNewFilterControls() {
|
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
|
const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth);
|
|
const DeckGroupLayout& f =
|
|
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
|
|
|
|
// Every knob cell resolves to its own id, from the centre of its cell.
|
|
for (const DeckCellLayout& c : f.cells) {
|
|
const DeckHit hit = hitTestDeck(dl, c.cell.x + c.cell.width / 2,
|
|
c.cell.y + c.cell.height / 2);
|
|
CHECK(hit.kind == DeckHitKind::Knob);
|
|
CHECK(hit.id == c.id);
|
|
}
|
|
CHECK(f.cells.size() == 7);
|
|
CHECK(f.cells[1].id == cell(DeckParam::kFilterCutoff));
|
|
|
|
// The enable toggle's two segments and the morph-law row toggle's two.
|
|
const DeckHit off = hitTestDeck(dl, f.captionToggle.seg0.x + 2,
|
|
f.captionToggle.seg0.y + 2);
|
|
CHECK(off.kind == DeckHitKind::CaptionToggle);
|
|
CHECK(off.id == cell(DeckParam::kFilterEnable) && off.segment == 0);
|
|
const DeckHit on = hitTestDeck(dl, f.captionToggle.seg1.x + 2,
|
|
f.captionToggle.seg1.y + 2);
|
|
CHECK(on.id == cell(DeckParam::kFilterEnable) && on.segment == 1);
|
|
|
|
const DeckHit band = hitTestDeck(dl, f.rowToggle.seg0.x + 2, f.rowToggle.seg0.y + 2);
|
|
CHECK(band.kind == DeckHitKind::RowToggle);
|
|
CHECK(band.id == cell(DeckParam::kFilterLaw) && band.segment == 0);
|
|
const DeckHit notch = hitTestDeck(dl, f.rowToggle.seg1.x + 2, f.rowToggle.seg1.y + 2);
|
|
CHECK(notch.id == cell(DeckParam::kFilterLaw) && notch.segment == 1);
|
|
|
|
// The filter-envelope knobs resolve too, and are distinct ids from the amp's.
|
|
const DeckGroupLayout& fe =
|
|
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilterEnv))];
|
|
const DeckHit attack = hitTestDeck(dl, fe.cells[0].cell.x + 4, fe.cells[0].cell.y + 4);
|
|
CHECK(attack.kind == DeckHitKind::Knob);
|
|
CHECK(attack.id == cell(DeckParam::kFilterEnvAttack));
|
|
CHECK(attack.id != cell(DeckParam::kAttack));
|
|
}
|
|
|
|
static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
|
|
// Centre is EXACT in both directions: a knob parked at 0.5 stores 0, and 0 reads back
|
|
// 0.5 — no residual modulation from a rounding hair.
|
|
CHECK(deckBipolarFromNorm(0.5) == 0.0);
|
|
CHECK(deckNormFromBipolar(0.0) == 0.5);
|
|
CHECK(deckBipolarFromNorm(0.0) == -1.0);
|
|
CHECK(deckBipolarFromNorm(1.0) == 1.0);
|
|
for (int i = 0; i <= 200; ++i) {
|
|
const double norm = static_cast<double>(i) / 200.0;
|
|
CHECK(std::fabs(deckNormFromBipolar(deckBipolarFromNorm(norm)) - norm) < 1e-12);
|
|
const double value = -1.0 + static_cast<double>(i) / 100.0;
|
|
CHECK(std::fabs(deckBipolarFromNorm(deckNormFromBipolar(value)) - value) < 1e-12);
|
|
}
|
|
// Out of range clamps rather than extrapolating.
|
|
CHECK(deckBipolarFromNorm(-3.0) == -1.0);
|
|
CHECK(deckBipolarFromNorm(3.0) == 1.0);
|
|
CHECK(deckNormFromBipolar(-3.0) == 0.0);
|
|
CHECK(deckNormFromBipolar(3.0) == 1.0);
|
|
}
|
|
|
|
static void testEveryDeckControlIsClassifiedLiveOrReloading() {
|
|
// The live set: the six filter tone/modulation knobs, plus every stage time and stage
|
|
// level on all three envelopes.
|
|
const DeckParam live[] = {
|
|
DeckParam::kFilterMorph, DeckParam::kFilterCutoff, DeckParam::kFilterQ,
|
|
DeckParam::kFilterDrive, DeckParam::kFilterModAmt, DeckParam::kFilterKeyTrack,
|
|
DeckParam::kAttack, DeckParam::kHold, DeckParam::kDecay, DeckParam::kSustain,
|
|
DeckParam::kRelease,
|
|
DeckParam::kFilterEnvAttack, DeckParam::kFilterEnvHold, DeckParam::kFilterEnvDecay,
|
|
DeckParam::kFilterEnvSustain, DeckParam::kFilterEnvRelease,
|
|
DeckParam::kPitchEnvAttack, DeckParam::kPitchEnvDecay, DeckParam::kPitchEnvDepth,
|
|
};
|
|
for (DeckParam p : live) CHECK(isLiveDeckParam(p));
|
|
|
|
// Everything else reloads or rebuilds; deck_groups.h is the home for why each exclusion
|
|
// is excluded.
|
|
const DeckParam reloads[] = {
|
|
DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kPitchEnvEnable,
|
|
DeckParam::kFilterEnable, DeckParam::kFilterLaw, DeckParam::kFilterVel,
|
|
DeckParam::kKeyTrack, DeckParam::kTrigLength, DeckParam::kTrigFadeIn,
|
|
DeckParam::kTrigFadeOut, DeckParam::kVoiceCount, DeckParam::kVoiceMode,
|
|
DeckParam::kMonoTrigger, DeckParam::kMasterGain,
|
|
};
|
|
for (DeckParam p : reloads) CHECK(!isLiveDeckParam(p));
|
|
|
|
// COVERAGE, not cardinality: every id appears in EXACTLY ONE of the two lists. A sum check
|
|
// would stay green if an edit duplicated one id and dropped another, leaving that one
|
|
// unclassified.
|
|
for (int i = 0; i < static_cast<int>(DeckParam::kCount); ++i) {
|
|
const DeckParam p = static_cast<DeckParam>(i);
|
|
int seen = 0;
|
|
for (DeckParam q : live) if (q == p) ++seen;
|
|
for (DeckParam q : reloads) if (q == p) ++seen;
|
|
if (seen != 1) std::printf(" (deck id %d classified %d times)\n", i, seen);
|
|
CHECK(seen == 1);
|
|
}
|
|
}
|
|
|
|
static void testOnlyALiveControlsDragTakesTheLiveTier() {
|
|
// isLiveDeckParam alone is not what a user experiences — liveCommitFor is, at the editor's
|
|
// commit site. Inverting it has to FAIL a test rather than merely read wrong.
|
|
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kFilterCutoff),
|
|
PlayMode::Gate));
|
|
// A knob's routing is the knob's, not the play mode's.
|
|
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kAttack),
|
|
PlayMode::Trigger));
|
|
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kTrigFadeIn),
|
|
PlayMode::Trigger));
|
|
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kMasterGain),
|
|
PlayMode::Gate));
|
|
// The shell's processor-side sentinels (preview velocity is -2) and any out-of-range id
|
|
// are not parameter-set controls, so they must never reach the enum.
|
|
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -2, PlayMode::Gate));
|
|
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -1, PlayMode::Gate));
|
|
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kCount),
|
|
PlayMode::Gate));
|
|
// An envelope-node drag edits the AHDSR in Gate; the same drag in Trigger rewrites the
|
|
// play span, which is not a live control.
|
|
CHECK(liveCommitFor(LiveDragKind::kEnvNode, -1, PlayMode::Gate));
|
|
CHECK(!liveCommitFor(LiveDragKind::kEnvNode, -1, PlayMode::Trigger));
|
|
// Every other drag (markers, scrollbar, curve nodes) commits through a reload.
|
|
CHECK(!liveCommitFor(LiveDragKind::kOther, static_cast<int>(DeckParam::kFilterCutoff),
|
|
PlayMode::Gate));
|
|
}
|
|
|
|
int main() {
|
|
testEveryDeckControlIsClassifiedLiveOrReloading();
|
|
testOnlyALiveControlsDragTakesTheLiveTier();
|
|
testDeckReadsPitchThenFilterThenAmpLeftToRight();
|
|
testFilterGroupCarriesItsFiveToneControlsPlusModulation();
|
|
testAmpGroupWidthSurvivesAGateTriggerFlip();
|
|
testWrappedDeckHeightAtTheEditorFloorWidth();
|
|
testDeckFitsInsideTheEnforcedMinimumWindow();
|
|
testHitTestResolvesTheNewFilterControls();
|
|
testBipolarKnobLawRoundTripsAndIsExactAtCentre();
|
|
if (g_fail == 0) std::printf("deck_groups: all tests passed\n");
|
|
return g_fail == 0 ? 0 : 1;
|
|
}
|