// 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 VELOCITY group's exclusive ownership of the three curve // cells and its placement immediately left of VOICE, 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, the commit-tier routing — 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). #include "../src/core/instrument/ui/deck_groups.h" #include "../src/core/instrument/ui/sample_bands.h" #include #include #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) // 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& g, int id) { for (std::size_t i = 0; i < g.size(); ++i) { if (g[i].id == id) return static_cast(i); } return -1; } static int cell(DeckParam p) { return static_cast(p); } static void testDeckReadsPitchThenFilterThenAmpLeftToRight() { for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { const std::vector 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); // VELOCITY then the two instance-wide groups at the end. Velocity sits IMMEDIATELY // left of VOICE — MASTER is reserved for post-voice-mixer concerns, so the curves // must not drift into it. const int vel = indexOfGroup(g, kGroupVelocity); CHECK(amp < vel); CHECK(vel + 1 == indexOfGroup(g, kGroupVoice)); CHECK(indexOfGroup(g, kGroupVoice) < indexOfGroup(g, kGroupMaster)); } } // The three velocity curves live together in VELOCITY and nowhere else: no other group may // carry a curve cell, or the "one home" the group exists for is not one. static void testVelocityGroupOwnsTheThreeCurvesExclusively() { for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { const std::vector g = sampleDeckGroups(mode); const DeckGroupDesc& v = g[static_cast(indexOfGroup(g, kGroupVelocity))]; const std::vector expected = {cell(DeckParam::kAmpVelCurve), cell(DeckParam::kPitchVelCurve), cell(DeckParam::kFilterVelCurve)}; CHECK(v.cellIds == expected); CHECK(v.captionToggle.id == -1 && v.rowToggle.id == -1 && v.captionRadio.id == -1); for (const DeckGroupDesc& d : g) { if (d.id == kGroupVelocity) continue; for (int id : d.cellIds) CHECK(curveTargetFor(id) == CurveTarget::kNone); CHECK(curveTargetFor(d.captionToggle.id) == CurveTarget::kNone); CHECK(curveTargetFor(d.rowToggle.id) == CurveTarget::kNone); } } } // Each curve cell names its OWN destination, and an ordinary knob names none — the predicate // the shell uses to tell a popup opener from a dial. static void testCurveTargetNamesEachCellsOwnDestination() { CHECK(curveTargetFor(cell(DeckParam::kAmpVelCurve)) == CurveTarget::kAmp); CHECK(curveTargetFor(cell(DeckParam::kPitchVelCurve)) == CurveTarget::kPitch); CHECK(curveTargetFor(cell(DeckParam::kFilterVelCurve)) == CurveTarget::kFilter); CHECK(curveTargetFor(cell(DeckParam::kFilterCutoff)) == CurveTarget::kNone); CHECK(curveTargetFor(cell(DeckParam::kMasterGain)) == CurveTarget::kNone); CHECK(curveTargetFor(-1) == CurveTarget::kNone); // a blank reserved cell CHECK(curveTargetFor(9999) == CurveTarget::kNone); // out of the id space } // The cells hit-test inside their own group, from the centre of each cell — the deck grammar // treats them as knob cells, so the popup routing rides an ordinary Knob hit. static void testVelocityCellsHitTestWithinTheirGroup() { const std::vector g = sampleDeckGroups(PlayMode::Gate); const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth); const DeckGroupLayout& v = dl.groups[static_cast(indexOfGroup(g, kGroupVelocity))]; CHECK(v.cells.size() == 3); const CurveTarget want[] = {CurveTarget::kAmp, CurveTarget::kPitch, CurveTarget::kFilter}; for (std::size_t i = 0; i < v.cells.size(); ++i) { const DeckCellLayout& c = v.cells[i]; 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(curveTargetFor(hit.id) == want[i]); // Inside its own group box, and the cell the hit resolved is this one. CHECK(c.cell.x >= v.box.x && c.cell.right() <= v.box.right()); } } static void testFilterGroupCarriesItsToneControlsPlusModulation() { const std::vector& g = sampleDeckGroups(PlayMode::Gate); const DeckGroupDesc& f = g[static_cast(indexOfGroup(g, kGroupFilter))]; const std::vector 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(indexOfGroup(g, kGroupFilterEnv))]; const std::vector 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); } // Exactly the three envelope decks carry an overlay-select radio, each its own, and no other // group has one — the exclusivity the shell enforces is only meaningful if the id space is. static void testOnlyTheThreeEnvelopeDecksCarryARadio() { for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { const std::vector g = sampleDeckGroups(mode); int radios = 0; for (const DeckGroupDesc& d : g) { if (d.captionRadio.id < 0) continue; ++radios; const int want = d.id == kGroupAmpEnv ? cell(DeckParam::kAmpEnvSelect) : d.id == kGroupPitchEnv ? cell(DeckParam::kPitchEnvSelect) : d.id == kGroupFilterEnv ? cell(DeckParam::kFilterEnvSelect) : -1; CHECK(d.captionRadio.id == want); } CHECK(radios == 3); } } // The mode-driven shape switch, on BOTH the amp and the filter envelope: Gate shows the // AHDSR's five stages, Trigger the AHD's three (behind the play span on the amp deck), and // neither mode leaks the other's controls onto the deck. static void testGateAndTriggerFacesCarryTheirOwnShapes() { const std::vector gate = sampleDeckGroups(PlayMode::Gate); const std::vector trig = sampleDeckGroups(PlayMode::Trigger); const DeckGroupDesc& gAmp = gate[static_cast(indexOfGroup(gate, kGroupAmpEnv))]; const DeckGroupDesc& tAmp = trig[static_cast(indexOfGroup(trig, kGroupAmpEnv))]; const std::vector gateAmp = {cell(DeckParam::kAttack), cell(DeckParam::kHold), cell(DeckParam::kDecay), cell(DeckParam::kSustain), cell(DeckParam::kRelease)}; const std::vector trigAmp = {cell(DeckParam::kTrigLength), cell(DeckParam::kTrigAttack), cell(DeckParam::kTrigHold), cell(DeckParam::kTrigDecay), -1}; CHECK(gAmp.cellIds == gateAmp); CHECK(tAmp.cellIds == trigAmp); const DeckGroupDesc& gFe = gate[static_cast(indexOfGroup(gate, kGroupFilterEnv))]; const DeckGroupDesc& tFe = trig[static_cast(indexOfGroup(trig, kGroupFilterEnv))]; const std::vector trigFe = {cell(DeckParam::kFilterTrigAttack), cell(DeckParam::kFilterTrigHold), cell(DeckParam::kFilterTrigDecay), -1, -1}; CHECK(tFe.cellIds == trigFe); CHECK(gFe.cellIds != tFe.cellIds); // Same cell count either way, so the group's width — and its neighbours' placement — // survives a mode flip. CHECK(gFe.cellIds.size() == tFe.cellIds.size()); CHECK(deckGroupWidth(gFe) == deckGroupWidth(tFe)); } // Every SLOPED stage knob carries an inner curve dial; Hold, Sustain, and everything that is // not a stage carries none. This is the "which segments are sloped" rule, asserted rather than // read. static void testOnlySlopedStageKnobsCarryAnInnerCurveDial() { const DeckParam sloped[] = { DeckParam::kAttack, DeckParam::kDecay, DeckParam::kRelease, DeckParam::kTrigAttack, DeckParam::kTrigDecay, DeckParam::kPitchEnvAttack, DeckParam::kPitchEnvDecay, DeckParam::kFilterEnvAttack, DeckParam::kFilterEnvDecay, DeckParam::kFilterEnvRelease, DeckParam::kFilterTrigAttack, DeckParam::kFilterTrigDecay, }; for (DeckParam p : sloped) { const DeckParam c = curveParamFor(p); CHECK(c != DeckParam::kCount); // A curve control is itself flat — no inner dial on an inner dial. CHECK(curveParamFor(c) == DeckParam::kCount); } const DeckParam flat[] = { DeckParam::kHold, DeckParam::kSustain, DeckParam::kTrigHold, DeckParam::kPitchEnvHold, DeckParam::kFilterEnvHold, DeckParam::kFilterEnvSustain, DeckParam::kFilterTrigHold, DeckParam::kTrigLength, DeckParam::kPitchEnvDepth, DeckParam::kFilterCutoff, DeckParam::kMasterGain, DeckParam::kKeyTrack, }; for (DeckParam p : flat) CHECK(curveParamFor(p) == DeckParam::kCount); // Every sloped knob maps to a DISTINCT curve control — a copy-paste that pointed two // stages at one exponent would tie two dials together silently. for (std::size_t i = 0; i < sizeof(sloped) / sizeof(sloped[0]); ++i) { for (std::size_t j = i + 1; j < sizeof(sloped) / sizeof(sloped[0]); ++j) { CHECK(curveParamFor(sloped[i]) != curveParamFor(sloped[j])); } } } static void testAmpGroupWidthSurvivesAGateTriggerFlip() { // The reserved blanks are what stop a mode flip reflowing the groups beside AMP. const std::vector gate = sampleDeckGroups(PlayMode::Gate); const std::vector trig = sampleDeckGroups(PlayMode::Trigger); const DeckGroupDesc& a = gate[static_cast(indexOfGroup(gate, kGroupAmpEnv))]; const DeckGroupDesc& b = trig[static_cast(indexOfGroup(trig, kGroupAmpEnv))]; CHECK(deckGroupWidth(a) == deckGroupWidth(b)); CHECK(a.cellIds.size() == b.cellIds.size()); CHECK(b.cellIds[4] == -1); // the Trigger face's one reserved blank // 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 g = sampleDeckGroups(PlayMode::Gate); // At the floor (== default) 840 the deck takes three rows: PITCH + PITCH ENV + FILTER fill // the first (818 of the 824 available — six px of headroom, so one more FILTER cell would // wrap the group and reflow everything under it), FILTER ENV + AMP + VELOCITY the second, // VOICE + MASTER the third. Two rows cannot hold the eight groups in ANY order at this // width: 1666 px of group plus 72 px of gaps against a 1648 px two-row capacity. CHECK(deckRowCount(g, kAvailAtMinWidth) == 3); CHECK(deckHeight(g, kAvailAtMinWidth) == 3 * kDeckGroupH + 2 * 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 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 g = sampleDeckGroups(PlayMode::Gate); const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth); const DeckGroupLayout& f = dl.groups[static_cast(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(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(i) / 200.0; CHECK(std::fabs(deckNormFromBipolar(deckBipolarFromNorm(norm)) - norm) < 1e-12); const double value = -1.0 + static_cast(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 seven filter tone/modulation knobs, plus every stage time, stage level, // hold fraction and curve exponent on all three envelopes — in BOTH mode shapes. const DeckParam live[] = { 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(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::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve, DeckParam::kKeyTrack, DeckParam::kTrigLength, DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect, 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(DeckParam::kCount); ++i) { const DeckParam p = static_cast(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(DeckParam::kFilterCutoff))); CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kAttack))); // The Trigger amp is live now that the fade pair folded into the AHD — the one behavioural // consequence of that consolidation. CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kTrigAttack))); CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kTrigDecayCurve))); CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kTrigLength))); CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kMasterGain))); CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kAmpEnvSelect))); // 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)); CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -1)); CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kCount))); // Every stage value an envelope node can reach is live, in either mode shape. CHECK(liveCommitFor(LiveDragKind::kEnvNode, -1)); // Every other drag (markers, scrollbar, curve nodes) commits through a reload. CHECK(!liveCommitFor(LiveDragKind::kOther, static_cast(DeckParam::kFilterCutoff))); } // --- 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); } // 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, /*pitchEnv=*/false, /*filter=*/true)); CHECK(!overlayEnvInert(OverlayEnv::kPitch, true, true)); CHECK(overlayEnvInert(OverlayEnv::kFilter, true, /*filter=*/false)); CHECK(!overlayEnvInert(OverlayEnv::kFilter, true, true)); // Amp has no enable toggle, so it is never inert; kNone draws nothing to grab. CHECK(!overlayEnvInert(OverlayEnv::kAmp, false, false)); CHECK(!overlayEnvInert(OverlayEnv::kNone, false, false)); } // 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, /*pitchEnv=*/true, /*filter=*/false)); CHECK(!deckKnobInert(DeckParam::kFilterVelCurve, true, true)); CHECK(deckKnobInert(DeckParam::kFilterCutoff, true, false)); CHECK(!deckKnobInert(DeckParam::kFilterCutoff, true, true)); CHECK(deckKnobInert(DeckParam::kPitchEnvDepth, /*pitchEnv=*/false, true)); CHECK(!deckKnobInert(DeckParam::kPitchEnvDepth, 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. CHECK(!deckKnobInert(DeckParam::kAmpVelCurve, false, false)); CHECK(!deckKnobInert(DeckParam::kAttack, false, false)); } int main() { testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks(); testClickingTheActiveOverlayRadioClearsToNone(); testANonRadioIdLeavesTheOverlaySelectionAlone(); testOverlayIsInertExactlyWhenItsGroupToggleIsOff(); testDeckKnobIsInertExactlyWithItsGroupsEnableToggle(); testEveryDeckControlIsClassifiedLiveOrReloading(); testOnlyALiveControlsDragTakesTheLiveTier(); testDeckReadsPitchThenFilterThenAmpLeftToRight(); testVelocityGroupOwnsTheThreeCurvesExclusively(); testCurveTargetNamesEachCellsOwnDestination(); testVelocityCellsHitTestWithinTheirGroup(); testFilterGroupCarriesItsToneControlsPlusModulation(); testOnlyTheThreeEnvelopeDecksCarryARadio(); testGateAndTriggerFacesCarryTheirOwnShapes(); testOnlySlopedStageKnobsCarryAnInnerCurveDial(); testAmpGroupWidthSurvivesAGateTriggerFlip(); testWrappedDeckHeightAtTheEditorFloorWidth(); testDeckFitsInsideTheEnforcedMinimumWindow(); testHitTestResolvesTheNewFilterControls(); testBipolarKnobLawRoundTripsAndIsExactAtCentre(); if (g_fail == 0) std::printf("deck_groups: all tests passed\n"); return g_fail == 0 ? 0 : 1; }