250 lines
14 KiB
C++
250 lines
14 KiB
C++
// Standalone tests for reasampler::instrument::ui::deck_groups' commit-tier routing and
|
|
// overlay-selection state machine — no VST3, no REAPER, no framework. Split from
|
|
// test_deck_groups.cpp on the seam those fixtures already had: nothing here touches
|
|
// layoutDeck, DeckGroupWidth, or any other geometry API — deckParamCommit/liveCommitFor (which
|
|
// controls are live, and which drags take the live tier) and the overlay-selection state
|
|
// machine (exclusivity, the none resting state, and which selections are inert) are pure
|
|
// control-id/enum predicates. test_deck_groups.cpp keeps the geometry/row/width fixtures.
|
|
|
|
#include "../src/core/instrument/ui/deck_groups.h"
|
|
|
|
#include <cstdio>
|
|
|
|
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)
|
|
|
|
static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() {
|
|
// The live set: the seven filter tone/modulation knobs, the baseline pitch offset, plus
|
|
// every stage time, stage level, hold fraction and curve exponent on all three envelopes —
|
|
// in BOTH mode shapes.
|
|
const DeckParam live[] = {
|
|
DeckParam::kPitch,
|
|
DeckParam::kFilterMorph, DeckParam::kFilterCutoff, DeckParam::kFilterQ,
|
|
DeckParam::kFilterDrive, DeckParam::kFilterModAmt, DeckParam::kFilterVel,
|
|
DeckParam::kFilterKeyTrack,
|
|
DeckParam::kAttack, DeckParam::kHold, DeckParam::kDecay, DeckParam::kSustain,
|
|
DeckParam::kRelease,
|
|
DeckParam::kTrigAttack, DeckParam::kTrigHold, DeckParam::kTrigDecay,
|
|
DeckParam::kFilterEnvAttack, DeckParam::kFilterEnvHold, DeckParam::kFilterEnvDecay,
|
|
DeckParam::kFilterEnvSustain, DeckParam::kFilterEnvRelease,
|
|
DeckParam::kFilterTrigAttack, DeckParam::kFilterTrigHold, DeckParam::kFilterTrigDecay,
|
|
DeckParam::kPitchEnvAttack, DeckParam::kPitchEnvHold, DeckParam::kPitchEnvDecay,
|
|
DeckParam::kPitchEnvDepth,
|
|
DeckParam::kAttackCurve, DeckParam::kDecayCurve, DeckParam::kReleaseCurve,
|
|
DeckParam::kTrigAttackCurve, DeckParam::kTrigDecayCurve,
|
|
DeckParam::kPitchEnvAttackCurve, DeckParam::kPitchEnvDecayCurve,
|
|
DeckParam::kFilterEnvAttackCurve, DeckParam::kFilterEnvDecayCurve,
|
|
DeckParam::kFilterEnvReleaseCurve,
|
|
DeckParam::kFilterTrigAttackCurve, DeckParam::kFilterTrigDecayCurve,
|
|
// The one live control that does not ride the live block: a lock-free atomic the audio
|
|
// thread applies as a post-sum multiply. The tier answers "does an edit reach the audio
|
|
// without a reload", not "which mechanism carries it".
|
|
DeckParam::kMasterGain,
|
|
};
|
|
for (DeckParam p : live) CHECK(deckParamCommit(p) == LiveCommit::Live);
|
|
|
|
// The note-on-latched tier: published like a live control, read only at note-on. Asserted as
|
|
// its OWN state rather than as "not Reload" — the whole point of widening the predicate is
|
|
// that none of these falls back into either neighbour, and the VST3 parameter surface reads
|
|
// this classification to decide what it exposes to the host.
|
|
const DeckParam latched[] = {DeckParam::kRate, DeckParam::kKeyTrack,
|
|
DeckParam::kTrigLength};
|
|
for (DeckParam p : latched) CHECK(deckParamCommit(p) == LiveCommit::NoteOnLatched);
|
|
|
|
// 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::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve,
|
|
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
|
|
DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode,
|
|
DeckParam::kVoiceCount, DeckParam::kVoiceMode,
|
|
DeckParam::kMonoTrigger, DeckParam::kLimiterEnable,
|
|
DeckParam::kMasterMeter, DeckParam::kMasterGr,
|
|
};
|
|
for (DeckParam p : reloads) CHECK(deckParamCommit(p) == LiveCommit::Reload);
|
|
|
|
// COVERAGE, not cardinality: every id appears in EXACTLY ONE of the three 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 : latched) 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() {
|
|
// deckParamCommit 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.
|
|
const auto knob = [](DeckParam p) {
|
|
return liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(p));
|
|
};
|
|
CHECK(knob(DeckParam::kFilterCutoff) == LiveCommit::Live);
|
|
CHECK(knob(DeckParam::kAttack) == LiveCommit::Live);
|
|
CHECK(knob(DeckParam::kPitch) == LiveCommit::Live);
|
|
// The Trigger amp is live now that the fade pair folded into the AHD — the one behavioural
|
|
// consequence of that consolidation.
|
|
CHECK(knob(DeckParam::kTrigAttack) == LiveCommit::Live);
|
|
CHECK(knob(DeckParam::kTrigDecayCurve) == LiveCommit::Live);
|
|
// Rate keeps its own tier through the drag site: it must not arrive as Live (which would let
|
|
// it move a sounding note) nor as Reload (which would re-decode the WAV under a swept knob).
|
|
CHECK(knob(DeckParam::kRate) == LiveCommit::NoteOnLatched);
|
|
// Trigger length resolves playEnd_ and key-track the pitch ratio — both facts a voice fixes
|
|
// at note-on, which is the latched tier's own definition rather than the reload tier's.
|
|
CHECK(knob(DeckParam::kTrigLength) == LiveCommit::NoteOnLatched);
|
|
CHECK(knob(DeckParam::kKeyTrack) == LiveCommit::NoteOnLatched);
|
|
// Master gain is Live and reaches the audio BESIDE the live block rather than through it —
|
|
// one atomic the audio thread applies as a post-sum multiply. Classifying it Reload would
|
|
// claim a gain move re-decodes the WAV, which it never did.
|
|
CHECK(knob(DeckParam::kMasterGain) == LiveCommit::Live);
|
|
CHECK(knob(DeckParam::kAmpEnvSelect) == LiveCommit::Reload);
|
|
// 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) == LiveCommit::Reload);
|
|
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, -1) == LiveCommit::Reload);
|
|
CHECK(knob(DeckParam::kCount) == LiveCommit::Reload);
|
|
// Every stage value an envelope node can reach is live, in either mode shape.
|
|
CHECK(liveCommitFor(LiveDragKind::kEnvNode, -1) == LiveCommit::Live);
|
|
// Every other drag (markers, scrollbar, curve nodes) commits through a reload.
|
|
CHECK(liveCommitFor(LiveDragKind::kOther, static_cast<int>(DeckParam::kFilterCutoff)) ==
|
|
LiveCommit::Reload);
|
|
}
|
|
|
|
// --- The overlay focus state machine -------------------------------------------
|
|
|
|
static int radio(DeckParam p) { return static_cast<int>(p); }
|
|
|
|
// EXCLUSIVITY, and the whole of it: the focus is a function of the clicked GROUP alone, so
|
|
// wherever it was before, clicking an envelope deck lands on that deck's envelope. Two
|
|
// envelopes can never be overlay-active at once, and no previous state can change the answer.
|
|
static void testOverlayFocusIsExclusiveAndIndependentOfThePreviousSelection() {
|
|
const struct { int group; OverlayEnv env; } decks[] = {
|
|
{kGroupAmpEnv, OverlayEnv::kAmp},
|
|
{kGroupPitchEnv, OverlayEnv::kPitch},
|
|
{kGroupFilterEnv, OverlayEnv::kFilter},
|
|
};
|
|
for (const auto& d : decks) CHECK(overlayEnvForGroup(d.group) == d.env);
|
|
// Distinct answers, so no two decks can select the same overlay.
|
|
CHECK(overlayEnvForGroup(kGroupAmpEnv) != overlayEnvForGroup(kGroupPitchEnv));
|
|
CHECK(overlayEnvForGroup(kGroupPitchEnv) != overlayEnvForGroup(kGroupFilterEnv));
|
|
CHECK(overlayEnvForGroup(kGroupAmpEnv) != overlayEnvForGroup(kGroupFilterEnv));
|
|
}
|
|
|
|
// Focus SETS; it does not toggle. Driven as the SHELL drives it — `focus = f(group)` over a
|
|
// click sequence starting from every prior focus — because that composition is the thing the
|
|
// retired re-click-clears branch broke: a second click on the focused deck (which is every
|
|
// knob tweak on it) landed back on kNone. The map taking no current focus is what makes that
|
|
// unreachable; this pins the sequence a reader would otherwise have to reconstruct.
|
|
static void testAClickSequenceOnOneDeckNeverLeavesIt() {
|
|
for (OverlayEnv prior : {OverlayEnv::kNone, OverlayEnv::kAmp, OverlayEnv::kPitch,
|
|
OverlayEnv::kFilter}) {
|
|
OverlayEnv focus = prior;
|
|
// Panel, then knob, then button — all three land in the same group, so all three are
|
|
// the same assignment, whatever the click before them was.
|
|
for (int i = 0; i < 3; ++i) {
|
|
focus = overlayEnvForGroup(kGroupFilterEnv);
|
|
CHECK(focus == OverlayEnv::kFilter);
|
|
}
|
|
// And leaving is a click ELSEWHERE, never a repeat of the one that got here.
|
|
focus = overlayEnvForGroup(kGroupVoice);
|
|
CHECK(focus == OverlayEnv::kNone);
|
|
}
|
|
}
|
|
|
|
// kNone is still a reachable resting state — reached by clicking a control surface OUTSIDE the
|
|
// envelope decks rather than by clicking the active one again.
|
|
static void testClickingAnyNonEnvelopeDeckClearsTheFocus() {
|
|
for (int id : {kGroupPitch, kGroupFilter, kGroupVelocity, kGroupVoice, kGroupMaster}) {
|
|
CHECK(overlayEnvForGroup(id) == OverlayEnv::kNone);
|
|
}
|
|
CHECK(overlayEnvForGroup(-1) == OverlayEnv::kNone); // off the deck entirely
|
|
// A CONTROL id is not a group id: the map keys on groups now, and a stray control id must
|
|
// never light an overlay by numeric coincidence.
|
|
CHECK(overlayEnvForGroup(radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kNone);
|
|
CHECK(overlayEnvForGroup(radio(DeckParam::kFilterCutoff)) == OverlayEnv::kNone);
|
|
}
|
|
|
|
// The two group gates, spelled the way the predicates read them. Spline flags default off, so
|
|
// a case that says nothing about them is asserting the staged behaviour.
|
|
static DeckEnableState gates(bool pitchEnv, bool filter) {
|
|
DeckEnableState s;
|
|
s.pitchEnvEnabled = pitchEnv;
|
|
s.filterEnabled = filter;
|
|
return s;
|
|
}
|
|
|
|
// An overlay whose deck group is switched OFF is inert, matching the drawn-but-dead knobs on
|
|
// the same params: a node drag must not reach a value the knob refuses.
|
|
static void testOverlayIsInertExactlyWhenItsGroupToggleIsOff() {
|
|
CHECK(overlayEnvInert(OverlayEnv::kPitch, gates(/*pitchEnv=*/false, /*filter=*/true)));
|
|
CHECK(!overlayEnvInert(OverlayEnv::kPitch, gates(true, true)));
|
|
CHECK(overlayEnvInert(OverlayEnv::kFilter, gates(true, /*filter=*/false)));
|
|
CHECK(!overlayEnvInert(OverlayEnv::kFilter, gates(true, true)));
|
|
// Amp has no enable toggle, so it is never inert; kNone draws nothing to grab.
|
|
CHECK(!overlayEnvInert(OverlayEnv::kAmp, gates(false, false)));
|
|
CHECK(!overlayEnvInert(OverlayEnv::kNone, gates(false, false)));
|
|
|
|
// The enable gate alone, which the SPLINE overlay reads: it survives a mode switch, so a
|
|
// disabled group's contour is as dead as its knobs.
|
|
CHECK(!overlayEnvEnabled(OverlayEnv::kPitch, gates(false, true)));
|
|
CHECK(overlayEnvEnabled(OverlayEnv::kAmp, gates(false, false)));
|
|
// ...while the staged overlay additionally goes inert once the envelope is drawn: its
|
|
// nodes are no longer what the overlay is editing.
|
|
DeckEnableState drawn = gates(true, true);
|
|
drawn.ampSpline = true;
|
|
CHECK(overlayEnvInert(OverlayEnv::kAmp, drawn));
|
|
CHECK(overlayEnvEnabled(OverlayEnv::kAmp, drawn));
|
|
}
|
|
|
|
// A deck knob goes inert exactly with its group's own enable toggle — including the filter's
|
|
// VELOCITY cell, which sits in the VELOCITY group visually but is a filter parameter and must
|
|
// go inert with the rest of the filter (the reachable-through-the-deck route mouseDownDeck
|
|
// checks before ever routing a curve-cell click to the popup).
|
|
static void testDeckKnobIsInertExactlyWithItsGroupsEnableToggle() {
|
|
CHECK(deckKnobInert(DeckParam::kFilterVelCurve, gates(/*pitchEnv=*/true, /*filter=*/false)));
|
|
CHECK(!deckKnobInert(DeckParam::kFilterVelCurve, gates(true, true)));
|
|
CHECK(deckKnobInert(DeckParam::kFilterCutoff, gates(true, false)));
|
|
CHECK(!deckKnobInert(DeckParam::kFilterCutoff, gates(true, true)));
|
|
CHECK(deckKnobInert(DeckParam::kPitchEnvDepth, gates(/*pitchEnv=*/false, true)));
|
|
CHECK(!deckKnobInert(DeckParam::kPitchEnvDepth, gates(true, true)));
|
|
// The amp's own velocity cell and every ordinary control are never inert here — inertness
|
|
// is a filter/pitch-env-group-only concept until an envelope is drawn.
|
|
CHECK(!deckKnobInert(DeckParam::kAmpVelCurve, gates(false, false)));
|
|
CHECK(!deckKnobInert(DeckParam::kAttack, gates(false, false)));
|
|
}
|
|
|
|
// A drawn envelope's STAGED segment knobs go inert; the mode toggle itself and the depth knobs
|
|
// that scale either shape stay live. (Which segment knobs, per envelope, is pinned in
|
|
// spline_egs_tests alongside the rest of the spline rules.)
|
|
static void testAModeToggleIsNotALiveControl() {
|
|
CHECK(deckParamCommit(DeckParam::kAmpEnvMode) == LiveCommit::Reload);
|
|
CHECK(deckParamCommit(DeckParam::kPitchEnvMode) == LiveCommit::Reload);
|
|
CHECK(deckParamCommit(DeckParam::kFilterEnvMode) == LiveCommit::Reload);
|
|
// It needs no overlay map of its own: the toggle sits INSIDE its envelope's deck, so the
|
|
// click that flips it already focuses that envelope through the group map.
|
|
CHECK(overlayEnvForGroup(kGroupAmpEnv) == OverlayEnv::kAmp);
|
|
}
|
|
|
|
int main() {
|
|
testOverlayFocusIsExclusiveAndIndependentOfThePreviousSelection();
|
|
testAClickSequenceOnOneDeckNeverLeavesIt();
|
|
testClickingAnyNonEnvelopeDeckClearsTheFocus();
|
|
testOverlayIsInertExactlyWhenItsGroupToggleIsOff();
|
|
testDeckKnobIsInertExactlyWithItsGroupsEnableToggle();
|
|
testAModeToggleIsNotALiveControl();
|
|
testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers();
|
|
testOnlyALiveControlsDragTakesTheLiveTier();
|
|
if (g_fail == 0) std::printf("deck_groups_state: all tests passed\n");
|
|
return g_fail == 0 ? 0 : 1;
|
|
}
|