// Standalone tests for reasampler::instrument::ui::deck_groups — no VST3, no REAPER, no // framework. knob_deck's own tests pin how a descriptor list LAYS OUT; these pin WHICH // descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp), // the Filter group's contents, the VELOCITY group's exclusive ownership of the three curve // cells and its placement immediately left of VOICE, the wrapped deck height at the editor's // floor width and its fit inside the floor window, the pinned Gate group widths, the editor // floor derived from the deck's width budget and each group's categorical row, // that no face leaves slack where its dropped controls were and that a Gate/Spline/Gate round // trip restores the layout exactly, the hit-test reaching the new filter controls, and the // bipolar knob law's inverse pair. The commit-tier routing and the overlay-selection state // machine live in test_deck_groups_state.cpp — they touch no layout at all. #include "../src/core/instrument/ui/deck_groups.h" #include "../src/core/instrument/ui/master_meter.h" // kMeterColumnW: MASTER's reserve IS this #include "../src/core/instrument/ui/sample_bands.h" #include #include #include using namespace reasampler; using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) // The editor's floor width, which is also its default (checkSizeConstraint clamps to it), less // the band allocator's kPad inset on each side. static constexpr int kAvailAtMinWidth = kEditorMinWidth - 2 * kPad; static int indexOfGroup(const std::vector& g, int id) { for (std::size_t i = 0; i < g.size(); ++i) { if (g[i].id == id) return static_cast(i); } return -1; } static int cell(DeckParam p) { return static_cast(p); } static void testDeckReadsPitchThenFilterThenAmpLeftToRight() { for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { const std::vector g = sampleDeckGroups(mode); const int pitch = indexOfGroup(g, kGroupPitch); const int penv = indexOfGroup(g, kGroupPitchEnv); const int filt = indexOfGroup(g, kGroupFilter); const int fenv = indexOfGroup(g, kGroupFilterEnv); const int amp = indexOfGroup(g, kGroupAmpEnv); CHECK(pitch >= 0 && penv >= 0 && filt >= 0 && fenv >= 0 && amp >= 0); // The signal flow, left to right. Each envelope group trails its own stage. CHECK(pitch < penv); CHECK(penv < filt); CHECK(filt < fenv); CHECK(fenv < amp); // VELOCITY then the two instance-wide groups at the end. Velocity sits IMMEDIATELY // left of VOICE — MASTER is reserved for post-voice-mixer concerns, so the curves // must not drift into it. const int vel = indexOfGroup(g, kGroupVelocity); CHECK(amp < vel); CHECK(vel + 1 == indexOfGroup(g, kGroupVoice)); CHECK(indexOfGroup(g, kGroupVoice) < indexOfGroup(g, kGroupMaster)); } } // The three velocity curves live together in VELOCITY and nowhere else: no other group may // carry a curve cell, or the "one home" the group exists for is not one. static void testVelocityGroupOwnsTheThreeCurvesExclusively() { for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { const std::vector g = sampleDeckGroups(mode); const DeckGroupDesc& v = g[static_cast(indexOfGroup(g, kGroupVelocity))]; const std::vector expected = {cell(DeckParam::kAmpVelCurve), cell(DeckParam::kPitchVelCurve), cell(DeckParam::kFilterVelCurve)}; CHECK(v.cellIds == expected); CHECK(v.captionToggle.id == -1 && v.rowToggle.id == -1 && v.captionRadio.id == -1); for (const DeckGroupDesc& d : g) { if (d.id == kGroupVelocity) continue; for (int id : d.cellIds) CHECK(curveTargetFor(id) == CurveTarget::kNone); CHECK(curveTargetFor(d.captionToggle.id) == CurveTarget::kNone); CHECK(curveTargetFor(d.rowToggle.id) == CurveTarget::kNone); } } } // Each curve cell names its OWN destination, and an ordinary knob names none — the predicate // the shell uses to tell a popup opener from a dial. static void testCurveTargetNamesEachCellsOwnDestination() { CHECK(curveTargetFor(cell(DeckParam::kAmpVelCurve)) == CurveTarget::kAmp); CHECK(curveTargetFor(cell(DeckParam::kPitchVelCurve)) == CurveTarget::kPitch); CHECK(curveTargetFor(cell(DeckParam::kFilterVelCurve)) == CurveTarget::kFilter); CHECK(curveTargetFor(cell(DeckParam::kFilterCutoff)) == CurveTarget::kNone); CHECK(curveTargetFor(cell(DeckParam::kMasterGain)) == CurveTarget::kNone); CHECK(curveTargetFor(-1) == CurveTarget::kNone); // a width reserve, not a control CHECK(curveTargetFor(9999) == CurveTarget::kNone); // out of the id space } // The cells hit-test inside their own group, from the centre of each cell — the deck grammar // treats them as knob cells, so the popup routing rides an ordinary Knob hit. static void testVelocityCellsHitTestWithinTheirGroup() { const std::vector g = sampleDeckGroups(PlayMode::Gate); const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth); const DeckGroupLayout& v = dl.groups[static_cast(indexOfGroup(g, kGroupVelocity))]; CHECK(v.cells.size() == 3); const CurveTarget want[] = {CurveTarget::kAmp, CurveTarget::kPitch, CurveTarget::kFilter}; for (std::size_t i = 0; i < v.cells.size(); ++i) { const DeckCellLayout& c = v.cells[i]; const DeckHit hit = hitTestDeck(dl, c.cell.x + c.cell.width / 2, c.cell.y + c.cell.height / 2); CHECK(hit.kind == DeckHitKind::Knob); CHECK(hit.id == c.id); CHECK(curveTargetFor(hit.id) == want[i]); // Inside its own group box, and the cell the hit resolved is this one. CHECK(c.cell.x >= v.box.x && c.cell.right() <= v.box.right()); } } static void testFilterGroupCarriesItsToneControlsPlusModulation() { const std::vector& g = sampleDeckGroups(PlayMode::Gate); const DeckGroupDesc& f = g[static_cast(indexOfGroup(g, kGroupFilter))]; const std::vector expected = { cell(DeckParam::kFilterMorph), cell(DeckParam::kFilterCutoff), cell(DeckParam::kFilterQ), cell(DeckParam::kFilterDrive), cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterVel), cell(DeckParam::kFilterKeyTrack)}; CHECK(f.cellIds == expected); // Off by default is a state question, but reachability is a layout one: BOTH toggles now // ride the caption row, which is what takes the group from 524 to 432. CHECK(f.captionToggle.id == cell(DeckParam::kFilterEnable)); CHECK(f.captionToggle2.id == cell(DeckParam::kFilterLaw)); CHECK(f.rowToggle.id == -1); const DeckGroupDesc& fe = g[static_cast(indexOfGroup(g, kGroupFilterEnv))]; const std::vector env = { cell(DeckParam::kFilterEnvAttack), cell(DeckParam::kFilterEnvHold), cell(DeckParam::kFilterEnvDecay), cell(DeckParam::kFilterEnvSustain), cell(DeckParam::kFilterEnvRelease)}; CHECK(fe.cellIds == env); // The filter envelope has no enable of its own — the FILTER group's toggle governs both. CHECK(fe.captionToggle.id == -1); CHECK(fe.rowToggle.id == -1); } // 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 g = sampleDeckGroups(mode); int radios = 0; for (const DeckGroupDesc& d : g) { if (d.captionRadio.id < 0) continue; if (d.captionRadio.passive) { CHECK(d.id == kGroupMaster); CHECK(d.captionRadio.id == cell(DeckParam::kMasterGr)); // A passive slot names no overlay, so no click on it could select one even if // the hit-test ever handed it through. CHECK(overlayEnvForRadio(d.captionRadio.id) == OverlayEnv::kNone); continue; } ++radios; const int want = d.id == kGroupAmpEnv ? cell(DeckParam::kAmpEnvSelect) : d.id == kGroupPitchEnv ? cell(DeckParam::kPitchEnvSelect) : d.id == kGroupFilterEnv ? cell(DeckParam::kFilterEnvSelect) : -1; CHECK(d.captionRadio.id == want); } CHECK(radios == 3); } } // The mode-driven shape switch, on BOTH the amp and the filter envelope: Gate shows the // AHDSR's five stages, Trigger the AHD's three (behind the play span on the amp deck), and // neither mode leaks the other's controls onto the deck. static void testGateAndTriggerFacesCarryTheirOwnShapes() { const std::vector gate = sampleDeckGroups(PlayMode::Gate); const std::vector trig = sampleDeckGroups(PlayMode::Trigger); const DeckGroupDesc& gAmp = gate[static_cast(indexOfGroup(gate, kGroupAmpEnv))]; const DeckGroupDesc& tAmp = trig[static_cast(indexOfGroup(trig, kGroupAmpEnv))]; const std::vector gateAmp = {cell(DeckParam::kAttack), cell(DeckParam::kHold), cell(DeckParam::kDecay), cell(DeckParam::kSustain), cell(DeckParam::kRelease)}; const std::vector trigAmp = {cell(DeckParam::kTrigLength), cell(DeckParam::kTrigAttack), cell(DeckParam::kTrigHold), cell(DeckParam::kTrigDecay), -1}; CHECK(gAmp.cellIds == gateAmp); CHECK(tAmp.cellIds == trigAmp); const DeckGroupDesc& gFe = gate[static_cast(indexOfGroup(gate, kGroupFilterEnv))]; const DeckGroupDesc& tFe = trig[static_cast(indexOfGroup(trig, kGroupFilterEnv))]; const std::vector trigFe = {cell(DeckParam::kFilterTrigAttack), cell(DeckParam::kFilterTrigHold), cell(DeckParam::kFilterTrigDecay), -1, -1}; CHECK(tFe.cellIds == trigFe); CHECK(gFe.cellIds != tFe.cellIds); // Same cell count either way, so the group's width — and its neighbours' placement — // survives a mode flip. CHECK(gFe.cellIds.size() == tFe.cellIds.size()); CHECK(deckGroupWidth(gFe) == deckGroupWidth(tFe)); } // Every SLOPED stage knob carries an inner curve dial; Hold, Sustain, and everything that is // not a stage carries none. This is the "which segments are sloped" rule, asserted rather than // read. static void testOnlySlopedStageKnobsCarryAnInnerCurveDial() { const DeckParam sloped[] = { DeckParam::kAttack, DeckParam::kDecay, DeckParam::kRelease, DeckParam::kTrigAttack, DeckParam::kTrigDecay, DeckParam::kPitchEnvAttack, DeckParam::kPitchEnvDecay, DeckParam::kFilterEnvAttack, DeckParam::kFilterEnvDecay, DeckParam::kFilterEnvRelease, DeckParam::kFilterTrigAttack, DeckParam::kFilterTrigDecay, }; for (DeckParam p : sloped) { const DeckParam c = curveParamFor(p); CHECK(c != DeckParam::kCount); // A curve control is itself flat — no inner dial on an inner dial. CHECK(curveParamFor(c) == DeckParam::kCount); } const DeckParam flat[] = { DeckParam::kHold, DeckParam::kSustain, DeckParam::kTrigHold, DeckParam::kPitchEnvHold, DeckParam::kFilterEnvHold, DeckParam::kFilterEnvSustain, DeckParam::kFilterTrigHold, DeckParam::kTrigLength, DeckParam::kPitchEnvDepth, DeckParam::kFilterCutoff, DeckParam::kMasterGain, DeckParam::kKeyTrack, }; for (DeckParam p : flat) CHECK(curveParamFor(p) == DeckParam::kCount); // Every sloped knob maps to a DISTINCT curve control — a copy-paste that pointed two // stages at one exponent would tie two dials together silently. for (std::size_t i = 0; i < sizeof(sloped) / sizeof(sloped[0]); ++i) { for (std::size_t j = i + 1; j < sizeof(sloped) / sizeof(sloped[0]); ++j) { CHECK(curveParamFor(sloped[i]) != curveParamFor(sloped[j])); } } } static void testAmpGroupWidthSurvivesAGateTriggerFlip() { // The reserved blanks are what stop a mode flip reflowing the groups beside AMP. const std::vector gate = sampleDeckGroups(PlayMode::Gate); const std::vector trig = sampleDeckGroups(PlayMode::Trigger); const DeckGroupDesc& a = gate[static_cast(indexOfGroup(gate, kGroupAmpEnv))]; const DeckGroupDesc& b = trig[static_cast(indexOfGroup(trig, kGroupAmpEnv))]; CHECK(deckGroupWidth(a) == deckGroupWidth(b)); CHECK(a.cellIds.size() == b.cellIds.size()); CHECK(b.cellIds[4] == -1); // the Trigger face's one reserved blank // Every other group is mode-independent, so the whole deck's height is too. CHECK(deckHeight(gate) == deckHeight(trig)); } // TWO rows plus the spanning deck, BY CONSTRUCTION: the row count is read off the group // inventory's own row assignment, not observed as a pack outcome, so it holds at every width. static void testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction() { for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { const std::vector g = sampleDeckGroups(mode); CHECK(deckRowCount(g) == 2); CHECK(deckHeight(g) == 2 * kDeckGroupH + kDeckRowGap); CHECK(deckHeight(g) == 216); for (int avail : {kAvailAtMinWidth, kAvailAtMinWidth + 200, 4000}) { const DeckLayout dl = layoutDeck(g, kPad, 0, avail); CHECK(dl.rowCount == 2); CHECK(dl.height == 216); CHECK(dl.groups.size() == g.size()); int rowTops[2] = {0, kDeckGroupH + kDeckRowGap}; for (const DeckGroupLayout& gl : dl.groups) { const DeckRow row = deckRowFor(static_cast(gl.id)); if (row == DeckRow::Spanning) { CHECK(gl.box.y == 0); CHECK(gl.box.height == kDeckSpanningH); CHECK(gl.box.right() == kPad + avail); // right-anchored at every width } else { CHECK(gl.box.y == rowTops[row == DeckRow::Contour ? 1 : 0]); CHECK(gl.box.height == kDeckGroupH); } } } } } // The guard the raised floor exists to provide: at the smallest window the host can produce, // the deck band still lands inside the client area AND the waveform still gets its two-lane // floor. Growing the deck past what the floor height can hold fails HERE instead of silently pushing // FILTER ENV / AMP / VOICE / MASTER off-screen, where there is no scroll to reach them. static void testDeckFitsInsideTheEnforcedMinimumWindow() { for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { const std::vector g = sampleDeckGroups(mode); const int h = deckHeight(g); const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h); CHECK(deckRowCount(g) == 2); // either face CHECK(b.decks.height == h); // The reflow's 112 px land in the waveform: at two rows the deck band is 216 and the // waveform 358, against 328/246 before. Pinned now that both are reached by // construction rather than by a pack outcome. CHECK(b.decks.height == 2 * kDeckGroupH + kDeckRowGap); CHECK(b.waveform.height == 358); // Bottom-anchored INSIDE the pad is the whole assertion: the degrade path pushes the // deck down until the waveform hits its floor, so any deck too tall to fit stops // landing on this exact line. A `<= kEditorMinHeight` bound would not catch it — the // degrade can still leave the deck ending at the window edge. CHECK(b.decks.bottom() == kEditorMinHeight - kPad); CHECK(b.waveform.height >= kWaveformMinHeight); } } // The floor is a DERIVED number, and this is the one place the derivation is written down — // sample_bands stays independent of knob_deck, so neither header can hold it. This fixture is // the only one that includes both. static void testTheEditorFloorIsDerivedFromTheDeckWidthBudget() { CHECK(kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW + 2 * kPad == kEditorMinWidth); // The budget: what is left between the derived floor and the hard ceiling, and it is spent // once. A cell costs 60 of it. CHECK(kEditorCeilingWidth - kEditorMinWidth == 82); // 82 still buys one more deck cell (60), which is the only purchase the ledger promises — // the widen below spent 8 px of slack, not the layout's purchasing power. CHECK(kEditorCeilingWidth - kEditorMinWidth >= kDeckCellW); // The reflow's 112 px goes entirely to the waveform, so the height does not move. CHECK(kEditorMinHeight == 680); // 1190 + 8: the row block was widened 1020 -> 1028 to put the two rows' filter edges on // one pixel, which is the only reason the floor moved off Γ-W1-T4's number. CHECK(kEditorMinWidth == 1198); CHECK(kEditorMinWidth <= kEditorCeilingWidth); CHECK(kEditorMinHeight <= 720); // And the row block really is what the two rows justify inside — derived from the floor // and the spanning reserve, not restated. CHECK(kEditorMinWidth - 2 * kPad - kDeckSpanningW - kDeckGroupGap == kDeckRowBlockW); } static void testEveryDeckGroupBelongsToExactlyOneRow() { CHECK(deckRowFor(kGroupPitch) == DeckRow::Sound); CHECK(deckRowFor(kGroupFilter) == DeckRow::Sound); CHECK(deckRowFor(kGroupVelocity) == DeckRow::Sound); CHECK(deckRowFor(kGroupVoice) == DeckRow::Sound); CHECK(deckRowFor(kGroupPitchEnv) == DeckRow::Contour); CHECK(deckRowFor(kGroupFilterEnv) == DeckRow::Contour); CHECK(deckRowFor(kGroupAmpEnv) == DeckRow::Contour); CHECK(deckRowFor(kGroupMaster) == DeckRow::Spanning); // Totality against the descriptor list the deck actually carries, not just against the // enum: a group that shipped without a row would land here as a miscount. for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { int sound = 0, contour = 0, spanning = 0; for (const DeckGroupDesc& d : sampleDeckGroups(mode)) { switch (deckRowFor(static_cast(d.id))) { case DeckRow::Sound: ++sound; break; case DeckRow::Contour: ++contour; break; case DeckRow::Spanning: ++spanning; break; } } CHECK(sound == 4 && contour == 3 && spanning == 1); } } // Both rows now fit their block, in BOTH play modes. Row 1's fit is the one this track closes: // it was 1030, +42 from PITCH/RATE's third cell and −92 from FILTER's Band|Notch caption move // take it to 980. Row 2's 876 is mode-stable because FILTER ENV's and AMP's reserve slots hold // them at 312 in Trigger too — asserted here rather than assumed. static void testBothRowsAndTheSpanningDeckFitTheBudget() { for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { const std::vector 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(deckRowFor(static_cast(d.id))); width[r] += deckGroupWidth(d); ++count[r]; } const int sound = static_cast(DeckRow::Sound); const int contour = static_cast(DeckRow::Contour); const int spanning = static_cast(DeckRow::Spanning); CHECK(count[sound] == 4); CHECK(width[sound] == 980); // 192 + 432 + 192 + 164 CHECK(count[contour] == 3); CHECK(width[contour] == 876); // 252 + 312 + 312 CHECK(count[spanning] == 1); CHECK(width[spanning] == kDeckSpanningW); // 142 exactly — the reserve is now spent for (int r : {sound, contour}) { CHECK(width[r] <= kDeckRowBlockW); // Slack enough that no gutter in the row falls under the minimum. CHECK(kDeckRowBlockW - width[r] >= (count[r] - 1) * kDeckGroupGap); } } } // The gutters the justification law produces at the floor, and the alignment they buy. The // At the 1028 block the justification law makes the tie-line exact by arithmetic rather than // by a special rule: row 1's slack is 48 over three gutters (16 each, no residue) and row 2's // is 152 over two (76 each), which lands both filter edges on 640. Only two of the three // properties §1.3 once claimed can hold at once — a smallest gutter of exactly kDeckGroupGap // needs a 1016 block — and 12 is a floor, not a target, so 16 satisfies the real rule. static void testGutterArithmeticAndTheFilterTieLineAtTheFloor() { const std::vector g = sampleDeckGroups(PlayMode::Gate); const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); const auto box = [&](int id) { return dl.groups[static_cast(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 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(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] = ≷ } const auto right = [&](int id) { return dl.groups[static_cast(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 g = sampleDeckGroups(PlayMode::Gate); const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); const DeckGroupLayout& m = dl.groups[static_cast(indexOfGroup(g, kGroupMaster))]; CHECK(m.box.width == 142); CHECK(m.box.height == 216); // 6 + 60 + 8 + 62 + 6 — the decomposition, not just the total, and the 62 is the meter // module's own kMeterColumnW rather than a copy of it. That link is the whole point: the // column is banked to GROW (§1.2), and a reserve that did not track it would leave the // interior underfilling or overrunning with every test still green. CHECK(kDeckGroupPadX + kDeckCellW + kDeckColumnGap + kMeterColumnW + kDeckGroupPadX == 142); CHECK(m.column.id == cell(DeckParam::kMasterMeter)); CHECK(m.column.box.width == kMeterColumnW); // One cell drawn (gain) and one slot RESERVED below it: the reserve is height at a fixed // position and draws nothing. CHECK(m.cells.size() == 1); CHECK(m.cells[0].id == cell(DeckParam::kMasterGain)); CHECK(m.cells[0].cell.y - m.box.y == 26); const int reserveTop = m.cells[0].cell.y + kDeckGroupH + kDeckRowGap; CHECK(reserveTop - m.box.y == 138); // The two baselines are row 1's and row 2's own. const DeckGroupLayout& filter = dl.groups[static_cast(indexOfGroup(g, kGroupFilter))]; const DeckGroupLayout& amp = dl.groups[static_cast(indexOfGroup(g, kGroupAmpEnv))]; CHECK(m.cells[0].cell.y == filter.cells[0].cell.y); CHECK(reserveTop == amp.cells[0].cell.y); // The meter: one rect spanning both baselines, 62 x 186. CHECK(m.column.id == cell(DeckParam::kMasterMeter)); CHECK(m.column.box.width == 62); CHECK(m.column.box.height == 186); CHECK(m.column.box.y == m.cells[0].cell.y); CHECK(m.column.box.bottom() - m.box.y == 212); } // The regression guard for the rule most likely to be "generalised" wrongly: MASTER's left // column is FIXED slots at the two baselines, NOT knob_deck's horizontal run-division law // applied vertically — which would stretch the one gain knob over the whole 186 px. static void testTheMasterColumnDoesNotDivideItsRunVertically() { const std::vector g = sampleDeckGroups(PlayMode::Gate); const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); const DeckGroupLayout& m = dl.groups[static_cast(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 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(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 g = sampleDeckGroups(PlayMode::Gate); DeckGroupDesc probe = g[static_cast(indexOfGroup(g, kGroupMaster))]; probe.captionWidth += 40; // unbalances it: the caption row now measures past the knob row const std::vector one = {probe}; const DeckLayout dl = layoutDeck(one, kPad, 0, kAvailAtMinWidth); const DeckGroupLayout& m = dl.groups[0]; CHECK(m.box.width > 142); // the widen is real, not absorbed elsewhere CHECK(m.column.box.right() == m.box.right() - kDeckGroupPadX); } static void testHitTestResolvesTheNewFilterControls() { const std::vector g = sampleDeckGroups(PlayMode::Gate); const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth); const DeckGroupLayout& f = dl.groups[static_cast(indexOfGroup(g, kGroupFilter))]; // Every knob cell resolves to its own id, from the centre of its cell. for (const DeckCellLayout& c : f.cells) { const DeckHit hit = hitTestDeck(dl, c.cell.x + c.cell.width / 2, c.cell.y + c.cell.height / 2); CHECK(hit.kind == DeckHitKind::Knob); CHECK(hit.id == c.id); } CHECK(f.cells.size() == 7); CHECK(f.cells[1].id == cell(DeckParam::kFilterCutoff)); // The enable toggle's two segments and the morph-law row toggle's two. const DeckHit off = hitTestDeck(dl, f.captionToggle.seg0.x + 2, f.captionToggle.seg0.y + 2); CHECK(off.kind == DeckHitKind::CaptionToggle); CHECK(off.id == cell(DeckParam::kFilterEnable) && off.segment == 0); const DeckHit on = hitTestDeck(dl, f.captionToggle.seg1.x + 2, f.captionToggle.seg1.y + 2); CHECK(on.id == cell(DeckParam::kFilterEnable) && on.segment == 1); // The morph law answers from its NEW home in the caption row, and as a CaptionToggle — // the shell's toggle branch handles both kinds, so the move must not change the id or the // segment either. const DeckHit band = hitTestDeck(dl, f.captionToggle2.seg0.x + 2, f.captionToggle2.seg0.y + 2); CHECK(band.kind == DeckHitKind::CaptionToggle); CHECK(band.id == cell(DeckParam::kFilterLaw) && band.segment == 0); const DeckHit notch = hitTestDeck(dl, f.captionToggle2.seg1.x + 2, f.captionToggle2.seg1.y + 2); CHECK(notch.id == cell(DeckParam::kFilterLaw) && notch.segment == 1); // The filter-envelope knobs resolve too, and are distinct ids from the amp's. const DeckGroupLayout& fe = dl.groups[static_cast(indexOfGroup(g, kGroupFilterEnv))]; const DeckHit attack = hitTestDeck(dl, fe.cells[0].cell.x + 4, fe.cells[0].cell.y + 4); CHECK(attack.kind == DeckHitKind::Knob); CHECK(attack.id == cell(DeckParam::kFilterEnvAttack)); CHECK(attack.id != cell(DeckParam::kAttack)); } static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() { // Centre is EXACT in both directions: a knob parked at 0.5 stores 0, and 0 reads back // 0.5 — no residual modulation from a rounding hair. CHECK(deckBipolarFromNorm(0.5) == 0.0); CHECK(deckNormFromBipolar(0.0) == 0.5); CHECK(deckBipolarFromNorm(0.0) == -1.0); CHECK(deckBipolarFromNorm(1.0) == 1.0); for (int i = 0; i <= 200; ++i) { const double norm = static_cast(i) / 200.0; CHECK(std::fabs(deckNormFromBipolar(deckBipolarFromNorm(norm)) - norm) < 1e-12); const double value = -1.0 + static_cast(i) / 100.0; CHECK(std::fabs(deckBipolarFromNorm(deckNormFromBipolar(value)) - value) < 1e-12); } // Out of range clamps rather than extrapolating. CHECK(deckBipolarFromNorm(-3.0) == -1.0); CHECK(deckBipolarFromNorm(3.0) == 1.0); CHECK(deckNormFromBipolar(-3.0) == 0.0); CHECK(deckNormFromBipolar(3.0) == 1.0); } // 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 g = sampleDeckGroups(mode); const DeckLayout dl = layoutDeck(g, kPad, 0, avail); CHECK(dl.groups.size() == g.size()); for (std::size_t i = 0; i < dl.groups.size(); ++i) { // The spanning deck's slots STACK — the run-division law this pins is the // horizontal one, and its vertical guard is its own test. if (g[i].row == DeckRow::Spanning) continue; const DeckGroupLayout& lay = dl.groups[i]; const int reserved = static_cast(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(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 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(DeckParam::kKeyTrack)); CHECK(pitch->cellIds[1] == static_cast(DeckParam::kRate)); CHECK(pitch->cellIds[2] == static_cast(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 g = sampleDeckGroups(PlayMode::Gate); const DeckGroupDesc& penv = g[static_cast(indexOfGroup(g, kGroupPitchEnv))]; const DeckGroupDesc& amp = g[static_cast(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 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(i)]) == w.width); const DeckGroupLayout& lay = dl.groups[static_cast(indexOfGroup(g, w.id))]; CHECK(lay.box.width == w.width); } // Gate carries no reserves, so its cells are the deck's base size; Trigger's two // reduced faces divide the same reserved run between fewer cells and get wider ones. for (const DeckGroupLayout& lay : dl.groups) { for (const DeckCellLayout& c : lay.cells) { CHECK(c.cell.width >= kDeckCellW); if (mode == PlayMode::Gate) CHECK(c.cell.width == kDeckCellW); } } } } static bool sameToggle(const DeckToggleLayout& a, const DeckToggleLayout& b) { return a.id == b.id && a.seg0 == b.seg0 && a.seg1 == b.seg1; } static bool sameLayout(const DeckLayout& a, const DeckLayout& b) { if (a.rowCount != b.rowCount || a.height != b.height || a.groups.size() != b.groups.size()) return false; for (std::size_t i = 0; i < a.groups.size(); ++i) { const DeckGroupLayout& x = a.groups[i]; const DeckGroupLayout& y = b.groups[i]; if (x.id != y.id || !(x.box == y.box) || !(x.caption == y.caption)) return false; if (x.captionRadio.id != y.captionRadio.id || !(x.captionRadio.box == y.captionRadio.box) || x.captionRadio.passive != y.captionRadio.passive) return false; if (x.column.id != y.column.id || !(x.column.box == y.column.box)) return false; if (!sameToggle(x.captionToggle, y.captionToggle) || !sameToggle(x.captionToggle2, y.captionToggle2) || !sameToggle(x.rowToggle, y.rowToggle)) return false; if (x.cells.size() != y.cells.size()) return false; for (std::size_t k = 0; k < x.cells.size(); ++k) { const DeckCellLayout& c = x.cells[k]; const DeckCellLayout& d = y.cells[k]; if (c.id != d.id || !(c.cell == d.cell) || !(c.knob == d.knob) || !(c.inner == d.inner) || !(c.label == d.label)) return false; } } return true; } // A Spline excursion is fully reversible at the layout level: the mode forcing swaps the amp // and filter faces onto their wider cells and back, leaving no residue in the geometry. Driven // through the shared enforceGateUnavailableWhileDrawn helper, so the deck cannot agree with a // forcing rule the real callers do not use. static void testGateSplineGateRoundTripsToTheSameLayout() { PlayParams p; // Gate, all three envelopes staged const DeckLayout before = layoutDeck(sampleDeckGroups(p.playMode), kPad, 0, kAvailAtMinWidth); p.ampSpline.mode = EnvMode::Spline; enforceGateUnavailableWhileDrawn(p); // the shared helper both real callers route through CHECK(p.playMode == PlayMode::Trigger); const DeckLayout drawn = layoutDeck(sampleDeckGroups(p.playMode), kPad, 0, kAvailAtMinWidth); // The excursion is real: the amp face's cells are strictly wider than Gate's. const DeckGroupLayout& gateAmp = before.groups[static_cast(indexOfGroup(sampleDeckGroups(PlayMode::Gate), kGroupAmpEnv))]; const DeckGroupLayout& trigAmp = drawn.groups[static_cast(indexOfGroup(sampleDeckGroups(PlayMode::Trigger), kGroupAmpEnv))]; CHECK(trigAmp.cells.size() < gateAmp.cells.size()); CHECK(trigAmp.cells[0].cell.width > gateAmp.cells[0].cell.width); CHECK(!sameLayout(before, drawn)); p.ampSpline.mode = EnvMode::Staged; CHECK(!splineActive(p)); p.playMode = PlayMode::Gate; // Gate is selectable again once nothing is drawn const DeckLayout after = layoutDeck(sampleDeckGroups(p.playMode), kPad, 0, kAvailAtMinWidth); CHECK(sameLayout(before, after)); } int main() { testTheModeTogglesCostNoGroupWidth(); testDeckReadsPitchThenFilterThenAmpLeftToRight(); testVelocityGroupOwnsTheThreeCurvesExclusively(); testCurveTargetNamesEachCellsOwnDestination(); testVelocityCellsHitTestWithinTheirGroup(); testFilterGroupCarriesItsToneControlsPlusModulation(); testOnlyTheThreeEnvelopeDecksCarryASelectableRadio(); testGateAndTriggerFacesCarryTheirOwnShapes(); testOnlySlopedStageKnobsCarryAnInnerCurveDial(); testAmpGroupWidthSurvivesAGateTriggerFlip(); testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction(); testDeckFitsInsideTheEnforcedMinimumWindow(); testNoFaceLeavesSlackWhereItsDroppedControlsWere(); testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo(); testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp(); testEveryGroupWidthMatchesTheMeasuredLayout(); testGateSplineGateRoundTripsToTheSameLayout(); testTheEditorFloorIsDerivedFromTheDeckWidthBudget(); testEveryDeckGroupBelongsToExactlyOneRow(); testBothRowsAndTheSpanningDeckFitTheBudget(); testGutterArithmeticAndTheFilterTieLineAtTheFloor(); testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor(); testTheMasterDeckInteriorLandsOnBothRowBaselines(); testTheMasterColumnDoesNotDivideItsRunVertically(); testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow(); testHitTestResolvesTheNewFilterControls(); testBipolarKnobLawRoundTripsAndIsExactAtCentre(); if (g_fail == 0) std::printf("deck_groups: all tests passed\n"); return g_fail == 0 ? 0 : 1; }