Files
reasampler/tests/test_deck_groups.cpp
T

847 lines
46 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, and the
// bipolar knob law's inverse pair. 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 "../src/core/instrument/ui/master_meter.h" // kMeterColumnW: MASTER's reserve IS this
#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 == 82);
// 82 still buys one more deck cell (60), which is the only purchase the ledger promises —
// the widen below spent 8 px of slack, not the layout's purchasing power.
CHECK(kEditorCeilingWidth - kEditorMinWidth >= kDeckCellW);
// The reflow's 112 px goes entirely to the waveform, so the height does not move.
CHECK(kEditorMinHeight == 680);
// 1190 + 8: the row block was widened 1020 -> 1028 to put the two rows' filter edges on
// one pixel, which is the only reason the floor moved off Γ-W1-T4's number.
CHECK(kEditorMinWidth == 1198);
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
// At the 1028 block the justification law makes the tie-line exact by arithmetic rather than
// by a special rule: row 1's slack is 48 over three gutters (16 each, no residue) and row 2's
// is 152 over two (76 each), which lands both filter edges on 640. Only two of the three
// properties §1.3 once claimed can hold at once — a smallest gutter of exactly kDeckGroupGap
// needs a 1016 block — and 12 is a floor, not a target, so 16 satisfies the real rule.
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, and three EQUAL gutters — 48 divides by 3
// with no residue, so no gutter carries a leftover pixel.
CHECK(box(kGroupPitch).x == kPad);
CHECK(box(kGroupFilter).x - box(kGroupPitch).right() == 16);
CHECK(box(kGroupVelocity).x - box(kGroupFilter).right() == 16);
CHECK(box(kGroupVoice).x - box(kGroupVelocity).right() == 16);
CHECK(box(kGroupVoice).right() == kPad + kDeckRowBlockW);
// Row 2: flush left, flush right, two gutters exactly equal.
CHECK(box(kGroupPitchEnv).x == kPad);
CHECK(box(kGroupFilterEnv).x - box(kGroupPitchEnv).right() == 76);
CHECK(box(kGroupAmpEnv).x - box(kGroupFilterEnv).right() == 76);
CHECK(box(kGroupAmpEnv).right() == kPad + kDeckRowBlockW);
// The tie-line, block-relative: both filter edges on ONE pixel, which is what the widen
// bought. Pinned as an identity too, so a group-width change cannot pass by moving both.
CHECK(box(kGroupFilterEnv).right() - kPad == 640);
CHECK(box(kGroupFilter).right() - kPad == 640);
CHECK(box(kGroupFilter).right() == box(kGroupFilterEnv).right());
// 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.
//
// Checked per ROW (tracking the last-seen box in each of the two categorical rows while
// walking dl.groups in deck order), not just deck-order neighbours: two same-row groups can
// sit apart in deck order with a different-row group between them, and a deck-order-only
// check would silently skip that gutter.
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* prevInRow[2] = {nullptr, nullptr};
for (const DeckGroupLayout& gl : dl.groups) {
const DeckRow row = deckRowFor(static_cast<DeckGroupId>(gl.id));
if (row == DeckRow::Spanning) continue;
const int r = row == DeckRow::Contour ? 1 : 0;
if (prevInRow[r]) {
CHECK(gl.box.x - prevInRow[r]->box.right() >= kDeckGroupGap);
}
prevInRow[r] = &gl;
}
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: the tie-line is exact AT the floor and separates above it,
// which is the accepted outcome rather than 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, and the 62 is the meter
// module's own kMeterColumnW rather than a copy of it. That link is the whole point: the
// column is banked to GROW (§1.2), and a reserve that did not track it would leave the
// interior underfilling or overrunning with every test still green.
CHECK(kDeckGroupPadX + kDeckCellW + kDeckColumnGap + kMeterColumnW + kDeckGroupPadX == 142);
CHECK(m.column.id == cell(DeckParam::kMasterMeter));
CHECK(m.column.box.width == kMeterColumnW);
// 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);
}
// MASTER's caption row and knob row measure exactly equal (130 == 130) today, so a column
// derived from either edge lands in the same place — that balance is what let a left-derived
// offset masquerade as right-anchored. Widen the caption reserve alone (as a wider caption or
// a limiter-toggle change would) and the column must still land flush against the group's own
// right padding, derived from innerRight rather than measured past the cell slots.
static void testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
DeckGroupDesc probe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
probe.captionWidth += 40; // unbalances it: the caption row now measures past the knob row
const std::vector<DeckGroupDesc> one = {probe};
const DeckLayout dl = layoutDeck(one, kPad, 0, kAvailAtMinWidth);
const DeckGroupLayout& m = dl.groups[0];
CHECK(m.box.width > 142); // the widen is real, not absorbed elsewhere
CHECK(m.column.box.right() == m.box.right() - kDeckGroupPadX);
}
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);
}
// 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
}
// The kEnvModeSegW ceilings recorded in deck_groups.cpp's own comment (PITCH ENV binds at 47,
// AMP at 55) pinned against the descriptors they derive from, the same way the Pitch/Rate
// caption ceiling above is: a change to either group's caption width or its enable toggle
// would otherwise invalidate the recorded numbers with nothing failing.
static void testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckGroupDesc& penv = g[static_cast<std::size_t>(indexOfGroup(g, kGroupPitchEnv))];
const DeckGroupDesc& amp = g[static_cast<std::size_t>(indexOfGroup(g, kGroupAmpEnv))];
CHECK(deckGroupWidth(penv) == 252);
CHECK(deckGroupWidth(amp) == 312);
DeckGroupDesc penvProbe = penv;
penvProbe.captionToggle2.segWidth = 47;
CHECK(deckGroupWidth(penvProbe) == 252); // at the ceiling, still knob-row-driven
penvProbe.captionToggle2.segWidth = 48;
CHECK(deckGroupWidth(penvProbe) > 252); // one past it, the caption row takes over
DeckGroupDesc ampProbe = amp;
ampProbe.captionToggle2.segWidth = 55;
CHECK(deckGroupWidth(ampProbe) == 312);
ampProbe.captionToggle2.segWidth = 56;
CHECK(deckGroupWidth(ampProbe) > 312);
}
// 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() {
testTheModeTogglesCostNoGroupWidth();
testDeckReadsPitchThenFilterThenAmpLeftToRight();
testVelocityGroupOwnsTheThreeCurvesExclusively();
testCurveTargetNamesEachCellsOwnDestination();
testVelocityCellsHitTestWithinTheirGroup();
testFilterGroupCarriesItsToneControlsPlusModulation();
testOnlyTheThreeEnvelopeDecksCarryASelectableRadio();
testGateAndTriggerFacesCarryTheirOwnShapes();
testOnlySlopedStageKnobsCarryAnInnerCurveDial();
testAmpGroupWidthSurvivesAGateTriggerFlip();
testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction();
testDeckFitsInsideTheEnforcedMinimumWindow();
testNoFaceLeavesSlackWhereItsDroppedControlsWere();
testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo();
testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp();
testEveryGroupWidthMatchesTheMeasuredLayout();
testGateSplineGateRoundTripsToTheSameLayout();
testTheEditorFloorIsDerivedFromTheDeckWidthBudget();
testEveryDeckGroupBelongsToExactlyOneRow();
testBothRowsAndTheSpanningDeckFitTheBudget();
testGutterArithmeticAndTheFilterTieLineAtTheFloor();
testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor();
testTheMasterDeckInteriorLandsOnBothRowBaselines();
testTheMasterColumnDoesNotDivideItsRunVertically();
testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow();
testHitTestResolvesTheNewFilterControls();
testBipolarKnobLawRoundTripsAndIsExactAtCentre();
if (g_fail == 0) std::printf("deck_groups: all tests passed\n");
return g_fail == 0 ? 0 : 1;
}