Files
reasampler/tests/test_deck_groups.cpp
T
daniel 757e1585d6 fix: close round-3 review findings — smallest-target-first, residue test fix, extraction
Waveform overlay now resolves node/tab/marker click collisions by target area instead of check order; residue test now uses a distinguishing fixture; Gate-unavailable-while-drawn logic extracted to one pure helper shared by resolvePlay and applyControl.
2026-07-31 23:44:05 -04:00

679 lines
37 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 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 pinned Gate widths and row assignment,
// that no face leaves slack where its dropped controls were and that a Gate/Spline/Gate round
// trip restores the layout exactly, 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 <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);
// 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<DeckGroupDesc> g = sampleDeckGroups(mode);
const DeckGroupDesc& v =
g[static_cast<std::size_t>(indexOfGroup(g, kGroupVelocity))];
const std::vector<int> 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 width reserve, not a control
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<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth);
const DeckGroupLayout& v =
dl.groups[static_cast<std::size_t>(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<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);
}
// 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<DeckGroupDesc> 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<DeckGroupDesc> gate = sampleDeckGroups(PlayMode::Gate);
const std::vector<DeckGroupDesc> trig = sampleDeckGroups(PlayMode::Trigger);
const DeckGroupDesc& gAmp = gate[static_cast<std::size_t>(indexOfGroup(gate, kGroupAmpEnv))];
const DeckGroupDesc& tAmp = trig[static_cast<std::size_t>(indexOfGroup(trig, kGroupAmpEnv))];
const std::vector<int> gateAmp = {cell(DeckParam::kAttack), cell(DeckParam::kHold),
cell(DeckParam::kDecay), cell(DeckParam::kSustain),
cell(DeckParam::kRelease)};
const std::vector<int> 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<std::size_t>(indexOfGroup(gate, kGroupFilterEnv))];
const DeckGroupDesc& tFe = trig[static_cast<std::size_t>(indexOfGroup(trig, kGroupFilterEnv))];
const std::vector<int> 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<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[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<DeckGroupDesc> 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<DeckGroupDesc> g = sampleDeckGroups(mode);
const int h = deckHeight(g, kAvailAtMinWidth);
const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h);
CHECK(deckRowCount(g, kAvailAtMinWidth) == 3); // either face, three rows at the floor
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 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::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode,
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)));
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(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<int>(DeckParam::kTrigAttack)));
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kTrigDecayCurve)));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kTrigLength)));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kMasterGain)));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(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<int>(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<int>(DeckParam::kFilterCutoff)));
}
// --- The overlay selection state machine ---------------------------------------
static int radio(DeckParam p) { return static_cast<int>(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(!isLiveDeckParam(DeckParam::kAmpEnvMode));
CHECK(!isLiveDeckParam(DeckParam::kPitchEnvMode));
CHECK(!isLiveDeckParam(DeckParam::kFilterEnvMode));
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);
}
// The three mode toggles ride each env group's caption slack, so the deck's wrapped geometry
// is unchanged by them: raising their segment width past the caption headroom would reflow the
// first row and push the deck to a fourth one (see testDeckFitsInsideTheEnforcedMinimumWindow).
static void testTheModeTogglesCostNoGroupWidth() {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
for (const DeckGroupDesc& g : sampleDeckGroups(mode)) {
if (g.captionToggle2.id < 0) continue;
DeckGroupDesc without = g;
without.captionToggle2 = DeckToggleDesc{};
CHECK(deckGroupWidth(g) == deckGroupWidth(without));
}
}
}
// A typical larger window, to check the same properties once the deck has re-wrapped.
static constexpr int kAvailAtLargerWidth = 1100 - 2 * kPad;
// The gap fix as a property of the shipped descriptors, not a picture: whichever face a
// mode-dependent group shows, its knob row still spans the group's whole reserved run. The
// Trigger faces drop Sustain and Release and get wider cells for it — never a hole where the
// dropped control was. What the run does not cover is the indivisible residue alone, strictly
// under one pixel per cell.
static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() {
for (int avail : {kAvailAtMinWidth, kAvailAtLargerWidth}) {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
const DeckLayout dl = layoutDeck(g, kPad, 0, avail);
CHECK(dl.groups.size() == g.size());
for (std::size_t i = 0; i < dl.groups.size(); ++i) {
const DeckGroupLayout& lay = dl.groups[i];
const int reserved = static_cast<int>(g[i].cellIds.size()) * kDeckCellW;
const std::size_t present = lay.cells.size();
CHECK(present > 0);
for (std::size_t k = 0; k < present; ++k) {
const DeckCellLayout& c = lay.cells[k];
CHECK(c.id >= 0); // a reserve yields width, never a dead rect
CHECK(c.cell.width == lay.cells[0].cell.width);
if (k > 0) CHECK(c.cell.x == lay.cells[k - 1].cell.right());
}
const int covered = lay.cells.back().cell.right() - lay.cells.front().cell.x;
CHECK(reserved - covered < static_cast<int>(present));
CHECK(lay.cells.front().cell.x >= lay.box.x + kDeckGroupPadX);
CHECK(lay.cells.back().cell.right() <= lay.box.right() - kDeckGroupPadX);
}
}
}
}
// The "residue lands in symmetric end margins" rule is knob_deck's own (layoutGroup), pinned
// once by its synthetic residue>=2 fixture in test_knob_deck.cpp rather than restated here.
// Gate is the common face and it already packs correctly: pin its group widths and row
// assignment at the floor so a later edit anywhere in the deck cannot reflow it silently.
// (Measured from the shipped descriptors, not copied out of a failing run.)
static void testGateModeWidthsAndRowAssignmentAreUnchanged() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const struct { int id; int width; int row; } want[] = {
{kGroupPitch, 150, 0}, {kGroupPitchEnv, 204, 0}, {kGroupFilter, 440, 0},
{kGroupFilterEnv, 252, 1}, {kGroupAmpEnv, 252, 1}, {kGroupVelocity, 156, 1},
{kGroupVoice, 152, 2}, {kGroupMaster, 60, 2},
};
CHECK(g.size() == sizeof(want) / sizeof(want[0]));
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
for (std::size_t i = 0; i < dl.groups.size(); ++i) {
CHECK(dl.groups[i].id == want[i].id);
CHECK(deckGroupWidth(g[i]) == want[i].width);
CHECK(dl.groups[i].box.width == want[i].width);
CHECK(dl.groups[i].box.y == want[i].row * (kDeckGroupH + kDeckRowGap));
// Gate carries no reserves, so its cells are the deck's base size.
for (const DeckCellLayout& c : dl.groups[i].cells) CHECK(c.cell.width == kDeckCellW);
}
}
static bool sameToggle(const DeckToggleLayout& a, const DeckToggleLayout& b) {
return a.id == b.id && a.seg0 == b.seg0 && a.seg1 == b.seg1;
}
static bool sameLayout(const DeckLayout& a, const DeckLayout& b) {
if (a.rowCount != b.rowCount || a.height != b.height ||
a.groups.size() != b.groups.size()) return false;
for (std::size_t i = 0; i < a.groups.size(); ++i) {
const DeckGroupLayout& x = a.groups[i];
const DeckGroupLayout& y = b.groups[i];
if (x.id != y.id || !(x.box == y.box) || !(x.caption == y.caption)) return false;
if (x.captionRadio.id != y.captionRadio.id || !(x.captionRadio.box == y.captionRadio.box))
return false;
if (!sameToggle(x.captionToggle, y.captionToggle) ||
!sameToggle(x.captionToggle2, y.captionToggle2) ||
!sameToggle(x.rowToggle, y.rowToggle)) return false;
if (x.cells.size() != y.cells.size()) return false;
for (std::size_t k = 0; k < x.cells.size(); ++k) {
const DeckCellLayout& c = x.cells[k];
const DeckCellLayout& d = y.cells[k];
if (c.id != d.id || !(c.cell == d.cell) || !(c.knob == d.knob) ||
!(c.inner == d.inner) || !(c.label == d.label)) return false;
}
}
return true;
}
// A Spline excursion is fully reversible at the layout level: the mode forcing swaps the amp
// and filter faces onto their wider cells and back, leaving no residue in the geometry. Driven
// through splineActive and the editor's own forcing rule, so the deck cannot agree with a
// forcing rule the shell does not use.
static void testGateSplineGateRoundTripsToTheSameLayout() {
PlayParams p; // Gate, all three envelopes staged
const DeckLayout before = layoutDeck(sampleDeckGroups(p.playMode), kPad, 0, kAvailAtMinWidth);
p.ampSpline.mode = EnvMode::Spline;
if (splineActive(p)) p.playMode = PlayMode::Trigger; // editor_controls' forcing, verbatim
CHECK(p.playMode == PlayMode::Trigger);
const DeckLayout drawn = layoutDeck(sampleDeckGroups(p.playMode), kPad, 0, kAvailAtMinWidth);
// The excursion is real: the amp face's cells are strictly wider than Gate's.
const DeckGroupLayout& gateAmp =
before.groups[static_cast<std::size_t>(indexOfGroup(sampleDeckGroups(PlayMode::Gate),
kGroupAmpEnv))];
const DeckGroupLayout& trigAmp =
drawn.groups[static_cast<std::size_t>(indexOfGroup(sampleDeckGroups(PlayMode::Trigger),
kGroupAmpEnv))];
CHECK(trigAmp.cells.size() < gateAmp.cells.size());
CHECK(trigAmp.cells[0].cell.width > gateAmp.cells[0].cell.width);
CHECK(!sameLayout(before, drawn));
p.ampSpline.mode = EnvMode::Staged;
CHECK(!splineActive(p));
p.playMode = PlayMode::Gate; // Gate is selectable again once nothing is drawn
const DeckLayout after = layoutDeck(sampleDeckGroups(p.playMode), kPad, 0, kAvailAtMinWidth);
CHECK(sameLayout(before, after));
}
int main() {
testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks();
testClickingTheActiveOverlayRadioClearsToNone();
testANonRadioIdLeavesTheOverlaySelectionAlone();
testOverlayIsInertExactlyWhenItsGroupToggleIsOff();
testDeckKnobIsInertExactlyWithItsGroupsEnableToggle();
testAModeToggleIsNeitherLiveNorAnOverlayRadio();
testTheModeTogglesCostNoGroupWidth();
testEveryDeckControlIsClassifiedLiveOrReloading();
testOnlyALiveControlsDragTakesTheLiveTier();
testDeckReadsPitchThenFilterThenAmpLeftToRight();
testVelocityGroupOwnsTheThreeCurvesExclusively();
testCurveTargetNamesEachCellsOwnDestination();
testVelocityCellsHitTestWithinTheirGroup();
testFilterGroupCarriesItsToneControlsPlusModulation();
testOnlyTheThreeEnvelopeDecksCarryARadio();
testGateAndTriggerFacesCarryTheirOwnShapes();
testOnlySlopedStageKnobsCarryAnInnerCurveDial();
testAmpGroupWidthSurvivesAGateTriggerFlip();
testWrappedDeckHeightAtTheEditorFloorWidth();
testDeckFitsInsideTheEnforcedMinimumWindow();
testNoFaceLeavesSlackWhereItsDroppedControlsWere();
testGateModeWidthsAndRowAssignmentAreUnchanged();
testGateSplineGateRoundTripsToTheSameLayout();
testHitTestResolvesTheNewFilterControls();
testBipolarKnobLawRoundTripsAndIsExactAtCentre();
if (g_fail == 0) std::printf("deck_groups: all tests passed\n");
return g_fail == 0 ? 0 : 1;
}