0627398bbb
Row membership is now the group's own property, not a wrap outcome. FILTER's Band|Notch moves to its caption slack, which is what makes the sound row fit. MASTER gains the limiter toggle, the output meter and the GR lamp.
999 lines
54 KiB
C++
999 lines
54 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 group widths, the editor
|
||
// floor derived from the deck's width budget and each group's categorical row,
|
||
// 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: 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 : {kAvailAtMinWidth, kAvailAtMinWidth + 200, 4000}) {
|
||
const DeckLayout dl = layoutDeck(g, kPad, 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() == kPad + avail); // right-anchored at every width
|
||
} else {
|
||
CHECK(gl.box.y == rowTops[row == DeckRow::Contour ? 1 : 0]);
|
||
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 the floor height 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);
|
||
const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h);
|
||
CHECK(deckRowCount(g) == 2); // either face
|
||
CHECK(b.decks.height == h);
|
||
// The reflow's 112 px land in the waveform: at two rows the deck band is 216 and the
|
||
// waveform 358, against 328/246 before. Pinned now that both are reached by
|
||
// construction rather than by a pack outcome.
|
||
CHECK(b.decks.height == 2 * kDeckGroupH + kDeckRowGap);
|
||
CHECK(b.waveform.height == 358);
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
// The floor is a DERIVED number, and this is the one place the derivation is written down —
|
||
// sample_bands stays independent of knob_deck, so neither header can hold it. This fixture is
|
||
// the only one that includes both.
|
||
static void testTheEditorFloorIsDerivedFromTheDeckWidthBudget() {
|
||
CHECK(kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW + 2 * kPad == kEditorMinWidth);
|
||
// The budget: what is left between the derived floor and the hard ceiling, and it is spent
|
||
// once. A cell costs 60 of it.
|
||
CHECK(kEditorCeilingWidth - kEditorMinWidth == 90);
|
||
// The reflow's 112 px goes entirely to the waveform, so the height does not move.
|
||
CHECK(kEditorMinHeight == 680);
|
||
// The floor did not move to make the reflow fit — the reflow was fitted to the floor. This
|
||
// wave spends the budget it was handed; it does not widen it.
|
||
CHECK(kEditorMinWidth == 1190);
|
||
CHECK(kEditorMinWidth <= kEditorCeilingWidth);
|
||
CHECK(kEditorMinHeight <= 720);
|
||
// And the row block really is what the two rows justify inside — derived from the floor
|
||
// and the spanning reserve, not restated.
|
||
CHECK(kEditorMinWidth - 2 * kPad - kDeckSpanningW - kDeckGroupGap == kDeckRowBlockW);
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
// Both rows now fit their block, in BOTH play modes. Row 1's fit is the one this track closes:
|
||
// it was 1030, +42 from PITCH/RATE's third cell and −92 from FILTER's Band|Notch caption move
|
||
// take it to 980. Row 2's 876 is mode-stable because FILTER ENV's and AMP's reserve slots hold
|
||
// them at 312 in Trigger too — asserted here rather than assumed.
|
||
static void testBothRowsAndTheSpanningDeckFitTheBudget() {
|
||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
||
int width[3] = {0, 0, 0};
|
||
int count[3] = {0, 0, 0};
|
||
for (const DeckGroupDesc& d : g) {
|
||
const int r = static_cast<int>(deckRowFor(static_cast<DeckGroupId>(d.id)));
|
||
width[r] += deckGroupWidth(d);
|
||
++count[r];
|
||
}
|
||
const int sound = static_cast<int>(DeckRow::Sound);
|
||
const int contour = static_cast<int>(DeckRow::Contour);
|
||
const int spanning = static_cast<int>(DeckRow::Spanning);
|
||
|
||
CHECK(count[sound] == 4);
|
||
CHECK(width[sound] == 980); // 192 + 432 + 192 + 164
|
||
CHECK(count[contour] == 3);
|
||
CHECK(width[contour] == 876); // 252 + 312 + 312
|
||
CHECK(count[spanning] == 1);
|
||
CHECK(width[spanning] == kDeckSpanningW); // 142 exactly — the reserve is now spent
|
||
|
||
for (int r : {sound, contour}) {
|
||
CHECK(width[r] <= kDeckRowBlockW);
|
||
// Slack enough that no gutter in the row falls under the minimum.
|
||
CHECK(kDeckRowBlockW - width[r] >= (count[r] - 1) * kDeckGroupGap);
|
||
}
|
||
}
|
||
}
|
||
|
||
// The gutters the justification law produces at the floor, and the alignment they buy. The
|
||
// SPEC (instrument-control-surface.md §1.2/§1.3) states row 1 as 12/14/14 with both filter
|
||
// edges at x = 636; equal division of 40 px over three gutters cannot produce that, so what is
|
||
// pinned here is what the LAW produces — 14/13/13, filter edge 638 — with row 2 exact at
|
||
// 72/72 and 636. The 2 px is flagged for review; a row block of 1028 (floor 1198, still under
|
||
// the 1280 ceiling) is the width at which the law puts both edges on 640.
|
||
static void testGutterArithmeticAndTheFilterTieLineAtTheFloor() {
|
||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
|
||
|
||
const auto box = [&](int id) {
|
||
return dl.groups[static_cast<std::size_t>(indexOfGroup(g, id))].box;
|
||
};
|
||
// Row 1: flush left, flush right on the block, gutters 14/13/13.
|
||
CHECK(box(kGroupPitch).x == kPad);
|
||
CHECK(box(kGroupFilter).x - box(kGroupPitch).right() == 14);
|
||
CHECK(box(kGroupVelocity).x - box(kGroupFilter).right() == 13);
|
||
CHECK(box(kGroupVoice).x - box(kGroupVelocity).right() == 13);
|
||
CHECK(box(kGroupVoice).right() == kPad + kDeckRowBlockW);
|
||
|
||
// Row 2: flush left, flush right, and its two gutters exactly equal — the property the
|
||
// 1020 block was chosen for, and the one it does deliver.
|
||
CHECK(box(kGroupPitchEnv).x == kPad);
|
||
CHECK(box(kGroupFilterEnv).x - box(kGroupPitchEnv).right() == 72);
|
||
CHECK(box(kGroupAmpEnv).x - box(kGroupFilterEnv).right() == 72);
|
||
CHECK(box(kGroupAmpEnv).right() == kPad + kDeckRowBlockW);
|
||
|
||
// The filter tie-line, block-relative. Row 2 lands on the specified 636; row 1 lands 2 px
|
||
// past it. See this test's header.
|
||
CHECK(box(kGroupFilterEnv).right() - kPad == 636);
|
||
CHECK(box(kGroupFilter).right() - kPad == 638);
|
||
|
||
// MASTER is right-anchored outside the block, one kDeckGroupGap clear of it.
|
||
CHECK(box(kGroupMaster).x - box(kGroupVoice).right() == kDeckGroupGap);
|
||
CHECK(box(kGroupMaster).right() == kPad + kAvailAtMinWidth);
|
||
}
|
||
|
||
// No gutter is ever narrower than kDeckGroupGap at or above the floor, and both rows stay
|
||
// flush at every width — the property the exact-at-the-floor numbers above are one point of.
|
||
// Above the floor the tie-line DRIFTS, which is accepted and deliberate (§1.3): row 1 divides
|
||
// its slack over three gutters and row 2 over two, so row 2's filter edge pulls right past
|
||
// row 1's and the gap widens monotonically. Encoded as EXPECTED, not as a failure.
|
||
static void testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor() {
|
||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
||
int lastDrift = 1 << 20; // sentinel above any real drift
|
||
for (int avail = kAvailAtMinWidth; avail <= kAvailAtMinWidth + 600; avail += 37) {
|
||
const DeckLayout dl = layoutDeck(g, kPad, 0, avail);
|
||
const DeckGroupLayout* prev = nullptr;
|
||
DeckRow prevRow = DeckRow::Spanning;
|
||
for (const DeckGroupLayout& gl : dl.groups) {
|
||
const DeckRow row = deckRowFor(static_cast<DeckGroupId>(gl.id));
|
||
if (row != DeckRow::Spanning && prev && row == prevRow) {
|
||
CHECK(gl.box.x - prev->box.right() >= kDeckGroupGap);
|
||
}
|
||
prev = ≷
|
||
prevRow = row;
|
||
}
|
||
const auto right = [&](int id) {
|
||
return dl.groups[static_cast<std::size_t>(indexOfGroup(g, id))].box.right();
|
||
};
|
||
// Flush right on the block at every width, both rows.
|
||
CHECK(right(kGroupVoice) == right(kGroupAmpEnv));
|
||
// Monotone in width rather than oscillating: row 2's two gutters absorb slack
|
||
// faster than row 1's three, so the gap only ever opens.
|
||
const int drift = right(kGroupFilter) - right(kGroupFilterEnv);
|
||
CHECK(drift <= lastDrift);
|
||
lastDrift = drift;
|
||
}
|
||
// It really does open up, and by far more than the 2 px it starts at — separation
|
||
// above the floor is the accepted outcome, not a near-miss to be pinned back.
|
||
CHECK(lastDrift < -50);
|
||
}
|
||
}
|
||
|
||
// MASTER's interior, exact to the pixel (§1.4). The two left slots sit on the two rows' own
|
||
// knob baselines — that is what "stitched to both rows" means — and the meter is ONE rect
|
||
// across both, never a readout per row.
|
||
static void testTheMasterDeckInteriorLandsOnBothRowBaselines() {
|
||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
|
||
const DeckGroupLayout& m =
|
||
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
|
||
|
||
CHECK(m.box.width == 142);
|
||
CHECK(m.box.height == 216);
|
||
// 6 + 60 + 8 + 62 + 6 — the decomposition, not just the total.
|
||
CHECK(kDeckGroupPadX + kDeckCellW + kDeckColumnGap + 62 + kDeckGroupPadX == 142);
|
||
|
||
// One cell drawn (gain) and one slot RESERVED below it: the reserve is height at a fixed
|
||
// position and draws nothing.
|
||
CHECK(m.cells.size() == 1);
|
||
CHECK(m.cells[0].id == cell(DeckParam::kMasterGain));
|
||
CHECK(m.cells[0].cell.y - m.box.y == 26);
|
||
const int reserveTop = m.cells[0].cell.y + kDeckGroupH + kDeckRowGap;
|
||
CHECK(reserveTop - m.box.y == 138);
|
||
|
||
// The two baselines are row 1's and row 2's own.
|
||
const DeckGroupLayout& filter =
|
||
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
|
||
const DeckGroupLayout& amp =
|
||
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupAmpEnv))];
|
||
CHECK(m.cells[0].cell.y == filter.cells[0].cell.y);
|
||
CHECK(reserveTop == amp.cells[0].cell.y);
|
||
|
||
// The meter: one rect spanning both baselines, 62 x 186.
|
||
CHECK(m.column.id == cell(DeckParam::kMasterMeter));
|
||
CHECK(m.column.box.width == 62);
|
||
CHECK(m.column.box.height == 186);
|
||
CHECK(m.column.box.y == m.cells[0].cell.y);
|
||
CHECK(m.column.box.bottom() - m.box.y == 212);
|
||
}
|
||
|
||
// The regression guard for the rule most likely to be "generalised" wrongly: MASTER's left
|
||
// column is FIXED slots at the two baselines, NOT knob_deck's horizontal run-division law
|
||
// applied vertically — which would stretch the one gain knob over the whole 186 px.
|
||
static void testTheMasterColumnDoesNotDivideItsRunVertically() {
|
||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
|
||
const DeckGroupLayout& m =
|
||
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
|
||
CHECK(m.cells[0].cell.height == kDeckCellH);
|
||
CHECK(m.cells[0].cell.width == kDeckCellW);
|
||
// Under the run-division law the lone present cell would take the whole two-slot run;
|
||
// here it takes exactly one slot and leaves the rest empty.
|
||
CHECK(m.cells[0].cell.height < m.column.box.height);
|
||
CHECK(m.cells[0].cell.bottom() < m.column.box.bottom());
|
||
CHECK(m.cells[0].knob.width == kDeckKnobSize && m.cells[0].knob.height == kDeckKnobSize);
|
||
// And dropping the reserve does not move the gain knob or the meter — the slot below it is
|
||
// reserved height, so nothing above it depends on whether it is there.
|
||
std::vector<DeckGroupDesc> noReserve = g;
|
||
for (DeckGroupDesc& d : noReserve) {
|
||
if (d.id == kGroupMaster) d.cellIds = {cell(DeckParam::kMasterGain)};
|
||
}
|
||
const DeckLayout dl2 = layoutDeck(noReserve, kPad, 0, kAvailAtMinWidth);
|
||
const DeckGroupLayout& m2 =
|
||
dl2.groups[static_cast<std::size_t>(indexOfGroup(noReserve, kGroupMaster))];
|
||
CHECK(m2.cells[0].cell == m.cells[0].cell);
|
||
CHECK(m2.column.box == m.column.box);
|
||
}
|
||
|
||
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);
|
||
|
||
// 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 void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() {
|
||
// The live set: the seven filter tone/modulation knobs, the baseline pitch offset, plus
|
||
// every stage time, stage level, hold fraction and curve exponent on all three envelopes —
|
||
// in BOTH mode shapes.
|
||
const DeckParam live[] = {
|
||
DeckParam::kPitch,
|
||
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(deckParamCommit(p) == LiveCommit::Live);
|
||
|
||
// The note-on-latched tier: published like a live control, read only at note-on. Asserted as
|
||
// its OWN state rather than as "not Reload" — the whole point of widening the predicate is
|
||
// that Rate must not fall back into either neighbour, and Γ-W4-T1 reads this classification
|
||
// to decide what it exposes to the host.
|
||
const DeckParam latched[] = {DeckParam::kRate};
|
||
for (DeckParam p : latched) CHECK(deckParamCommit(p) == LiveCommit::NoteOnLatched);
|
||
|
||
// 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, DeckParam::kLimiterEnable,
|
||
DeckParam::kMasterMeter, DeckParam::kMasterGr,
|
||
};
|
||
for (DeckParam p : reloads) CHECK(deckParamCommit(p) == LiveCommit::Reload);
|
||
|
||
// COVERAGE, not cardinality: every id appears in EXACTLY ONE of the three 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 : latched) 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() {
|
||
// deckParamCommit 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.
|
||
const auto knob = [](DeckParam p) {
|
||
return liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(p));
|
||
};
|
||
CHECK(knob(DeckParam::kFilterCutoff) == LiveCommit::Live);
|
||
CHECK(knob(DeckParam::kAttack) == LiveCommit::Live);
|
||
CHECK(knob(DeckParam::kPitch) == LiveCommit::Live);
|
||
// The Trigger amp is live now that the fade pair folded into the AHD — the one behavioural
|
||
// consequence of that consolidation.
|
||
CHECK(knob(DeckParam::kTrigAttack) == LiveCommit::Live);
|
||
CHECK(knob(DeckParam::kTrigDecayCurve) == LiveCommit::Live);
|
||
// Rate keeps its own tier through the drag site: it must not arrive as Live (which would let
|
||
// it move a sounding note) nor as Reload (which would re-decode the WAV under a swept knob).
|
||
CHECK(knob(DeckParam::kRate) == LiveCommit::NoteOnLatched);
|
||
CHECK(knob(DeckParam::kTrigLength) == LiveCommit::Reload);
|
||
CHECK(knob(DeckParam::kMasterGain) == LiveCommit::Reload);
|
||
CHECK(knob(DeckParam::kAmpEnvSelect) == LiveCommit::Reload);
|
||
// 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) == LiveCommit::Reload);
|
||
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, -1) == LiveCommit::Reload);
|
||
CHECK(knob(DeckParam::kCount) == LiveCommit::Reload);
|
||
// Every stage value an envelope node can reach is live, in either mode shape.
|
||
CHECK(liveCommitFor(LiveDragKind::kEnvNode, -1) == LiveCommit::Live);
|
||
// Every other drag (markers, scrollbar, curve nodes) commits through a reload.
|
||
CHECK(liveCommitFor(LiveDragKind::kOther, static_cast<int>(DeckParam::kFilterCutoff)) ==
|
||
LiveCommit::Reload);
|
||
}
|
||
|
||
// --- 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(deckParamCommit(DeckParam::kAmpEnvMode) == LiveCommit::Reload);
|
||
CHECK(deckParamCommit(DeckParam::kPitchEnvMode) == LiveCommit::Reload);
|
||
CHECK(deckParamCommit(DeckParam::kFilterEnvMode) == LiveCommit::Reload);
|
||
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) {
|
||
// 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.
|
||
|
||
// PITCH/RATE carries three cells and measures exactly 192 — the KNOB row (3 x kDeckCellW plus
|
||
// padding) is what it measures from, and the caption row must stay under that. The ceiling is
|
||
// asserted by construction rather than as a comment: at a caption reserve of 80 the group is
|
||
// still 192, and at 81 it is not, which is the whole content of "hard ceiling 80". Widening the
|
||
// group is not the remedy if the caption text ever outgrows it — narrowing the mode toggle is.
|
||
static void testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo() {
|
||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||
const DeckGroupDesc* pitch = nullptr;
|
||
for (const DeckGroupDesc& d : g) if (d.id == kGroupPitch) pitch = &d;
|
||
CHECK(pitch != nullptr);
|
||
if (!pitch) return;
|
||
CHECK(pitch->cellIds.size() == 3);
|
||
CHECK(pitch->cellIds[0] == static_cast<int>(DeckParam::kKeyTrack));
|
||
CHECK(pitch->cellIds[1] == static_cast<int>(DeckParam::kRate));
|
||
CHECK(pitch->cellIds[2] == static_cast<int>(DeckParam::kPitch));
|
||
CHECK(deckGroupWidth(*pitch) == 192);
|
||
CHECK(3 * kDeckCellW + 2 * kDeckGroupPadX == 192); // the knob row IS the measurement
|
||
|
||
DeckGroupDesc probe = *pitch;
|
||
probe.captionWidth = 80;
|
||
CHECK(deckGroupWidth(probe) == 192); // at the ceiling the caption row still fits under it
|
||
probe.captionWidth = 81;
|
||
CHECK(deckGroupWidth(probe) > 192); // one past it, the caption row takes over
|
||
}
|
||
|
||
// Every group's width, in BOTH play modes, against the measured layout table
|
||
// (instrument-control-surface.md §1.2). Mode-independence is the second half of the claim: the
|
||
// reserve slots hold the two mode-dependent groups at 312 either way, which is what makes the
|
||
// contour row's 876 a constant rather than a Gate-only fact.
|
||
static void testEveryGroupWidthMatchesTheMeasuredLayout() {
|
||
const struct { int id; int width; } want[] = {
|
||
{kGroupPitch, 192}, {kGroupPitchEnv, 252}, {kGroupFilter, 432},
|
||
{kGroupFilterEnv, 312}, {kGroupAmpEnv, 312}, {kGroupVelocity, 192},
|
||
{kGroupVoice, 164}, {kGroupMaster, 142},
|
||
};
|
||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
||
CHECK(g.size() == sizeof(want) / sizeof(want[0]));
|
||
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
|
||
for (const auto& w : want) {
|
||
const int i = indexOfGroup(g, w.id);
|
||
CHECK(i >= 0);
|
||
if (i < 0) continue;
|
||
CHECK(deckGroupWidth(g[static_cast<std::size_t>(i)]) == w.width);
|
||
const DeckGroupLayout& lay =
|
||
dl.groups[static_cast<std::size_t>(indexOfGroup(g, w.id))];
|
||
CHECK(lay.box.width == w.width);
|
||
}
|
||
// Gate carries no reserves, so its cells are the deck's base size; Trigger's two
|
||
// reduced faces divide the same reserved run between fewer cells and get wider ones.
|
||
for (const DeckGroupLayout& lay : dl.groups) {
|
||
for (const DeckCellLayout& c : lay.cells) {
|
||
CHECK(c.cell.width >= kDeckCellW);
|
||
if (mode == PlayMode::Gate) 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) ||
|
||
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), kPad, 0, kAvailAtMinWidth);
|
||
|
||
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), 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();
|
||
testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers();
|
||
testOnlyALiveControlsDragTakesTheLiveTier();
|
||
testDeckReadsPitchThenFilterThenAmpLeftToRight();
|
||
testVelocityGroupOwnsTheThreeCurvesExclusively();
|
||
testCurveTargetNamesEachCellsOwnDestination();
|
||
testVelocityCellsHitTestWithinTheirGroup();
|
||
testFilterGroupCarriesItsToneControlsPlusModulation();
|
||
testOnlyTheThreeEnvelopeDecksCarryASelectableRadio();
|
||
testGateAndTriggerFacesCarryTheirOwnShapes();
|
||
testOnlySlopedStageKnobsCarryAnInnerCurveDial();
|
||
testAmpGroupWidthSurvivesAGateTriggerFlip();
|
||
testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction();
|
||
testDeckFitsInsideTheEnforcedMinimumWindow();
|
||
testNoFaceLeavesSlackWhereItsDroppedControlsWere();
|
||
testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo();
|
||
testEveryGroupWidthMatchesTheMeasuredLayout();
|
||
testGateSplineGateRoundTripsToTheSameLayout();
|
||
testTheEditorFloorIsDerivedFromTheDeckWidthBudget();
|
||
testEveryDeckGroupBelongsToExactlyOneRow();
|
||
testBothRowsAndTheSpanningDeckFitTheBudget();
|
||
testGutterArithmeticAndTheFilterTieLineAtTheFloor();
|
||
testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor();
|
||
testTheMasterDeckInteriorLandsOnBothRowBaselines();
|
||
testTheMasterColumnDoesNotDivideItsRunVertically();
|
||
testHitTestResolvesTheNewFilterControls();
|
||
testBipolarKnobLawRoundTripsAndIsExactAtCentre();
|
||
if (g_fail == 0) std::printf("deck_groups: all tests passed\n");
|
||
return g_fail == 0 ? 0 : 1;
|
||
}
|