Merge Γ-W3-T1: two categorical deck rows and a double-height MASTER bus deck, an exact filter tie-line at a 1028 row block, and the instrument reload decoupled from VST3 activation

This commit is contained in:
2026-08-02 13:19:58 -04:00
45 changed files with 2771 additions and 950 deletions
+114 -432
View File
@@ -1,18 +1,16 @@
// 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).
// framework. Pins WHICH descriptors the Sample face carries and how they resolve to a layout:
// the signal-flow group order (pitch -> filter -> amp), the Filter group's contents, the
// VELOCITY group's exclusive ownership of the three curve cells and its placement immediately
// left of VOICE, row membership, that no face leaves slack where its dropped controls were,
// that a Gate/Spline/Gate round trip restores the layout exactly, the hit-test reaching the new
// filter controls, and the bipolar knob law's inverse pair. The width-BUDGET fixtures (the
// editor floor's derivation, the row/gutter arithmetic at the floor, MASTER's interior) live in
// test_deck_groups_measured.cpp, which needs sample_bands/master_meter and this file does not.
// The commit-tier routing and the overlay-selection state machine live in
// test_deck_groups_state.cpp — they touch no layout at all.
#include "../src/core/instrument/ui/deck_groups.h"
#include "../src/core/instrument/ui/sample_bands.h"
#include <cmath>
#include <cstdio>
@@ -25,9 +23,14 @@ 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;
// A pad and an available width for exercising layoutDeck, kept independent of sample_bands.h —
// this file pins what the deck IS, not the window-floor budget. kSampleAvail equals the real
// floor's available width because it is derived the same way (block + gap + spanning deck);
// that identity, and the window-fact constants (kPad, kEditorMinWidth) it derives from, are
// test_deck_groups_measured.cpp's to own.
static constexpr int kSamplePad = 8;
static constexpr int kSampleAvail = kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW;
static constexpr int kSampleAvailWide = kSampleAvail + 200; // comfortably above the block
static int indexOfGroup(const std::vector<DeckGroupDesc>& g, int id) {
for (std::size_t i = 0; i < g.size(); ++i) {
@@ -99,7 +102,7 @@ static void testCurveTargetNamesEachCellsOwnDestination() {
// 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 DeckLayout dl = layoutDeck(g, kSamplePad, 40, kSampleAvail);
const DeckGroupLayout& v =
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupVelocity))];
CHECK(v.cells.size() == 3);
@@ -125,10 +128,11 @@ static void testFilterGroupCarriesItsToneControlsPlusModulation() {
cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterVel),
cell(DeckParam::kFilterKeyTrack)};
CHECK(f.cellIds == expected);
// Off by default is a state question, but reachability is a layout one: the enable
// toggle is in the caption row and the morph law in the knob row.
// 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.rowToggle.id == cell(DeckParam::kFilterLaw));
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 = {
@@ -141,14 +145,24 @@ static void testFilterGroupCarriesItsToneControlsPlusModulation() {
CHECK(fe.rowToggle.id == -1);
}
// Exactly the three envelope decks carry an overlay-select radio, each its own, and no other
// group has one — the exclusivity the shell enforces is only meaningful if the id space is.
static void testOnlyTheThreeEnvelopeDecksCarryARadio() {
// 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)
@@ -234,65 +248,37 @@ static void testAmpGroupWidthSurvivesAGateTriggerFlip() {
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, kAvailAtMinWidth) == deckHeight(trig, kAvailAtMinWidth));
CHECK(deckHeight(gate) == deckHeight(trig));
}
static void testWrappedDeckHeightAtTheEditorFloorWidth() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
// An UPPER BOUND, not an equality. The greedy whole-group wrap is still what decides row
// membership until the reflow replaces it with the categorical partition, and at this width
// it happens to pack two ragged rows with the wrong composition. Bounding it is a real
// regression canary — a third row would cost the waveform 112 px again — without turning a
// wrap outcome into a claim.
const int rows = deckRowCount(g, kAvailAtMinWidth);
CHECK(rows <= 2);
CHECK(deckHeight(g, kAvailAtMinWidth) == rows * kDeckGroupH + (rows - 1) * kDeckRowGap);
// Whole groups only, never split: every group's box lies inside the available width or is
// the first of its row.
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
CHECK(dl.groups.size() == g.size());
for (const DeckGroupLayout& gl : dl.groups) {
CHECK(gl.box.x >= kPad);
CHECK(gl.box.height == kDeckGroupH);
}
}
// The guard the raised floor exists to provide: at the smallest window the host can produce,
// the deck band still lands inside the client area AND the waveform still gets its two-lane
// floor. Growing the deck past what 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() {
// 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);
const int h = deckHeight(g, kAvailAtMinWidth);
const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h);
CHECK(deckRowCount(g, kAvailAtMinWidth) <= 2); // either face; see the bound above
CHECK(b.decks.height == h);
// The raised floor hands the waveform the reflow's 112 px two waves early: at two rows
// the deck band is 216 and the waveform 358, against 328/246 before. Bounded rather
// than pinned for the same reason the row count is.
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);
}
}
CHECK(deckRowCount(g) == 2);
CHECK(deckHeight(g) == 2 * kDeckGroupH + kDeckRowGap);
CHECK(deckHeight(g) == 216);
// 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);
for (int avail : {kSampleAvail, kSampleAvail + 200, 4000}) {
const DeckLayout dl = layoutDeck(g, kSamplePad, 0, avail);
CHECK(dl.rowCount == 2);
CHECK(dl.height == 216);
CHECK(dl.groups.size() == g.size());
int rowTops[2] = {0, kDeckGroupH + kDeckRowGap};
for (const DeckGroupLayout& gl : dl.groups) {
const DeckRow row = deckRowFor(static_cast<DeckGroupId>(gl.id));
if (row == DeckRow::Spanning) {
CHECK(gl.box.y == 0);
CHECK(gl.box.height == kDeckSpanningH);
CHECK(gl.box.right() == kSamplePad + avail); // right-anchored at every width
} else {
CHECK(gl.box.y == rowTops[row == DeckRow::Contour ? 1 : 0]);
CHECK(gl.box.height == kDeckGroupH);
}
}
}
}
}
static void testEveryDeckGroupBelongsToExactlyOneRow() {
@@ -320,41 +306,46 @@ static void testEveryDeckGroupBelongsToExactlyOneRow() {
}
}
// What the budget can already be measured against. The contour row fits today and MASTER has
// not touched its reserve; the SOUND row does not fit yet and must not be forced to — it is
// 1030 against the 1020 block, and the 50 px deficit is exactly what two later descriptor
// changes buy: PITCH becoming PITCH/RATE (+42) and FILTER's Band|Notch moving from the knob
// row to the caption corner (92), netting 980. The fit is asserted when they land, not here.
static void testTheContourRowAndTheSpanningDeckFitTheBudget() {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
int contourWidth = 0, contourGroups = 0, spanningWidth = 0;
for (const DeckGroupDesc& d : g) {
const DeckRow row = deckRowFor(static_cast<DeckGroupId>(d.id));
if (row == DeckRow::Contour) {
contourWidth += deckGroupWidth(d);
++contourGroups;
} else if (row == DeckRow::Spanning) {
spanningWidth += deckGroupWidth(d);
// The gap fix as a property of the shipped descriptors, not a picture: whichever face a
// mode-dependent group shows, its knob row still spans the group's whole reserved run. The
// Trigger faces drop Sustain and Release and get wider cells for it — never a hole where the
// dropped control was. What the run does not cover is the indivisible residue alone, strictly
// under one pixel per cell. Checked at both a tight and a genuinely wider width.
static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() {
for (int avail : {kSampleAvail, kSampleAvailWide}) {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
const DeckLayout dl = layoutDeck(g, kSamplePad, 0, avail);
CHECK(dl.groups.size() == g.size());
for (std::size_t i = 0; i < dl.groups.size(); ++i) {
// The spanning deck's slots STACK — the run-division law this pins is the
// horizontal one, and its vertical guard is its own test.
if (g[i].row == DeckRow::Spanning) continue;
const DeckGroupLayout& lay = dl.groups[i];
const int reserved = static_cast<int>(g[i].cellIds.size()) * kDeckCellW;
const std::size_t present = lay.cells.size();
CHECK(present > 0);
for (std::size_t k = 0; k < present; ++k) {
const DeckCellLayout& c = lay.cells[k];
CHECK(c.id >= 0); // a reserve yields width, never a dead rect
CHECK(c.cell.width == lay.cells[0].cell.width);
if (k > 0) CHECK(c.cell.x == lay.cells[k - 1].cell.right());
}
const int covered = lay.cells.back().cell.right() - lay.cells.front().cell.x;
CHECK(reserved - covered < static_cast<int>(present));
CHECK(lay.cells.front().cell.x >= lay.box.x + kDeckGroupPadX);
CHECK(lay.cells.back().cell.right() <= lay.box.right() - kDeckGroupPadX);
}
}
// 252 + 312 + 312. Mode-stable because FILTER ENV's and AMP's reserve slots hold them
// at 312 in Trigger as well as Gate.
CHECK(contourGroups == 3);
CHECK(contourWidth == 876);
CHECK(contourWidth <= kDeckRowBlockW);
// Slack enough that neither of the row's two gutters falls under the minimum.
CHECK(kDeckRowBlockW - contourWidth >= (contourGroups - 1) * kDeckGroupGap);
// MASTER is 72 today against a 142 reserve: the double-height interior it grows into is
// budgeted for, not yet spent.
CHECK(spanningWidth == 72);
CHECK(spanningWidth <= kDeckSpanningW);
}
}
// The "residue lands in symmetric end margins" rule is knob_deck's own (layoutGroup), pinned
// once by its synthetic residue>=2 fixture in test_knob_deck.cpp rather than restated here.
static void testHitTestResolvesTheNewFilterControls() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth);
const DeckLayout dl = layoutDeck(g, kSamplePad, 40, kSampleAvail);
const DeckGroupLayout& f =
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
@@ -377,10 +368,15 @@ static void testHitTestResolvesTheNewFilterControls() {
f.captionToggle.seg1.y + 2);
CHECK(on.id == cell(DeckParam::kFilterEnable) && on.segment == 1);
const DeckHit band = hitTestDeck(dl, f.rowToggle.seg0.x + 2, f.rowToggle.seg0.y + 2);
CHECK(band.kind == DeckHitKind::RowToggle);
// 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.rowToggle.seg1.x + 2, f.rowToggle.seg1.y + 2);
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.
@@ -412,308 +408,6 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
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,
};
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) {
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
}
// Gate is the common face and its group widths are what the width budget is spent against:
// pin them at the floor so a later edit anywhere in the deck cannot move one silently.
// (Measured from the shipped descriptors, not copied out of a failing run.) The WRAP row a
// group lands on is deliberately NOT pinned — that is the interim greedy pack the reflow
// replaces, and deckRowFor is where row membership is asserted.
static void testGateModeGroupWidthsAreUnchanged() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const struct { int id; int width; } want[] = {
{kGroupPitch, 192}, {kGroupPitchEnv, 252}, {kGroupFilter, 524},
{kGroupFilterEnv, 312}, {kGroupAmpEnv, 312}, {kGroupVelocity, 192},
{kGroupVoice, 164}, {kGroupMaster, 72},
};
CHECK(g.size() == sizeof(want) / sizeof(want[0]));
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
for (std::size_t i = 0; i < dl.groups.size(); ++i) {
CHECK(dl.groups[i].id == want[i].id);
CHECK(deckGroupWidth(g[i]) == want[i].width);
CHECK(dl.groups[i].box.width == want[i].width);
// Every box lands on a row line, and no lower than the second — the same two-row
// bound the deck height carries.
CHECK(dl.groups[i].box.y % (kDeckGroupH + kDeckRowGap) == 0);
CHECK(dl.groups[i].box.y <= kDeckGroupH + kDeckRowGap);
// Gate carries no reserves, so its cells are the deck's base size.
for (const DeckCellLayout& c : dl.groups[i].cells) 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;
}
@@ -725,8 +419,10 @@ static bool sameLayout(const DeckLayout& a, const DeckLayout& b) {
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))
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;
@@ -747,12 +443,12 @@ static bool sameLayout(const DeckLayout& a, const DeckLayout& b) {
// 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);
const DeckLayout before = layoutDeck(sampleDeckGroups(p.playMode), kSamplePad, 0, kSampleAvail);
p.ampSpline.mode = EnvMode::Spline;
enforceGateUnavailableWhileDrawn(p); // the shared helper both real callers route through
CHECK(p.playMode == PlayMode::Trigger);
const DeckLayout drawn = layoutDeck(sampleDeckGroups(p.playMode), kPad, 0, kAvailAtMinWidth);
const DeckLayout drawn = layoutDeck(sampleDeckGroups(p.playMode), kSamplePad, 0, kSampleAvail);
// The excursion is real: the amp face's cells are strictly wider than Gate's.
const DeckGroupLayout& gateAmp =
before.groups[static_cast<std::size_t>(indexOfGroup(sampleDeckGroups(PlayMode::Gate),
@@ -767,40 +463,26 @@ static void testGateSplineGateRoundTripsToTheSameLayout() {
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);
const DeckLayout after = layoutDeck(sampleDeckGroups(p.playMode), kSamplePad, 0, kSampleAvail);
CHECK(sameLayout(before, after));
}
int main() {
testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks();
testClickingTheActiveOverlayRadioClearsToNone();
testANonRadioIdLeavesTheOverlaySelectionAlone();
testOverlayIsInertExactlyWhenItsGroupToggleIsOff();
testDeckKnobIsInertExactlyWithItsGroupsEnableToggle();
testAModeToggleIsNeitherLiveNorAnOverlayRadio();
testTheModeTogglesCostNoGroupWidth();
testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers();
testOnlyALiveControlsDragTakesTheLiveTier();
testDeckReadsPitchThenFilterThenAmpLeftToRight();
testVelocityGroupOwnsTheThreeCurvesExclusively();
testCurveTargetNamesEachCellsOwnDestination();
testVelocityCellsHitTestWithinTheirGroup();
testFilterGroupCarriesItsToneControlsPlusModulation();
testOnlyTheThreeEnvelopeDecksCarryARadio();
testOnlyTheThreeEnvelopeDecksCarryASelectableRadio();
testGateAndTriggerFacesCarryTheirOwnShapes();
testOnlySlopedStageKnobsCarryAnInnerCurveDial();
testAmpGroupWidthSurvivesAGateTriggerFlip();
testWrappedDeckHeightAtTheEditorFloorWidth();
testDeckFitsInsideTheEnforcedMinimumWindow();
testNoFaceLeavesSlackWhereItsDroppedControlsWere();
testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo();
testGateModeGroupWidthsAreUnchanged();
testGateSplineGateRoundTripsToTheSameLayout();
testTheEditorFloorIsDerivedFromTheDeckWidthBudget();
testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction();
testEveryDeckGroupBelongsToExactlyOneRow();
testTheContourRowAndTheSpanningDeckFitTheBudget();
testNoFaceLeavesSlackWhereItsDroppedControlsWere();
testHitTestResolvesTheNewFilterControls();
testBipolarKnobLawRoundTripsAndIsExactAtCentre();
testGateSplineGateRoundTripsToTheSameLayout();
if (g_fail == 0) std::printf("deck_groups: all tests passed\n");
return g_fail == 0 ? 0 : 1;
}
+399
View File
@@ -0,0 +1,399 @@
// Layout-BUDGET tests for reasampler::instrument::ui::deck_groups, split from
// test_deck_groups.cpp on the seam CMakeLists.txt already named: these fixtures need
// sample_bands (the window-floor constants, computeSampleBands) and master_meter
// (kMeterColumnW, MASTER's reserve), which test_deck_groups.cpp's WHICH-descriptors fixtures do
// not. Pins the editor floor's derivation from the deck's width budget, the row/gutter
// justification arithmetic at and above the floor, the pinned Gate group widths, and MASTER's
// interior to the pixel. test_deck_groups.cpp pins WHICH descriptors the deck carries; this
// file pins what the width budget MEASURES them at.
#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 <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); }
// 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 its originally specified value.
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);
}
// 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.
// 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 == kDeckSpanningW);
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 ==
kDeckSpanningW);
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 == kMeterColumnW);
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 > kDeckSpanningW); // the widen is real, not absorbed elsewhere
CHECK(m.column.box.right() == m.box.right() - kDeckGroupPadX);
}
// The three mode toggles ride each env group's caption slack, on the CONTOUR row: raising
// their segment width past the caption headroom would widen that row and eat its gutters,
// not add a row — row count is a property of the group inventory, not of width.
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));
}
}
}
// 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);
}
}
}
}
int main() {
testDeckFitsInsideTheEnforcedMinimumWindow();
testTheEditorFloorIsDerivedFromTheDeckWidthBudget();
testBothRowsAndTheSpanningDeckFitTheBudget();
testGutterArithmeticAndTheFilterTieLineAtTheFloor();
testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor();
testTheMasterDeckInteriorLandsOnBothRowBaselines();
testTheMasterColumnDoesNotDivideItsRunVertically();
testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow();
testTheModeTogglesCostNoGroupWidth();
testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo();
testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp();
testEveryGroupWidthMatchesTheMeasuredLayout();
if (g_fail == 0) std::printf("deck_groups_measured: all tests passed\n");
return g_fail == 0 ? 0 : 1;
}
+231
View File
@@ -0,0 +1,231 @@
// Standalone tests for reasampler::instrument::ui::deck_groups' commit-tier routing and
// overlay-selection state machine — no VST3, no REAPER, no framework. Split from
// test_deck_groups.cpp on the seam those fixtures already had: nothing here touches
// layoutDeck, DeckGroupWidth, or any other geometry API — deckParamCommit/liveCommitFor (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) are pure
// control-id/enum predicates. test_deck_groups.cpp keeps the geometry/row/width fixtures.
#include "../src/core/instrument/ui/deck_groups.h"
#include <cstdio>
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)
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);
}
int main() {
testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks();
testClickingTheActiveOverlayRadioClearsToNone();
testANonRadioIdLeavesTheOverlaySelectionAlone();
testOverlayIsInertExactlyWhenItsGroupToggleIsOff();
testDeckKnobIsInertExactlyWithItsGroupsEnableToggle();
testAModeToggleIsNeitherLiveNorAnOverlayRadio();
testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers();
testOnlyALiveControlsDragTakesTheLiveTier();
if (g_fail == 0) std::printf("deck_groups_state: all tests passed\n");
return g_fail == 0 ? 0 : 1;
}
+4 -4
View File
@@ -306,7 +306,7 @@ static void testGutterAtTheSawtoothMaximumResidueWidth() {
// nor the floor is covered by another test firing if either ever changes. Derived from
// kEditorMinWidth/kEditorMinHeight (editor_session.cpp's ViewRect default IS the floor) rather
// than a hardcoded window size, so a floor change fails HERE instead of silently moving the
// shipped gutter out from under it. The numbers below are today's floor (1190x680); re-derive
// shipped gutter out from under it. The numbers below are today's floor (1198x680); re-derive
// them by hand if the floor ever moves.
static void testGutterAtTheShippedDefaultWindowSize() {
// Derive rootStrip's width the same way the shell does, through the real allocator +
@@ -315,14 +315,14 @@ static void testGutterAtTheShippedDefaultWindowSize() {
const ChromeRects chrome = chromeRects(bands.chrome, /*knobSize=*/24);
const int stripW = chrome.rootStrip.width;
CHECK(stripW == kEditorMinWidth - 2 * kPad);
CHECK(stripW == 1174);
CHECK(stripW == 1182);
const StripLayout L = layoutStrip(stripW, 30);
CHECK(L.whiteWidth == 15);
const int margins = L.band.width - L.keys.width;
CHECK(margins == 49);
CHECK(margins == 57);
const int leftMargin = L.keys.x - L.band.x;
CHECK(leftMargin == 24);
CHECK(leftMargin == 28);
}
int main() {
+182 -37
View File
@@ -5,8 +5,8 @@
// * layout — caption toggle right-anchored IN the caption row; cells abutting left-to-right
// inside the box; knob square centered; label band beneath; row toggle after the cells.
// * reserves — a -1 id holds the group's width and hands its pixels to the cells present.
// * wrap — deterministic whole-group wrap at a narrowing width; the first group of a row
// always places; deckHeight consistency with deckRowCount.
// * rows — membership comes from the group's own DeckRow, never from a wrap outcome;
// space-between justification inside the row block; the right-anchored spanning deck.
// * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, fence padding
// misses, outside-deck misses.
// * knob-FACE hit-test — the reset resolve against the drawn circles: inner disc, outer ring,
@@ -52,42 +52,183 @@ static void testGroupWidth() {
// No toggles: max(caption, cells) + padding.
DeckGroupDesc master{4, 46, {}, {}, {}, {11}, {}};
CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX);
// A spanning group's cells STACK, so extra slots cost it no width — only its readout
// column does. Two slots measure the same as one.
DeckGroupDesc bus{5, 46, {}, {}, {}, {11}, {}, DeckRow::Spanning, {300, 62}};
CHECK(deckGroupWidth(bus) == kDeckCellW + kDeckColumnGap + 62 + 2 * kDeckGroupPadX);
bus.cellIds = {11, -1, -1};
CHECK(deckGroupWidth(bus) == kDeckCellW + kDeckColumnGap + 62 + 2 * kDeckGroupPadX);
}
static void testWrapAtNarrowWidthIsDeterministic() {
// A width that forces this synthetic deck to wrap: TWO rows, whole trailing groups only.
// Deliberately narrower than the shipped editor floor — this pins the wrap MECHANISM, not
// the shipped deck's row count (that is deck_groups' own test).
const auto deck = shellLikeDeck();
CHECK(deckRowCount(deck, 544) == 2);
CHECK(deckHeight(deck, 544) == 2 * kDeckGroupH + kDeckRowGap);
const DeckLayout dl = layoutDeck(deck, 8, 100, 544);
CHECK(dl.rowCount == 2);
CHECK(dl.height == deckHeight(deck, 544));
CHECK(dl.groups.size() == 5);
// Row membership: groups on row 1 share the first top; the wrapped groups sit one row
// pitch lower and restart at the left margin.
const int row0Top = dl.groups[0].box.y;
const int row1Top = row0Top + kDeckGroupH + kDeckRowGap;
CHECK(dl.groups[0].box.y == row0Top);
CHECK(dl.groups[1].box.y == row0Top);
bool sawWrap = false;
for (std::size_t i = 1; i < dl.groups.size(); ++i) {
if (dl.groups[i].box.y == row1Top && dl.groups[i - 1].box.y == row0Top) {
CHECK(dl.groups[i].box.x == 8); // wrapped row restarts at the left edge
sawWrap = true;
}
// A two-row deck with a spanning bus deck, shaped like the shipped one but with synthetic
// widths: two Sound groups, two Contour groups, one Spanning group carrying a column.
static std::vector<DeckGroupDesc> tworowDeck() {
std::vector<DeckGroupDesc> g;
g.push_back({0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}, DeckRow::Sound, {}});
g.push_back({1, 38, {}, {101, 48}, {}, {6, 7}, {}, DeckRow::Sound, {}});
g.push_back({2, 58, {}, {102, 32}, {}, {8, 9, 10}, {}, DeckRow::Contour, {}});
g.push_back({3, 38, {}, {103, 40}, {}, {11}, {}, DeckRow::Contour, {}});
g.push_back({4, 46, {200, true}, {104, 32}, {}, {12, -1}, {},
DeckRow::Spanning, {300, 62}});
return g;
}
// Row membership is the GROUP's, and nothing about the width can change it: the same list at
// three very different widths lays out as the same two rows plus the same spanning deck.
static void testRowMembershipComesFromTheGroupNotTheWidth() {
const auto deck = tworowDeck();
CHECK(deckRowCount(deck) == 2);
CHECK(deckHeight(deck) == 2 * kDeckGroupH + kDeckRowGap);
CHECK(deckHeight(deck) == kDeckSpanningH);
for (int avail : {600, 1174, 2000}) {
const DeckLayout dl = layoutDeck(deck, 8, 100, avail);
CHECK(dl.rowCount == 2);
CHECK(dl.height == deckHeight(deck));
CHECK(dl.groups.size() == 5);
// The output is in DECK order, not row order — a layout pairs with the descriptor at
// the same index whichever row it landed in.
CHECK(dl.groups[0].id == 0 && dl.groups[1].id == 1);
CHECK(dl.groups[2].id == 2 && dl.groups[3].id == 3);
CHECK(dl.groups[4].id == 4);
CHECK(dl.groups[0].box.y == 100 && dl.groups[1].box.y == 100);
const int row1Top = 100 + kDeckGroupH + kDeckRowGap;
CHECK(dl.groups[2].box.y == row1Top && dl.groups[3].box.y == row1Top);
// Both rows start flush left.
CHECK(dl.groups[0].box.x == 8 && dl.groups[2].box.x == 8);
// The spanning deck stands across both rows and is right-anchored.
CHECK(dl.groups[4].box.y == 100);
CHECK(dl.groups[4].box.height == kDeckSpanningH);
CHECK(dl.groups[4].box.right() == 8 + avail);
}
CHECK(sawWrap);
// Every box stays within the available width (no group straddles the right edge).
for (const auto& g : dl.groups) CHECK(g.box.right() <= 8 + 544);
}
static void testFirstGroupAlwaysPlaces() {
// A group wider than the row still places (degenerate width) — exactly one row per group.
const auto deck = shellLikeDeck();
CHECK(deckRowCount(deck, 100) == 5);
CHECK(deckHeight(deck, 100) == 5 * kDeckGroupH + 4 * kDeckRowGap);
// Space-between: slack becomes gutters, divided equally with the integer residue on the
// LEFTMOST ones, and the row ends flush against the block. Decks are never stretched.
static void testJustificationSpreadsSlackIntoEqualGutters() {
const auto deck = tworowDeck();
const int soundW = deckGroupWidth(deck[0]) + deckGroupWidth(deck[1]);
const int contourW = deckGroupWidth(deck[2]) + deckGroupWidth(deck[3]);
const int spanW = deckGroupWidth(deck[4]);
const int avail = 900;
const int block = avail - spanW - kDeckGroupGap;
const DeckLayout dl = layoutDeck(deck, 8, 0, avail);
// Natural widths, unstretched.
CHECK(dl.groups[0].box.width == deckGroupWidth(deck[0]));
CHECK(dl.groups[1].box.width == deckGroupWidth(deck[1]));
// One gutter per row here, so it takes the whole slack and both rows end on the block.
CHECK(dl.groups[1].box.x - dl.groups[0].box.right() == block - soundW);
CHECK(dl.groups[3].box.x - dl.groups[2].box.right() == block - contourW);
CHECK(dl.groups[1].box.right() == 8 + block);
CHECK(dl.groups[3].box.right() == 8 + block);
// Three gutters over an indivisible slack: base everywhere, +1 on the leftmost ones.
std::vector<DeckGroupDesc> four = {deck[0], deck[1], deck[1], deck[1]};
int total = 0;
for (const auto& g : four) total += deckGroupWidth(g);
const int block4 = 940;
const DeckLayout d4 = layoutDeck(four, 0, 0, block4);
const int slack = block4 - total;
CHECK(slack % 3 != 0); // the case the residue rule exists for
const int base = slack / 3;
const int residue = slack % 3;
for (int i = 0; i < 3; ++i) {
const int gut = d4.groups[static_cast<std::size_t>(i + 1)].box.x -
d4.groups[static_cast<std::size_t>(i)].box.right();
CHECK(gut == base + (i < residue ? 1 : 0));
CHECK(gut >= kDeckGroupGap);
}
CHECK(d4.groups.back().box.right() == block4); // flush right
}
// Below the width the block needs, gutters floor at kDeckGroupGap and the row overruns to the
// right. It never wraps — the editor clamps its window above this, so the degrade only has to
// be defined, not pretty.
static void testTooNarrowFloorsTheGuttersRatherThanWrapping() {
const auto deck = tworowDeck();
CHECK(deckRowCount(deck) == 2); // unchanged: a row count is not a width outcome
const DeckLayout dl = layoutDeck(deck, 0, 0, 200);
CHECK(dl.rowCount == 2);
CHECK(dl.height == 2 * kDeckGroupH + kDeckRowGap);
CHECK(dl.groups[1].box.x - dl.groups[0].box.right() == kDeckGroupGap);
CHECK(dl.groups[3].box.x - dl.groups[2].box.right() == kDeckGroupGap);
CHECK(dl.groups[1].box.right() > 200); // overruns rather than wrapping
}
// The spanning deck's left column uses FIXED slots at the row baselines. Applying the
// horizontal run-division law vertically would stretch its one knob over the whole box — this
// is the regression guard against exactly that.
static void testSpanningColumnStacksFixedSlotsAndCarriesItsReadout() {
const auto deck = tworowDeck();
const DeckLayout dl = layoutDeck(deck, 8, 100, 900);
const DeckGroupLayout& bus = dl.groups[4];
CHECK(bus.cells.size() == 1); // the -1 slot reserves height without drawing a cell
const DeckCellLayout& gain = bus.cells[0];
CHECK(gain.cell.width == kDeckCellW); // fixed, NOT the box's inner width
CHECK(gain.cell.height == kDeckCellH); // fixed, NOT half the double-height box
CHECK(gain.cell.x == bus.box.x + kDeckGroupPadX);
// Slot 0 shares row 0's knob baseline; the reserve below it shares row 1's.
CHECK(gain.cell.y == dl.groups[0].cells[0].cell.y);
const int reserveTop = gain.cell.y + kDeckGroupH + kDeckRowGap;
CHECK(reserveTop == dl.groups[2].cells[0].cell.y);
// ONE readout rect spanning both slots, right of the cell column, flush to the padding.
CHECK(bus.column.id == 300);
CHECK(bus.column.box.width == 62);
CHECK(bus.column.box.x == gain.cell.right() + kDeckColumnGap);
CHECK(bus.column.box.right() == bus.box.right() - kDeckGroupPadX);
CHECK(bus.column.box.y == gain.cell.y);
CHECK(bus.column.box.bottom() == bus.box.bottom() - kDeckGroupPadY);
CHECK(bus.column.box.height == kDeckSpanningH - kDeckGroupPadY - kDeckCaptionH -
kDeckCaptionGap - kDeckGroupPadY);
// The group is exactly as wide as its two columns plus padding.
CHECK(deckGroupWidth(deck[4]) ==
2 * kDeckGroupPadX + kDeckCellW + kDeckColumnGap + 62);
// The column answers its own hit kind; the cell above it still answers as a knob.
const DeckHit col = hitTestDeck(dl, bus.column.box.x + 4, bus.column.box.y + 40);
CHECK(col.kind == DeckHitKind::Column && col.id == 300);
const DeckHit knob = hitTestDeck(dl, gain.cell.x + 4, gain.cell.y + 4);
CHECK(knob.kind == DeckHitKind::Knob && knob.id == 12);
// The reserved slot draws nothing and answers nothing — it is height, not a control.
CHECK(hitTestDeck(dl, gain.cell.x + 4, reserveTop + 4).kind == DeckHitKind::None);
}
// A passive corner radio keeps its rect (the shell draws a lamp there) but is unreachable by
// the hit-test, so no gesture can grow on it by accident.
static void testPassiveRadioIsLaidOutButNeverHit() {
const auto deck = tworowDeck();
const DeckLayout dl = layoutDeck(deck, 8, 100, 900);
const DeckGroupLayout& bus = dl.groups[4];
CHECK(bus.captionRadio.id == 200);
CHECK(bus.captionRadio.passive);
CHECK(bus.captionRadio.box.width == kDeckRadioSize);
CHECK(bus.captionRadio.box.right() == bus.box.right() - kDeckGroupPadX);
const DeckHit h = hitTestDeck(dl, bus.captionRadio.box.x + 2, bus.captionRadio.box.y + 2);
CHECK(h.kind == DeckHitKind::None);
// An INTERACTIVE radio in the same slot still answers — the flag is what changed, not the
// geometry.
std::vector<DeckGroupDesc> active{deck[4]};
active[0].captionRadio.passive = false;
const DeckLayout dl2 = layoutDeck(active, 0, 0, 400);
const DeckHit h2 = hitTestDeck(dl2, dl2.groups[0].captionRadio.box.x + 2,
dl2.groups[0].captionRadio.box.y + 2);
CHECK(h2.kind == DeckHitKind::CaptionRadio && h2.id == 200);
}
// A deck with only a spanning group is as tall as that group, not as tall as zero rows.
static void testSpanningOnlyDeckKeepsItsHeight() {
std::vector<DeckGroupDesc> only{tworowDeck()[4]};
CHECK(deckRowCount(only) == 0);
CHECK(deckHeight(only) == kDeckSpanningH);
const DeckLayout dl = layoutDeck(only, 0, 0, 400);
CHECK(dl.rowCount == 0);
CHECK(dl.height == kDeckSpanningH);
CHECK(dl.groups.size() == 1);
}
static void testGroupInnerGeometry() {
@@ -401,16 +542,20 @@ static void testInKnobFaceUsesTheSmallerDimensionOnANonSquareRect() {
static void testEmptyDeck() {
const std::vector<DeckGroupDesc> none;
CHECK(deckRowCount(none, 800) == 0);
CHECK(deckHeight(none, 800) == 0);
CHECK(deckRowCount(none) == 0);
CHECK(deckHeight(none) == 0);
const DeckLayout dl = layoutDeck(none, 0, 0, 800);
CHECK(dl.groups.empty() && dl.rowCount == 0 && dl.height == 0);
}
int main() {
testGroupWidth();
testWrapAtNarrowWidthIsDeterministic();
testFirstGroupAlwaysPlaces();
testRowMembershipComesFromTheGroupNotTheWidth();
testJustificationSpreadsSlackIntoEqualGutters();
testTooNarrowFloorsTheGuttersRatherThanWrapping();
testSpanningColumnStacksFixedSlotsAndCarriesItsReadout();
testPassiveRadioIsLaidOutButNeverHit();
testSpanningOnlyDeckKeepsItsHeight();
testGroupInnerGeometry();
testHitTest();
testReservedCellWidthGoesToTheCellsPresent();
+317
View File
@@ -0,0 +1,317 @@
// Standalone tests for reasampler::instrument::ui::master_meter — no VST3, no REAPER, no
// framework. Assert:
//
// * column interior — the 22/4/36 decomposition, the exported column width the deck reserves,
// the mono bar taking the whole field, the two stereo bars, all inside the column.
// * bar count — the SAME LaneSplit resolveLaneSplit folds, over channel mode x source
// channel count, so it can never become a second rule.
// * the dB axis — top/floor on the field's edges, an interior value, and the clamps.
// * ballistics — instantaneous rise, 20 dB/s fall, the 1.5 s hold and its release AT RATE;
// the audio thread's clip latch surviving a UI frame; the per-field single-lane fold.
// * the GR lamp — lit only while the limiter reduces, held, and surviving the 500 ms tick the
// editor actually runs it at.
#include "../src/core/instrument/ui/master_meter.h"
#include "../src/core/instrument/ui/waveform_view.h"
#include <cmath>
#include <cstdio>
using namespace reasampler;
using namespace reasampler::instrument::ui;
using reasampler::instrument::engine::kMeterFallDbPerSecond;
using reasampler::instrument::engine::kMeterFloorDb;
using reasampler::instrument::engine::kMeterPeakHoldSeconds;
using reasampler::instrument::engine::kMeterTopDb;
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 shipped column: 62 px wide, 186 tall (knob_deck's spanning geometry).
static const Rect kColumn = Rect::ltrb(1114, 40, 1176, 226);
static void testColumnDividesIntoGutterAndBarField() {
const MeterRects m = meterRects(kColumn, LaneSplit::Single);
CHECK(m.labels.x == kColumn.x);
CHECK(m.labels.width == kMeterLabelW);
CHECK(m.field.x == m.labels.right() + kMeterLabelGap);
CHECK(m.field.width == kMeterFieldW);
// The three parts account for the column exactly — a residue would leave dead pixels the
// scale's numerals would then be centred against. Asserted against the EXPORTED width the
// deck reserves, not against this fixture's literal rect: the deck reading the same
// constant is what keeps the reserve and the interior from drifting apart.
CHECK(kMeterLabelW + kMeterLabelGap + kMeterFieldW == kMeterColumnW);
CHECK(kMeterColumnW == kColumn.width);
CHECK(m.field.right() == kColumn.right());
// Full height in both rects: the column spans both row baselines as ONE readout.
CHECK(m.labels.y == kColumn.y && m.labels.bottom() == kColumn.bottom());
CHECK(m.field.y == kColumn.y && m.field.bottom() == kColumn.bottom());
}
static void testMonoDrawsOneWideBarAndStereoDrawsTwo() {
const MeterRects mono = meterRects(kColumn, LaneSplit::Single);
CHECK(mono.barA == mono.field); // the one bar IS the field
CHECK(mono.barB.empty());
const MeterRects st = meterRects(kColumn, LaneSplit::Stereo);
CHECK(!st.barB.empty());
CHECK(st.barA.width == st.barB.width);
CHECK(st.barA.width == (kMeterFieldW - kMeterBarGap) / 2);
CHECK(st.barA.width == 17);
CHECK(st.barB.x - st.barA.right() == kMeterBarGap);
// Both bars inside the field, and the pair fills it to the pixel.
CHECK(st.barA.x == st.field.x);
CHECK(st.barB.right() == st.field.right());
CHECK(st.barA.y == st.field.y && st.barB.bottom() == st.field.bottom());
}
// The bar count is NOT a second rule: it is resolveLaneSplit's answer for the same (mode,
// source) pair the waveform asks about. A mono source under stereo mode is dual-mono — one
// source, two views.
static void testBarCountFollowsTheWaveformsOwnLaneSplit() {
const Rect band = Rect::ltrb(8, 100, 1182, 458);
for (bool stereoMode : {false, true}) {
for (int sourceChannels : {1, 2}) {
const LaneSplit split = resolveLaneSplit(stereoMode, sourceChannels);
const MeterRects m = meterRects(kColumn, split);
const int bars = m.barB.empty() ? 1 : 2;
// The waveform's own surface folds the SAME call, so on a band tall enough to
// divide the two answers agree by construction rather than by coincidence.
CHECK(bars == waveformSurface(band, stereoMode, sourceChannels).laneCount);
// Spelled out per combination so a regression names which one broke.
const bool expectTwo = stereoMode && sourceChannels >= 2;
CHECK(bars == (expectTwo ? 2 : 1));
}
}
}
static void testDbAxisSpansTheFieldAndClamps() {
const MeterRects m = meterRects(kColumn, LaneSplit::Single);
CHECK(meterDbToY(m.field, kMeterTopDb) == m.field.y);
CHECK(meterDbToY(m.field, kMeterFloorDb) == m.field.bottom());
// Monotone downward as the level falls.
int prev = m.field.y;
for (double db = kMeterTopDb; db >= kMeterFloorDb; db -= 6.0) {
const int y = meterDbToY(m.field, db);
CHECK(y >= prev);
prev = y;
}
// Clamped outside the scale rather than drawn off the field.
CHECK(meterDbToY(m.field, kMeterTopDb + 40.0) == m.field.y);
CHECK(meterDbToY(m.field, kMeterFloorDb - 40.0) == m.field.bottom());
// One INTERIOR point, because endpoints plus monotonicity are satisfied by any log or
// piecewise map through them, and the scale is specified LINEAR in dB. 27 is the
// midpoint of 60…+6, so it must land on the field's own midpoint: 186 x 0.5 = 93.
CHECK(meterDbToY(m.field, -27.0) == m.field.bottom() - 93);
// And a quarter of the way up, which fixes the slope rather than just the centre.
CHECK(meterDbToY(m.field, -43.5) == m.field.bottom() - 47); // round(0.25 x 186) = 47
}
// The numeral SET is spec-pinned (0, 12, 24, 36, 48, 60) as a property of the scale, so it
// is asserted here rather than left as a modulo inside the painter.
static void testEveryOtherTickCarriesANumeral() {
const int expected[] = {6, -6, -18, -30, -42, -54};
for (int db : expected) CHECK(!meterTickNumeralled(db));
const int numeralled[] = {0, -12, -24, -36, -48, -60};
for (int db : numeralled) CHECK(meterTickNumeralled(db));
}
// The floor tick sits ON the field's bottom edge, so an unclamped y±5 numeral box hangs below
// the column and into the deck's bottom padding.
static void testTheFloorNumeralStaysInsideTheGutter() {
const MeterRects m = meterRects(kColumn, LaneSplit::Single);
const Rect floorLabel = meterNumeralRect(m.labels, meterDbToY(m.field, kMeterFloorDb));
CHECK(floorLabel.bottom() <= m.labels.bottom());
CHECK(floorLabel.y >= m.labels.y);
CHECK(floorLabel.height == 10); // clamped, not squashed — the numeral still has its band
const Rect topLabel = meterNumeralRect(m.labels, meterDbToY(m.field, kMeterTopDb));
CHECK(topLabel.y >= m.labels.y);
CHECK(topLabel.height == 10);
// An interior tick is centred on its rule, which is the case the clamp must not disturb.
const int midY = meterDbToY(m.field, -24.0);
CHECK(meterNumeralRect(m.labels, midY).y == midY - 5);
}
// A column narrower than the interior needs yields NOTHING rather than a field overrunning it.
// Reachable only if the deck's reserve and this module's interior ever disagree — which is
// exactly what kMeterColumnW exists to prevent.
static void testAColumnTooNarrowForTheInteriorDrawsNothing() {
const Rect narrow = Rect::ltrb(0, 0, kMeterColumnW - 1, 186);
const MeterRects m = meterRects(narrow, LaneSplit::Stereo);
CHECK(m.field.empty() && m.barA.empty() && m.barB.empty());
// Exactly the needed width still lays out.
CHECK(!meterRects(Rect::ltrb(0, 0, kMeterColumnW, 186), LaneSplit::Stereo).field.empty());
}
static void testPeakRisesAtOnceAndFallsAtTwentyDbPerSecond() {
MasterMeterUi s;
// Unity on the left, silence on the right: the two channels are independent.
s = advanceMasterMeter(s, {1.0, 0.0, 1.0, false}, 0.1);
CHECK(std::fabs(s.left.levelDb - 0.0) < 1e-9); // rise is instantaneous, this very frame
CHECK(s.right.levelDb == kMeterFloorDb);
// One second of silence: exactly kMeterFallDbPerSecond of fall, not a smoothed decay.
s = advanceMasterMeter(s, {0.0, 0.0, 1.0, false}, 1.0);
CHECK(std::fabs(s.left.levelDb - -kMeterFallDbPerSecond) < 1e-9);
}
static void testPeakHoldSitsForItsFullWindowThenReleases() {
MasterMeterUi s;
s = advanceMasterMeter(s, {1.0, 1.0, 1.0, false}, 0.1);
const double held = s.left.holdDb;
CHECK(std::fabs(held - 0.0) < 1e-9);
// Just under the hold window: the bar has fallen a long way, the tick has not moved.
s = advanceMasterMeter(s, {0.0, 0.0, 1.0, false}, kMeterPeakHoldSeconds - 0.01);
CHECK(s.left.levelDb < held - 20.0);
CHECK(std::fabs(s.left.holdDb - held) < 1e-9);
// Past it, the tick releases at the SAME 20 dB/s the bar uses — pinned by value, not as an
// inequality: a slower release would satisfy "it fell" and still be the wrong meter. The
// frame spends the 0.01 s of hold it had left and releases for the remaining 0.49 s, which
// is also what proves the release does not quantize to whole UI frames.
s = advanceMasterMeter(s, {0.0, 0.0, 1.0, false}, 0.5);
CHECK(std::fabs(s.left.holdDb - (held - kMeterFallDbPerSecond * 0.49)) < 1e-9);
CHECK(s.left.holdDb >= s.left.levelDb);
}
// The published latch is the authoritative one: a clip between two UI frames never appears in
// the block peak this frame samples, so dropping it would silently lose the report.
static void testClipLatchesFromThePublishedFlagAndClearsOnDemand() {
MasterMeterUi s;
CHECK(!meterClipped(s));
s = advanceMasterMeter(s, {0.25, 0.25, 1.0, /*clip=*/true}, 0.1);
CHECK(meterClipped(s));
// Latched: quiet frames do not lower it.
s = advanceMasterMeter(s, {0.0, 0.0, 1.0, false}, 5.0);
CHECK(meterClipped(s));
s = clearMasterMeterClip(s);
CHECK(!meterClipped(s));
// And the UI's own sample latches it too, when the loud block IS the one sampled.
s = advanceMasterMeter(s, {1.0, 0.0, 1.0, false}, 0.1);
CHECK(meterClipped(s));
}
static void testGrLampLitOnlyWhileTheLimiterReduces() {
MasterMeterUi s;
CHECK(!grLampLit(s));
// A gain of 1 is no reduction, however long it is held.
s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, 0.1);
CHECK(s.reductionDb == 0.0);
CHECK(!grLampLit(s));
// ~6 dB of reduction lights it, and arms the hold.
s = advanceMasterMeter(s, {0.5, 0.5, 0.5, false}, 0.1);
CHECK(std::fabs(s.reductionDb - 6.0206) < 1e-3);
CHECK(grLampLit(s));
CHECK(s.reductionHoldSeconds == kMeterPeakHoldSeconds);
// Held flat, not decaying, for its whole window — the peak tick's own contract.
s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, kMeterPeakHoldSeconds - 0.01);
CHECK(std::fabs(s.reductionDb - 6.0206) < 1e-3);
CHECK(grLampLit(s));
// Past the window it releases at the meter's 20 dB/s, and 6 dB of catch is gone inside a
// third of a second of release.
s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, 1.0);
CHECK(!grLampLit(s));
CHECK(s.reductionDb == 0.0);
}
// The cadence the lamp ACTUALLY runs at is editor_platform's 500 ms sync tick, and the whole
// point of the hold is that the lamp survives it. Without one, a 6 dB catch decays 20 x 0.5 =
// 10 dB on the very next frame and clamps to 0 — lit for exactly one repaint. Pinned in frames,
// because "how many times does this draw lit" is arithmetic, not a look.
static void testGrLampSurvivesTheFiveHundredMillisecondTick() {
constexpr double kTick = 0.5; // editor_platform.cpp's kSyncTimerIntervalMs
MasterMeterUi s = advanceMasterMeter(MasterMeterUi{}, {0.5, 0.5, 0.5, false}, kTick);
CHECK(grLampLit(s));
int litFrames = 1;
for (int i = 0; i < 20 && grLampLit(s); ++i) {
s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, kTick);
if (grLampLit(s)) ++litFrames;
}
// 1.5 s of hold spans the tick that armed it plus three more, and the release then takes
// 6.02 dB below the 0.5 dB floor within one further 10 dB step.
CHECK(litFrames == 4);
CHECK(!grLampLit(s));
// A catch the previous frame does not shorten: a SECOND catch re-arms the full window.
MasterMeterUi t = advanceMasterMeter(MasterMeterUi{}, {0.5, 0.5, 0.5, false}, kTick);
t = advanceMasterMeter(t, {0.5, 0.5, 1.0, false}, kTick);
t = advanceMasterMeter(t, {0.5, 0.5, 0.5, false}, kTick);
CHECK(t.reductionHoldSeconds == kMeterPeakHoldSeconds);
}
// The one bar a single-lane column draws folds the two channels per FIELD. Picking whichever
// channel won on level would draw the OTHER channel's hold tick and clip nowhere.
static void testSingleLaneStateFoldsBothChannelsPerField() {
MasterMeterUi m;
m.left.levelDb = -30.0;
m.left.holdDb = -2.0; // left is quieter now but held the loudest peak
m.right.levelDb = -10.0;
m.right.holdDb = -8.0;
m.left.clip = true; // and only left ever clipped
m.right.clip = false;
const instrument::engine::MeterState s = meterSingleLaneState(m);
CHECK(s.levelDb == -10.0); // the louder channel's bar
CHECK(s.holdDb == -2.0); // but the higher hold tick, which is the other channel's
CHECK(s.clip); // and the clip, which a level pick would have dropped
}
// The tick repaints only on a change, so what counts as a change has to cover every drawn
// quantity — and only those.
static void testDrawEqualityCoversTheDrawnQuantities() {
MasterMeterUi a;
CHECK(meterDrawEqual(a, a));
MasterMeterUi loud = advanceMasterMeter(a, {1.0, 0.0, 1.0, false}, 0.1);
CHECK(!meterDrawEqual(a, loud)); // bar + hold tick moved
MasterMeterUi clipped = a;
clipped.left.clip = true;
CHECK(!meterDrawEqual(a, clipped)); // the cap appeared
MasterMeterUi lamp = a;
lamp.reductionDb = kGrLampFloorDb;
CHECK(!meterDrawEqual(a, lamp)); // the lamp lit
// Reduction that does not cross the lamp's floor draws identically — the state differs,
// the picture does not, and a repaint there would be pure cost.
MasterMeterUi graze = a;
graze.reductionDb = kGrLampFloorDb / 2.0;
CHECK(meterDrawEqual(a, graze));
}
static void testDegenerateColumnYieldsNothing() {
const MeterRects m = meterRects(Rect::ltrb(0, 0, 0, 0), LaneSplit::Stereo);
CHECK(m.field.empty() && m.barA.empty() && m.barB.empty());
}
int main() {
testColumnDividesIntoGutterAndBarField();
testMonoDrawsOneWideBarAndStereoDrawsTwo();
testBarCountFollowsTheWaveformsOwnLaneSplit();
testDbAxisSpansTheFieldAndClamps();
testEveryOtherTickCarriesANumeral();
testTheFloorNumeralStaysInsideTheGutter();
testAColumnTooNarrowForTheInteriorDrawsNothing();
testPeakRisesAtOnceAndFallsAtTwentyDbPerSecond();
testPeakHoldSitsForItsFullWindowThenReleases();
testClipLatchesFromThePublishedFlagAndClearsOnDemand();
testSingleLaneStateFoldsBothChannelsPerField();
testGrLampLitOnlyWhileTheLimiterReduces();
testGrLampSurvivesTheFiveHundredMillisecondTick();
testDrawEqualityCoversTheDrawnQuantities();
testDegenerateColumnYieldsNothing();
if (g_fail) {
std::printf("%d FAILURE(S)\n", g_fail);
return 1;
}
std::printf("master_meter tests passed\n");
return 0;
}
+107
View File
@@ -0,0 +1,107 @@
// Standalone tests for reasampler::instrument::engine::meter_accumulate — no VST3, no REAPER,
// no framework. Assert:
//
// * the window semantics — max for a peak, min for the limiter gain, and a consume that both
// reports the window and reinstalls the identity element that starts the next one.
// * the INTERLEAVE the CAS exists for: a consume landing inside a fold must not swallow that
// block. Driven by an accumulator that performs the consume from inside its first CAS, so
// the ordering is pinned rather than raced for.
#include "../src/core/instrument/engine/meter_accumulate.h"
#include <atomic>
#include <cstdio>
using namespace reasampler::instrument::engine;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// Stands in for std::atomic<float> with ONE scripted interference: the first compare-exchange
// runs the UI's consume (identity reinstalled, the window taken) and reports failure exactly as
// the real CAS does — expected updated to what the consume left. Everything after is ordinary,
// which is what makes the retry count assertable: a strong CAS fails only under interference.
struct ConsumingAccumulator {
float value;
float identity;
float consumed = -1.f; // what the injected consume took
int casCount = 0;
float load(std::memory_order) const { return value; }
bool compare_exchange_strong(float& expected, float desired, std::memory_order,
std::memory_order) {
if (casCount++ == 0) {
consumed = value;
value = identity;
expected = value;
return false;
}
value = desired;
return true;
}
};
static void testPeakWindowKeepsTheLoudestBlock() {
std::atomic<float> acc{kMeterPeakIdentity};
foldPeak(acc, 0.25f);
foldPeak(acc, 0.90f);
foldPeak(acc, 0.40f); // quieter than the window's max: must not lower it
CHECK(acc.load() == 0.90f);
CHECK(consumePeak(acc) == 0.90f);
// Consumed means a NEW window, not a carried-over one.
CHECK(acc.load() == kMeterPeakIdentity);
foldPeak(acc, 0.10f);
CHECK(consumePeak(acc) == 0.10f);
}
static void testGainWindowKeepsTheDeepestReduction() {
std::atomic<float> acc{kMeterGainIdentity};
foldMinGain(acc, 0.80f);
foldMinGain(acc, 0.55f);
foldMinGain(acc, 0.95f); // shallower: must not raise the window
CHECK(acc.load() == 0.55f);
CHECK(consumeMinGain(acc) == 0.55f);
// 1.0, not 0.0 — an untouched gain window means "no reduction", and a 0 identity would
// report a total mute on every idle frame.
CHECK(acc.load() == kMeterGainIdentity);
}
static void testBlocksAtOrBelowTheWindowLeaveItAlone() {
std::atomic<float> acc{kMeterPeakIdentity};
foldPeak(acc, 0.50f);
foldPeak(acc, 0.50f);
CHECK(acc.load() == 0.50f);
// The post-condition every fold owes, whichever way the comparison went.
foldPeak(acc, 0.20f);
CHECK(acc.load() >= 0.20f);
}
static void testConsumeInsideAFoldStillLandsTheBlockInTheNewWindow() {
// The window holds a LOUDER peak than the block being folded — the exact case a
// load-compare-store fold skips, so the block would be lost when the consume lands
// between that load and the store it decided not to make.
ConsumingAccumulator acc{0.90f, kMeterPeakIdentity};
foldPeak(acc, 0.40f);
CHECK(acc.consumed == 0.90f); // the UI got the window it was owed
CHECK(acc.value == 0.40f); // and the block reached the NEW window rather than vanishing
CHECK(acc.casCount == 2); // one interfering consume, exactly one retry
// Same for the gain window: a block reducing LESS than the window's minimum is the one a
// skipping fold drops, and losing it reports "no reduction" over a block that had some.
ConsumingAccumulator gain{0.60f, kMeterGainIdentity};
foldMinGain(gain, 0.90f);
CHECK(gain.consumed == 0.60f);
CHECK(gain.value == 0.90f);
CHECK(gain.casCount == 2);
}
int main() {
testPeakWindowKeepsTheLoudestBlock();
testGainWindowKeepsTheDeepestReduction();
testBlocksAtOrBelowTheWindowLeaveItAlone();
testConsumeInsideAFoldStillLandsTheBlockInTheNewWindow();
if (g_fail == 0) std::printf("meter_accumulate tests passed\n");
return g_fail == 0 ? 0 : 1;
}
+4 -4
View File
@@ -131,10 +131,10 @@ static void testDeckBandIsBottomAnchoredAtTheEditorFloor() {
CHECK(b.decks.bottom() == kEditorMinHeight - kPad);
}
// At a representative two-row deck height (216px — the ceiling test_deck_groups.cpp bounds
// the wrapped deck to), the waveform gets exactly what the floor's own height leaves it: an
// equality, not a bound, so a floor-height change that quietly ate into the waveform's slack
// would fail here rather than only widen/narrow a `>=`.
// At the shipped two-row deck height (216px — what test_deck_groups.cpp pins the deck to by
// construction, at and above the floor width), the waveform gets exactly what the floor's own
// height leaves it: an equality, not a bound, so a floor-height change that quietly ate into
// the waveform's slack would fail here rather than only widen/narrow a `>=`.
static void testWaveformGetsExactlyTheFloorsRemainingHeightAtATwoRowDeck() {
constexpr int twoRowDeckH = 216;
const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, twoRowDeckH);
+38
View File
@@ -531,6 +531,43 @@ static void testRefreshRefsFromBankUpsertAndOwnership() {
CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40);
}
static void testSameDecodeSourceTracksEveryDecodeInput() {
// The predicate a resumed (already-decoded) instrument is gated on: every field that
// changes what buildSampleData produces must read as different, and the display-only
// name must not.
SelectedSample a;
a.relativePath = "b/a.wav";
a.rootNote = 36;
a.channelCount = 2;
a.loop.hasLoop = true;
a.loop.start = 100;
a.loop.end = 900;
CHECK(sameDecodeSource(a, a));
SelectedSample recaptured = a;
recaptured.relativePath = "b/a2.wav"; // the recapture case: a new file behind one id
CHECK(!sameDecodeSource(a, recaptured));
SelectedSample reRooted = a;
reRooted.rootNote = 40;
CHECK(!sameDecodeSource(a, reRooted));
SelectedSample reChanneled = a;
reChanneled.channelCount = 1; // drives the channel-mode auto-default, hence the decode
CHECK(!sameDecodeSource(a, reChanneled));
SelectedSample loopOff = a;
loopOff.loop.hasLoop = false;
CHECK(!sameDecodeSource(a, loopOff));
SelectedSample loopMoved = a;
loopMoved.loop.start = 101;
CHECK(!sameDecodeSource(a, loopMoved));
loopMoved = a;
loopMoved.loop.end = 901;
CHECK(!sameDecodeSource(a, loopMoved));
}
static void testRetainRefsFiltersToPlayedSet() {
// getState hygiene: only the entries the instance currently plays persist — the table
// cannot grow with browsing history. Order of survivors is preserved.
@@ -1015,6 +1052,7 @@ int main() {
testReferencedSampleIdsIsTheLoadedCapture();
testFindRefLooksUpTheOwnedCopy();
testRefreshRefsFromBankUpsertAndOwnership();
testSameDecodeSourceTracksEveryDecodeInput();
testRetainRefsFiltersToPlayedSet();
testLegacyLiftDecision();
testResolvePlayConvertsWallClockAtTheRate();