// Standalone tests for reasampler::instrument::ui::deck_groups — no VST3, no REAPER, no // framework. Pins WHICH descriptors the Sample face carries and how they resolve to a layout: // 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, row membership, that no face leaves slack where its dropped controls were, // that a Gate/Spline/Gate round trip restores the layout exactly, the hit-test reaching the new // filter controls, and the bipolar knob law's inverse pair. The width-BUDGET fixtures (the // editor floor's derivation, the row/gutter arithmetic at the floor, MASTER's interior) live in // test_deck_groups_measured.cpp, which needs sample_bands/master_meter and this file does not. // The commit-tier routing and the overlay-selection state machine live in // test_deck_groups_state.cpp — they touch no layout at all. #include "../src/core/instrument/ui/deck_groups.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) // A pad and an available width for exercising layoutDeck, kept independent of sample_bands.h — // this file pins what the deck IS, not the window-floor budget. kSampleAvail equals the real // floor's available width because it is derived the same way (block + gap + spanning deck); // that identity, and the window-fact constants (kPad, kEditorMinWidth) it derives from, are // test_deck_groups_measured.cpp's to own. static constexpr int kSamplePad = 8; static constexpr int kSampleAvail = kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW; static constexpr int kSampleAvailWide = kSampleAvail + 200; // comfortably above the block 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 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 g = sampleDeckGroups(PlayMode::Gate); const DeckLayout dl = layoutDeck(g, kSamplePad, 40, kSampleAvail); 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: BOTH toggles now // ride the caption row, which is what takes the group from 524 to 432. CHECK(f.captionToggle.id == cell(DeckParam::kFilterEnable)); CHECK(f.captionToggle2.id == cell(DeckParam::kFilterLaw)); CHECK(f.rowToggle.id == -1); 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 a SELECTABLE overlay radio, each its own, and no // other group has one — the exclusivity the shell enforces is only meaningful if the id space // is. MASTER occupies the same corner slot with a PASSIVE lamp, which is a different thing: // it must never be counted as, or reachable as, a selector. static void testOnlyTheThreeEnvelopeDecksCarryASelectableRadio() { 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; if (d.captionRadio.passive) { CHECK(d.id == kGroupMaster); CHECK(d.captionRadio.id == cell(DeckParam::kMasterGr)); // A passive slot names no overlay, so no click on it could select one even if // the hit-test ever handed it through. CHECK(overlayEnvForRadio(d.captionRadio.id) == OverlayEnv::kNone); 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) == deckHeight(trig)); } // TWO rows plus the spanning deck, BY CONSTRUCTION: the row count is read off the group // inventory's own row assignment, not observed as a pack outcome, so it holds at every width. static void testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction() { for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { const std::vector g = sampleDeckGroups(mode); CHECK(deckRowCount(g) == 2); CHECK(deckHeight(g) == 2 * kDeckGroupH + kDeckRowGap); CHECK(deckHeight(g) == 216); for (int avail : {kSampleAvail, kSampleAvail + 200, 4000}) { const DeckLayout dl = layoutDeck(g, kSamplePad, 0, avail); CHECK(dl.rowCount == 2); CHECK(dl.height == 216); CHECK(dl.groups.size() == g.size()); int rowTops[2] = {0, kDeckGroupH + kDeckRowGap}; for (const DeckGroupLayout& gl : dl.groups) { const DeckRow row = deckRowFor(static_cast(gl.id)); if (row == DeckRow::Spanning) { CHECK(gl.box.y == 0); CHECK(gl.box.height == kDeckSpanningH); CHECK(gl.box.right() == kSamplePad + avail); // right-anchored at every width } else { CHECK(gl.box.y == rowTops[row == DeckRow::Contour ? 1 : 0]); CHECK(gl.box.height == kDeckGroupH); } } } } } static void testEveryDeckGroupBelongsToExactlyOneRow() { CHECK(deckRowFor(kGroupPitch) == DeckRow::Sound); CHECK(deckRowFor(kGroupFilter) == DeckRow::Sound); CHECK(deckRowFor(kGroupVelocity) == DeckRow::Sound); CHECK(deckRowFor(kGroupVoice) == DeckRow::Sound); CHECK(deckRowFor(kGroupPitchEnv) == DeckRow::Contour); CHECK(deckRowFor(kGroupFilterEnv) == DeckRow::Contour); CHECK(deckRowFor(kGroupAmpEnv) == DeckRow::Contour); CHECK(deckRowFor(kGroupMaster) == DeckRow::Spanning); // Totality against the descriptor list the deck actually carries, not just against the // enum: a group that shipped without a row would land here as a miscount. for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { int sound = 0, contour = 0, spanning = 0; for (const DeckGroupDesc& d : sampleDeckGroups(mode)) { switch (deckRowFor(static_cast(d.id))) { case DeckRow::Sound: ++sound; break; case DeckRow::Contour: ++contour; break; case DeckRow::Spanning: ++spanning; break; } } CHECK(sound == 4 && contour == 3 && spanning == 1); } } // 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. Checked at both a tight and a genuinely wider width. static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() { for (int avail : {kSampleAvail, kSampleAvailWide}) { for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { const std::vector g = sampleDeckGroups(mode); const DeckLayout dl = layoutDeck(g, kSamplePad, 0, avail); CHECK(dl.groups.size() == g.size()); for (std::size_t i = 0; i < dl.groups.size(); ++i) { // The spanning deck's slots STACK — the run-division law this pins is the // horizontal one, and its vertical guard is its own test. if (g[i].row == DeckRow::Spanning) continue; const DeckGroupLayout& lay = dl.groups[i]; const int reserved = static_cast(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(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. static void testHitTestResolvesTheNewFilterControls() { const std::vector g = sampleDeckGroups(PlayMode::Gate); const DeckLayout dl = layoutDeck(g, kSamplePad, 40, kSampleAvail); 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); // The morph law answers from its NEW home in the caption row, and as a CaptionToggle — // the shell's toggle branch handles both kinds, so the move must not change the id or the // segment either. const DeckHit band = hitTestDeck(dl, f.captionToggle2.seg0.x + 2, f.captionToggle2.seg0.y + 2); CHECK(band.kind == DeckHitKind::CaptionToggle); CHECK(band.id == cell(DeckParam::kFilterLaw) && band.segment == 0); const DeckHit notch = hitTestDeck(dl, f.captionToggle2.seg1.x + 2, f.captionToggle2.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 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) || x.captionRadio.passive != y.captionRadio.passive) return false; if (x.column.id != y.column.id || !(x.column.box == y.column.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 the shared enforceGateUnavailableWhileDrawn helper, so the deck cannot agree with a // forcing rule the real callers do not use. static void testGateSplineGateRoundTripsToTheSameLayout() { PlayParams p; // Gate, all three envelopes staged const DeckLayout before = layoutDeck(sampleDeckGroups(p.playMode), kSamplePad, 0, kSampleAvail); p.ampSpline.mode = EnvMode::Spline; enforceGateUnavailableWhileDrawn(p); // the shared helper both real callers route through CHECK(p.playMode == PlayMode::Trigger); const DeckLayout drawn = layoutDeck(sampleDeckGroups(p.playMode), kSamplePad, 0, kSampleAvail); // The excursion is real: the amp face's cells are strictly wider than Gate's. const DeckGroupLayout& gateAmp = before.groups[static_cast(indexOfGroup(sampleDeckGroups(PlayMode::Gate), kGroupAmpEnv))]; const DeckGroupLayout& trigAmp = drawn.groups[static_cast(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), kSamplePad, 0, kSampleAvail); CHECK(sameLayout(before, after)); } int main() { testDeckReadsPitchThenFilterThenAmpLeftToRight(); testVelocityGroupOwnsTheThreeCurvesExclusively(); testCurveTargetNamesEachCellsOwnDestination(); testVelocityCellsHitTestWithinTheirGroup(); testFilterGroupCarriesItsToneControlsPlusModulation(); testOnlyTheThreeEnvelopeDecksCarryASelectableRadio(); testGateAndTriggerFacesCarryTheirOwnShapes(); testOnlySlopedStageKnobsCarryAnInnerCurveDial(); testAmpGroupWidthSurvivesAGateTriggerFlip(); testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction(); testEveryDeckGroupBelongsToExactlyOneRow(); testNoFaceLeavesSlackWhereItsDroppedControlsWere(); testHitTestResolvesTheNewFilterControls(); testBipolarKnobLawRoundTripsAndIsExactAtCentre(); testGateSplineGateRoundTripsToTheSameLayout(); if (g_fail == 0) std::printf("deck_groups: all tests passed\n"); return g_fail == 0 ? 0 : 1; }