Files
reasampler/tests/test_deck_groups_measured.cpp
T

520 lines
29 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 fit their block, in BOTH play modes. The filter mod depth's move across the rows is
// what these two numbers now carry: SOUND loses one cell (980 -> 920) and CONTOUR gains one
// (876 -> 936). Row 2's 936 is mode-stable because FILTER ENV's and AMP's reserve slots hold
// them at 372/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] == 920); // 192 + 372 + 192 + 164
CHECK(count[contour] == 3);
CHECK(width[contour] == 936); // 252 + 372 + 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 it no longer
// buys. THE FILTER TIE-LINE IS GONE, and it is recorded here as a LOSS rather than left to be
// rediscovered: moving the mod depth from FILTER to FILTER ENV made the two filter groups
// EQUAL in width (372 each), and under space-between two equal groups whose rows carry
// different preceding widths can only share a right edge at one block width — which the
// arithmetic below shows is far below the width either row needs. It is unreachable, not
// merely missed, so kDeckRowBlockW and the editor floor are deliberately NOT moved to chase it.
static void testGutterArithmeticAndTheLostFilterTieLineAtTheFloor() {
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 — 108 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() == 36);
CHECK(box(kGroupVelocity).x - box(kGroupFilter).right() == 36);
CHECK(box(kGroupVoice).x - box(kGroupVelocity).right() == 36);
CHECK(box(kGroupVoice).right() == kPad + kDeckRowBlockW);
// Row 2: flush left, flush right, two gutters exactly equal — 92 over two.
CHECK(box(kGroupPitchEnv).x == kPad);
CHECK(box(kGroupFilterEnv).x - box(kGroupPitchEnv).right() == 46);
CHECK(box(kGroupAmpEnv).x - box(kGroupFilterEnv).right() == 46);
CHECK(box(kGroupAmpEnv).right() == kPad + kDeckRowBlockW);
// The loss, block-relative and exact: row 1's filter edge lands 70 px LEFT of row 2's.
CHECK(box(kGroupFilter).right() - kPad == 600);
CHECK(box(kGroupFilterEnv).right() - kPad == 670);
CHECK(box(kGroupFilter).right() != box(kGroupFilterEnv).right());
// And it is unreachable at any block width, which is the part that makes it a loss rather
// than a tuning problem. Solving 192 + (W-920)/3 == 252 + (W-936)/2 over the reals gives
// W = 608 — narrower than either row's own content (920 and 936), so no block that can
// hold the deck at all can also tie the two edges.
const double tieAt = 608.0;
for (int W : {920, 936, kDeckRowBlockW}) CHECK(static_cast<double>(W) > tieAt);
CHECK(192.0 + (tieAt - 920.0) / 3.0 == 252.0 + (tieAt - 936.0) / 2.0);
// 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.
// The two filter edges SEPARATE monotonically with width, which is accepted and deliberate:
// 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 only opens. 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, from the 70 the floor already carries.
CHECK(lastDrift < -70);
}
}
// 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 is the group that BINDS the single-button enable width, and it has ZERO slack: its
// knob row measures kDeckSpanningW 2·pad, so the caption row (46 + gap + button + gap + the
// GR lamp) may reach exactly that and no more. Past 64 the caption row takes over, the spanning
// deck grows, and the growth comes straight out of the 82 px between the editor's floor and its
// ceiling. Pinned at the boundary in both directions rather than as an inequality.
static void testTheLimiterButtonIsAtMostSixtyFourPxBeforeMasterGrows() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckGroupDesc& m = g[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
CHECK(m.captionToggle.id == cell(DeckParam::kLimiterEnable));
CHECK(m.captionToggle.style == DeckToggleStyle::kEnable);
CHECK(deckGroupWidth(m) == kDeckSpanningW);
// The knob row IS the measurement, and it is exactly the group's inner width.
CHECK(kDeckCellW + kDeckColumnGap + kMeterColumnW == kDeckSpanningW - 2 * kDeckGroupPadX);
DeckGroupDesc probe = m;
probe.captionToggle.width = 64;
CHECK(deckGroupWidth(probe) == kDeckSpanningW); // at the ceiling, still knob-row-driven
probe.captionToggle.width = 65;
CHECK(deckGroupWidth(probe) > kDeckSpanningW); // one past it, the spanning deck grows
// And the shipped width is inside the ceiling, so the budget below stays unspent.
CHECK(m.captionToggle.width <= 64);
}
// hitTestKnobFace resolves against the drawn CIRCLES and runs no toggle-precedence pass, so it
// is only correct while no toggle rect reaches a dial. The single-button styles made every
// button on the deck wider, so the claim is re-checked here over the SHIPPED descriptors in
// both faces — test_knob_deck's peer proves the geometry over a synthetic group; this proves it
// for the buttons that actually ship. Rect disjointness rather than a pixel sweep: inKnobFace
// answers only inside the knob rect, so no overlapping pixel can exist without one.
static void testNoShippedToggleReachesADrawnKnobFace() {
const auto disjoint = [](const Rect& a, const Rect& b) {
return a.empty() || b.empty() || a.right() <= b.x || b.right() <= a.x ||
a.bottom() <= b.y || b.bottom() <= a.y;
};
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
int swept = 0;
for (const DeckGroupLayout& lay : dl.groups) {
for (const DeckToggleLayout* t : {&lay.captionToggle, &lay.captionToggle2,
&lay.rowToggle}) {
if (t->id < 0) continue;
++swept;
for (const Rect& seg : {t->seg0, t->seg1}) {
// Against every group's cells, not just this one's: the row toggle anchors
// past its own run and a neighbour is what it would reach first.
for (const DeckGroupLayout& other : dl.groups) {
for (const DeckCellLayout& c : other.cells) CHECK(disjoint(seg, c.knob));
}
}
}
}
CHECK(swept == 11); // every shipped toggle was actually reached by the sweep
}
}
// The 82 px between the floor and the ceiling is untouched by this whole reflow — the mod
// depth's move is a swap between the two rows, not a purchase.
static void testTheEditorWidthBudgetIsStillUnspent() {
CHECK(kEditorMinWidth == 1198);
CHECK(kEditorCeilingWidth - kEditorMinWidth == 82);
CHECK(kDeckRowBlockW == 1028);
CHECK(kDeckSpanningW == 142);
}
// 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 — the bug a balanced caption row once hid.
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 kEnvModeW ceilings recorded in deck_groups.cpp's own comment (PITCH ENV binds at 122,
// AMP at 126) 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 button
// would otherwise invalidate the recorded numbers with nothing failing.
static void testEnvModeCeilingsArePinnedForPitchEnvAndAmp() {
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.width = 122;
CHECK(deckGroupWidth(penvProbe) == 252); // at the ceiling, still knob-row-driven
penvProbe.captionToggle2.width = 123;
CHECK(deckGroupWidth(penvProbe) > 252); // one past it, the caption row takes over
DeckGroupDesc ampProbe = amp;
ampProbe.captionToggle2.width = 126;
CHECK(deckGroupWidth(ampProbe) == 312);
ampProbe.captionToggle2.width = 127;
CHECK(deckGroupWidth(ampProbe) > 312);
// The two ceilings above are what make PITCH ENV the binding group: 122 < 126, so the
// shipped width has to clear PITCH ENV's, and it does.
CHECK(penv.captionToggle2.width <= 122);
}
// 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, 372},
{kGroupFilterEnv, 372}, {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);
}
// EVERY cell is kDeckCellW in EITHER mode — the spacing law. Trigger's two reduced
// faces keep the same reserved run and spend it on end margins, not on wider knobs.
for (const DeckGroupLayout& lay : dl.groups) {
for (const DeckCellLayout& c : lay.cells) CHECK(c.cell.width == kDeckCellW);
}
}
}
// THE Gate-face regression pin. Every group box and every cell rect at the editor's floor,
// block-relative, against the pre-reflow measurements. Three of the eight are the whole point:
// FILTER 432 -> 372, one cell narrower — the mod depth left it.
// FILTER ENV 312 -> 372, one cell wider — the mod depth arrived.
// VELOCITY its box translates 20 px LEFT. Nothing about the group changed; row 1's freed
// 60 px is divided over three gutters by the space-between law, and every group
// between the narrowed one and the row's flush-right end shifts by the share it
// did not absorb. That translation is the law working, not a second edit.
// Everything else — PITCH/RATE, PITCH ENV, AMP ENV, VOICE, MASTER — is pinned UNCHANGED to the
// pixel, boxes and cells alike, which is the criterion this reflow is measured against.
static void testTheGateFaceIsPixelIdenticalApartFromTheTwoFilterGroups() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
const auto lay = [&](int id) -> const DeckGroupLayout& {
return dl.groups[static_cast<std::size_t>(indexOfGroup(g, id))];
};
// {group, block-relative box x, width, cell count} — the pre-reflow numbers for the five
// untouched groups, and the derived ones for the three the move implicates.
const struct { int id; int x; int w; std::size_t cells; } want[] = {
{kGroupPitch, 0, 192, 3}, // unchanged
{kGroupFilter, 228, 372, 6}, // was x=208 w=432 with 7 cells
{kGroupVelocity, 636, 192, 3}, // unchanged group, box translated from x=656
{kGroupVoice, 864, 164, 1}, // unchanged
{kGroupPitchEnv, 0, 252, 4}, // unchanged
{kGroupFilterEnv, 298, 372, 6}, // was x=328 w=312 with 5 cells
{kGroupAmpEnv, 716, 312, 5}, // unchanged
};
for (const auto& w : want) {
const DeckGroupLayout& l = lay(w.id);
CHECK(l.box.x - kPad == w.x);
CHECK(l.box.width == w.w);
CHECK(l.cells.size() == w.cells);
// Cells: natural pitch, abutting, starting flush at the group's inner left (no Gate
// group carries a reserve, so the centring offset is zero everywhere here).
CHECK(l.cells.front().cell.x == l.box.x + kDeckGroupPadX);
for (std::size_t k = 0; k < l.cells.size(); ++k) {
CHECK(l.cells[k].cell.width == kDeckCellW);
CHECK(l.cells[k].cell.x - l.box.x == kDeckGroupPadX +
static_cast<int>(k) * kDeckCellW);
}
}
// The two filter groups moved by EXACTLY one cell, in opposite directions.
CHECK(lay(kGroupFilter).box.width + kDeckCellW == 432);
CHECK(lay(kGroupFilterEnv).box.width - kDeckCellW == 312);
}
int main() {
testDeckFitsInsideTheEnforcedMinimumWindow();
testTheGateFaceIsPixelIdenticalApartFromTheTwoFilterGroups();
testTheEditorFloorIsDerivedFromTheDeckWidthBudget();
testBothRowsAndTheSpanningDeckFitTheBudget();
testGutterArithmeticAndTheLostFilterTieLineAtTheFloor();
testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor();
testTheMasterDeckInteriorLandsOnBothRowBaselines();
testTheMasterColumnDoesNotDivideItsRunVertically();
testTheLimiterButtonIsAtMostSixtyFourPxBeforeMasterGrows();
testNoShippedToggleReachesADrawnKnobFace();
testTheEditorWidthBudgetIsStillUnspent();
testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow();
testTheModeTogglesCostNoGroupWidth();
testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo();
testEnvModeCeilingsArePinnedForPitchEnvAndAmp();
testEveryGroupWidthMatchesTheMeasuredLayout();
if (g_fail == 0) std::printf("deck_groups_measured: all tests passed\n");
return g_fail == 0 ? 0 : 1;
}