// 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 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, }; 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 Rate must not fall back into either neighbour, and Γ-W4-T1 reads this classification // to decide what it exposes to the host. const DeckParam latched[] = {DeckParam::kRate}; 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::kKeyTrack, DeckParam::kTrigLength, DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect, DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode, DeckParam::kVoiceCount, DeckParam::kVoiceMode, DeckParam::kMonoTrigger, DeckParam::kMasterGain, 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(DeckParam::kCount); ++i) { const DeckParam p = static_cast(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(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); CHECK(knob(DeckParam::kTrigLength) == LiveCommit::Reload); CHECK(knob(DeckParam::kMasterGain) == LiveCommit::Reload); 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(DeckParam::kFilterCutoff)) == LiveCommit::Reload); } // --- The overlay selection state machine --------------------------------------- static int radio(DeckParam p) { return static_cast(p); } // EXCLUSIVITY: picking another deck's radio switches to it outright — two envelopes can never // be overlay-active at once, whatever the previous selection was. static void testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks() { const OverlayEnv states[] = {OverlayEnv::kNone, OverlayEnv::kAmp, OverlayEnv::kPitch, OverlayEnv::kFilter}; for (OverlayEnv from : states) { if (from != OverlayEnv::kAmp) { CHECK(nextOverlaySelection(from, radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kAmp); } if (from != OverlayEnv::kPitch) { CHECK(nextOverlaySelection(from, radio(DeckParam::kPitchEnvSelect)) == OverlayEnv::kPitch); } if (from != OverlayEnv::kFilter) { CHECK(nextOverlaySelection(from, radio(DeckParam::kFilterEnvSelect)) == OverlayEnv::kFilter); } } } // kNone is a RESTING STATE the user can get back to: clicking the active radio clears it. static void testClickingTheActiveOverlayRadioClearsToNone() { CHECK(nextOverlaySelection(OverlayEnv::kAmp, radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kNone); CHECK(nextOverlaySelection(OverlayEnv::kPitch, radio(DeckParam::kPitchEnvSelect)) == OverlayEnv::kNone); CHECK(nextOverlaySelection(OverlayEnv::kFilter, radio(DeckParam::kFilterEnvSelect)) == OverlayEnv::kNone); } // A control that is not one of the three radios selects nothing and clears nothing. static void testANonRadioIdLeavesTheOverlaySelectionAlone() { CHECK(overlayEnvForRadio(radio(DeckParam::kFilterCutoff)) == OverlayEnv::kNone); CHECK(overlayEnvForRadio(-1) == OverlayEnv::kNone); CHECK(nextOverlaySelection(OverlayEnv::kFilter, radio(DeckParam::kFilterCutoff)) == OverlayEnv::kFilter); CHECK(nextOverlaySelection(OverlayEnv::kAmp, 9999) == OverlayEnv::kAmp); } // 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 testAModeToggleIsNeitherLiveNorAnOverlayRadio() { CHECK(deckParamCommit(DeckParam::kAmpEnvMode) == LiveCommit::Reload); CHECK(deckParamCommit(DeckParam::kPitchEnvMode) == LiveCommit::Reload); CHECK(deckParamCommit(DeckParam::kFilterEnvMode) == LiveCommit::Reload); CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kAmp); CHECK(overlayEnvForModeToggle(radio(DeckParam::kPitchEnvMode)) == OverlayEnv::kPitch); CHECK(overlayEnvForModeToggle(radio(DeckParam::kFilterEnvMode)) == OverlayEnv::kFilter); // A mode toggle must not be mistaken for the overlay-select radio beside it. CHECK(overlayEnvForRadio(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kNone); CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kNone); } int main() { testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks(); testClickingTheActiveOverlayRadioClearsToNone(); testANonRadioIdLeavesTheOverlaySelectionAlone(); testOverlayIsInertExactlyWhenItsGroupToggleIsOff(); testDeckKnobIsInertExactlyWithItsGroupsEnableToggle(); testAModeToggleIsNeitherLiveNorAnOverlayRadio(); testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers(); testOnlyALiveControlsDragTakesTheLiveTier(); if (g_fail == 0) std::printf("deck_groups_state: all tests passed\n"); return g_fail == 0 ? 0 : 1; }