489 lines
26 KiB
C++
489 lines
26 KiB
C++
// 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 <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)
|
|
|
|
// 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<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, kSamplePad, 40, kSampleAvail);
|
|
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: 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<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 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<DeckGroupDesc> 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<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) == 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<DeckGroupDesc> 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<DeckGroupId>(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<DeckGroupId>(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<DeckGroupDesc> 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<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.
|
|
|
|
static void testHitTestResolvesTheNewFilterControls() {
|
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
|
const DeckLayout dl = layoutDeck(g, kSamplePad, 40, kSampleAvail);
|
|
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);
|
|
|
|
// 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<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 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<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), 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;
|
|
}
|