// 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 wrapped deck height at the editor's floor width and its fit // inside the floor window, the hit-test reaching the new filter controls, and the bipolar knob // law's inverse pair. #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); // The two instance-wide groups stay at the end. CHECK(amp < indexOfGroup(g, kGroupVoice)); CHECK(indexOfGroup(g, kGroupVoice) < indexOfGroup(g, kGroupMaster)); } } static void testFilterGroupCarriesItsFiveToneControlsPlusModulation() { 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); } 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[3] == -1 && b.cellIds[4] == -1); // 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 two rows: PITCH + PITCH ENV + FILTER fill // the first, the remaining four fit the second. CHECK(deckRowCount(g, kAvailAtMinWidth) == 2); CHECK(deckHeight(g, kAvailAtMinWidth) == 2 * kDeckGroupH + 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); } int main() { testDeckReadsPitchThenFilterThenAmpLeftToRight(); testFilterGroupCarriesItsFiveToneControlsPlusModulation(); testAmpGroupWidthSurvivesAGateTriggerFlip(); testWrappedDeckHeightAtTheEditorFloorWidth(); testDeckFitsInsideTheEnforcedMinimumWindow(); testHitTestResolvesTheNewFilterControls(); testBipolarKnobLawRoundTripsAndIsExactAtCentre(); if (g_fail == 0) std::printf("deck_groups: all tests passed\n"); return g_fail == 0 ? 0 : 1; }