Reflow the deck into two categorical rows plus a double-height MASTER bus deck

Row membership is now the group's own property, not a wrap outcome. FILTER's Band|Notch moves to its caption slack, which is what makes the sound row fit. MASTER gains the limiter toggle, the output meter and the GR lamp.
This commit is contained in:
2026-08-02 07:41:10 -04:00
parent f60c05c042
commit 0627398bbb
19 changed files with 1239 additions and 236 deletions
+279 -87
View File
@@ -125,10 +125,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 +142,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,27 +245,36 @@ 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);
// TWO rows plus the spanning deck, BY CONSTRUCTION: the row count is read off the group
// inventory's own row assignment, not observed as a pack outcome, so it holds at every width.
static void testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction() {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
CHECK(deckRowCount(g) == 2);
CHECK(deckHeight(g) == 2 * kDeckGroupH + kDeckRowGap);
CHECK(deckHeight(g) == 216);
// 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);
for (int avail : {kAvailAtMinWidth, kAvailAtMinWidth + 200, 4000}) {
const DeckLayout dl = layoutDeck(g, kPad, 0, avail);
CHECK(dl.rowCount == 2);
CHECK(dl.height == 216);
CHECK(dl.groups.size() == g.size());
int rowTops[2] = {0, kDeckGroupH + kDeckRowGap};
for (const DeckGroupLayout& gl : dl.groups) {
const DeckRow row = deckRowFor(static_cast<DeckGroupId>(gl.id));
if (row == DeckRow::Spanning) {
CHECK(gl.box.y == 0);
CHECK(gl.box.height == kDeckSpanningH);
CHECK(gl.box.right() == kPad + avail); // right-anchored at every width
} else {
CHECK(gl.box.y == rowTops[row == DeckRow::Contour ? 1 : 0]);
CHECK(gl.box.height == kDeckGroupH);
}
}
}
}
}
@@ -265,15 +285,15 @@ static void testWrappedDeckHeightAtTheEditorFloorWidth() {
static void testDeckFitsInsideTheEnforcedMinimumWindow() {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
const int h = deckHeight(g, kAvailAtMinWidth);
const int h = deckHeight(g);
const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h);
CHECK(deckRowCount(g, kAvailAtMinWidth) <= 2); // either face; see the bound above
CHECK(deckRowCount(g) == 2); // either face
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);
// 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
@@ -293,6 +313,14 @@ static void testTheEditorFloorIsDerivedFromTheDeckWidthBudget() {
CHECK(kEditorCeilingWidth - kEditorMinWidth == 90);
// The reflow's 112 px goes entirely to the waveform, so the height does not move.
CHECK(kEditorMinHeight == 680);
// The floor did not move to make the reflow fit — the reflow was fitted to the floor. This
// wave spends the budget it was handed; it does not widen it.
CHECK(kEditorMinWidth == 1190);
CHECK(kEditorMinWidth <= kEditorCeilingWidth);
CHECK(kEditorMinHeight <= 720);
// And the row block really is what the two rows justify inside — derived from the floor
// and the spanning reserve, not restated.
CHECK(kEditorMinWidth - 2 * kPad - kDeckSpanningW - kDeckGroupGap == kDeckRowBlockW);
}
static void testEveryDeckGroupBelongsToExactlyOneRow() {
@@ -320,38 +348,180 @@ 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() {
// 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 contourWidth = 0, contourGroups = 0, spanningWidth = 0;
int width[3] = {0, 0, 0};
int count[3] = {0, 0, 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);
}
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);
}
// 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 gutters the justification law produces at the floor, and the alignment they buy. The
// SPEC (instrument-control-surface.md §1.2/§1.3) states row 1 as 12/14/14 with both filter
// edges at x = 636; equal division of 40 px over three gutters cannot produce that, so what is
// pinned here is what the LAW produces — 14/13/13, filter edge 638 — with row 2 exact at
// 72/72 and 636. The 2 px is flagged for review; a row block of 1028 (floor 1198, still under
// the 1280 ceiling) is the width at which the law puts both edges on 640.
static void testGutterArithmeticAndTheFilterTieLineAtTheFloor() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
const auto box = [&](int id) {
return dl.groups[static_cast<std::size_t>(indexOfGroup(g, id))].box;
};
// Row 1: flush left, flush right on the block, gutters 14/13/13.
CHECK(box(kGroupPitch).x == kPad);
CHECK(box(kGroupFilter).x - box(kGroupPitch).right() == 14);
CHECK(box(kGroupVelocity).x - box(kGroupFilter).right() == 13);
CHECK(box(kGroupVoice).x - box(kGroupVelocity).right() == 13);
CHECK(box(kGroupVoice).right() == kPad + kDeckRowBlockW);
// Row 2: flush left, flush right, and its two gutters exactly equal — the property the
// 1020 block was chosen for, and the one it does deliver.
CHECK(box(kGroupPitchEnv).x == kPad);
CHECK(box(kGroupFilterEnv).x - box(kGroupPitchEnv).right() == 72);
CHECK(box(kGroupAmpEnv).x - box(kGroupFilterEnv).right() == 72);
CHECK(box(kGroupAmpEnv).right() == kPad + kDeckRowBlockW);
// The filter tie-line, block-relative. Row 2 lands on the specified 636; row 1 lands 2 px
// past it. See this test's header.
CHECK(box(kGroupFilterEnv).right() - kPad == 636);
CHECK(box(kGroupFilter).right() - kPad == 638);
// MASTER is right-anchored outside the block, one kDeckGroupGap clear of it.
CHECK(box(kGroupMaster).x - box(kGroupVoice).right() == kDeckGroupGap);
CHECK(box(kGroupMaster).right() == kPad + kAvailAtMinWidth);
}
// No gutter is ever narrower than kDeckGroupGap at or above the floor, and both rows stay
// flush at every width — the property the exact-at-the-floor numbers above are one point of.
// Above the floor the tie-line DRIFTS, which is accepted and deliberate (§1.3): row 1 divides
// its slack over three gutters and row 2 over two, so row 2's filter edge pulls right past
// row 1's and the gap widens monotonically. Encoded as EXPECTED, not as a failure.
static void testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor() {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
int lastDrift = 1 << 20; // sentinel above any real drift
for (int avail = kAvailAtMinWidth; avail <= kAvailAtMinWidth + 600; avail += 37) {
const DeckLayout dl = layoutDeck(g, kPad, 0, avail);
const DeckGroupLayout* prev = nullptr;
DeckRow prevRow = DeckRow::Spanning;
for (const DeckGroupLayout& gl : dl.groups) {
const DeckRow row = deckRowFor(static_cast<DeckGroupId>(gl.id));
if (row != DeckRow::Spanning && prev && row == prevRow) {
CHECK(gl.box.x - prev->box.right() >= kDeckGroupGap);
}
prev = &gl;
prevRow = row;
}
const auto right = [&](int id) {
return dl.groups[static_cast<std::size_t>(indexOfGroup(g, id))].box.right();
};
// Flush right on the block at every width, both rows.
CHECK(right(kGroupVoice) == right(kGroupAmpEnv));
// Monotone in width rather than oscillating: row 2's two gutters absorb slack
// faster than row 1's three, so the gap only ever opens.
const int drift = right(kGroupFilter) - right(kGroupFilterEnv);
CHECK(drift <= lastDrift);
lastDrift = drift;
}
// It really does open up, and by far more than the 2 px it starts at — separation
// above the floor is the accepted outcome, not a near-miss to be pinned back.
CHECK(lastDrift < -50);
}
}
// MASTER's interior, exact to the pixel (§1.4). The two left slots sit on the two rows' own
// knob baselines — that is what "stitched to both rows" means — and the meter is ONE rect
// across both, never a readout per row.
static void testTheMasterDeckInteriorLandsOnBothRowBaselines() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
const DeckGroupLayout& m =
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
CHECK(m.box.width == 142);
CHECK(m.box.height == 216);
// 6 + 60 + 8 + 62 + 6 — the decomposition, not just the total.
CHECK(kDeckGroupPadX + kDeckCellW + kDeckColumnGap + 62 + kDeckGroupPadX == 142);
// One cell drawn (gain) and one slot RESERVED below it: the reserve is height at a fixed
// position and draws nothing.
CHECK(m.cells.size() == 1);
CHECK(m.cells[0].id == cell(DeckParam::kMasterGain));
CHECK(m.cells[0].cell.y - m.box.y == 26);
const int reserveTop = m.cells[0].cell.y + kDeckGroupH + kDeckRowGap;
CHECK(reserveTop - m.box.y == 138);
// The two baselines are row 1's and row 2's own.
const DeckGroupLayout& filter =
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
const DeckGroupLayout& amp =
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupAmpEnv))];
CHECK(m.cells[0].cell.y == filter.cells[0].cell.y);
CHECK(reserveTop == amp.cells[0].cell.y);
// The meter: one rect spanning both baselines, 62 x 186.
CHECK(m.column.id == cell(DeckParam::kMasterMeter));
CHECK(m.column.box.width == 62);
CHECK(m.column.box.height == 186);
CHECK(m.column.box.y == m.cells[0].cell.y);
CHECK(m.column.box.bottom() - m.box.y == 212);
}
// The regression guard for the rule most likely to be "generalised" wrongly: MASTER's left
// column is FIXED slots at the two baselines, NOT knob_deck's horizontal run-division law
// applied vertically — which would stretch the one gain knob over the whole 186 px.
static void testTheMasterColumnDoesNotDivideItsRunVertically() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
const DeckGroupLayout& m =
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
CHECK(m.cells[0].cell.height == kDeckCellH);
CHECK(m.cells[0].cell.width == kDeckCellW);
// Under the run-division law the lone present cell would take the whole two-slot run;
// here it takes exactly one slot and leaves the rest empty.
CHECK(m.cells[0].cell.height < m.column.box.height);
CHECK(m.cells[0].cell.bottom() < m.column.box.bottom());
CHECK(m.cells[0].knob.width == kDeckKnobSize && m.cells[0].knob.height == kDeckKnobSize);
// And dropping the reserve does not move the gain knob or the meter — the slot below it is
// reserved height, so nothing above it depends on whether it is there.
std::vector<DeckGroupDesc> noReserve = g;
for (DeckGroupDesc& d : noReserve) {
if (d.id == kGroupMaster) d.cellIds = {cell(DeckParam::kMasterGain)};
}
const DeckLayout dl2 = layoutDeck(noReserve, kPad, 0, kAvailAtMinWidth);
const DeckGroupLayout& m2 =
dl2.groups[static_cast<std::size_t>(indexOfGroup(noReserve, kGroupMaster))];
CHECK(m2.cells[0].cell == m.cells[0].cell);
CHECK(m2.column.box == m.column.box);
}
static void testHitTestResolvesTheNewFilterControls() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth);
@@ -377,10 +547,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.
@@ -455,7 +630,8 @@ static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() {
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode,
DeckParam::kVoiceCount, DeckParam::kVoiceMode,
DeckParam::kMonoTrigger, DeckParam::kMasterGain,
DeckParam::kMonoTrigger, DeckParam::kMasterGain, DeckParam::kLimiterEnable,
DeckParam::kMasterMeter, DeckParam::kMasterGr,
};
for (DeckParam p : reloads) CHECK(deckParamCommit(p) == LiveCommit::Reload);
@@ -640,6 +816,9 @@ static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() {
const DeckLayout dl = layoutDeck(g, kPad, 0, avail);
CHECK(dl.groups.size() == g.size());
for (std::size_t i = 0; i < dl.groups.size(); ++i) {
// The spanning deck's slots STACK — the run-division law this pins is the
// horizontal one, and its vertical guard is its own test.
if (g[i].row == DeckRow::Spanning) continue;
const DeckGroupLayout& lay = dl.groups[i];
const int reserved = static_cast<int>(g[i].cellIds.size()) * kDeckCellW;
const std::size_t present = lay.cells.size();
@@ -687,30 +866,37 @@ static void testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo() {
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);
// 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, 524},
{kGroupPitch, 192}, {kGroupPitchEnv, 252}, {kGroupFilter, 432},
{kGroupFilterEnv, 312}, {kGroupAmpEnv, 312}, {kGroupVelocity, 192},
{kGroupVoice, 164}, {kGroupMaster, 72},
{kGroupVoice, 164}, {kGroupMaster, 142},
};
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);
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);
}
}
}
}
@@ -725,8 +911,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;
@@ -786,19 +974,23 @@ int main() {
testCurveTargetNamesEachCellsOwnDestination();
testVelocityCellsHitTestWithinTheirGroup();
testFilterGroupCarriesItsToneControlsPlusModulation();
testOnlyTheThreeEnvelopeDecksCarryARadio();
testOnlyTheThreeEnvelopeDecksCarryASelectableRadio();
testGateAndTriggerFacesCarryTheirOwnShapes();
testOnlySlopedStageKnobsCarryAnInnerCurveDial();
testAmpGroupWidthSurvivesAGateTriggerFlip();
testWrappedDeckHeightAtTheEditorFloorWidth();
testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction();
testDeckFitsInsideTheEnforcedMinimumWindow();
testNoFaceLeavesSlackWhereItsDroppedControlsWere();
testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo();
testGateModeGroupWidthsAreUnchanged();
testEveryGroupWidthMatchesTheMeasuredLayout();
testGateSplineGateRoundTripsToTheSameLayout();
testTheEditorFloorIsDerivedFromTheDeckWidthBudget();
testEveryDeckGroupBelongsToExactlyOneRow();
testTheContourRowAndTheSpanningDeckFitTheBudget();
testBothRowsAndTheSpanningDeckFitTheBudget();
testGutterArithmeticAndTheFilterTieLineAtTheFloor();
testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor();
testTheMasterDeckInteriorLandsOnBothRowBaselines();
testTheMasterColumnDoesNotDivideItsRunVertically();
testHitTestResolvesTheNewFilterControls();
testBipolarKnobLawRoundTripsAndIsExactAtCentre();
if (g_fail == 0) std::printf("deck_groups: all tests passed\n");
+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();
+215
View File
@@ -0,0 +1,215 @@
// Standalone tests for reasampler::instrument::ui::master_meter — no VST3, no REAPER, no
// framework. Assert:
//
// * column interior — the 22/4/36 decomposition, the mono bar taking the whole field, the
// two stereo bars at 17 px and kMeterBarGap apart, all inside the column.
// * bar count — the SAME LaneSplit waveformSurface folds, over channel mode x source
// channel count, so it can never become a second rule.
// * the dB axis — top/floor land on the field's edges, it is monotone, and it clamps.
// * ballistics — instantaneous rise, 20 dB/s fall, the 1.5 s hold and its release; the
// audio thread's clip latch surviving a UI frame that never sampled the loud block.
// * the GR lamp — lit only while the limiter actually reduces, and decaying afterwards.
#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.
CHECK(kMeterLabelW + kMeterLabelGap + kMeterFieldW == 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 whatever waveformSurface resolved for the same
// (mode, source) pair. 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 WaveformSurface s = waveformSurface(band, stereoMode, sourceChannels);
const LaneSplit split =
s.laneCount == 2 ? LaneSplit::Stereo : LaneSplit::Single;
const MeterRects m = meterRects(kColumn, split);
const int bars = m.barB.empty() ? 1 : 2;
CHECK(bars == s.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());
}
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.
s = advanceMasterMeter(s, {0.0, 0.0, 1.0, false}, 0.5);
CHECK(s.left.holdDb < held);
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.
s = advanceMasterMeter(s, {0.5, 0.5, 0.5, false}, 0.1);
CHECK(std::fabs(s.reductionDb - 6.0206) < 1e-3);
CHECK(grLampLit(s));
// It decays at the meter's own rate rather than snapping dark, so a transient catch is
// visible for more than the single frame it happened on.
s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, 0.1);
CHECK(grLampLit(s));
CHECK(s.reductionDb < 6.0206);
s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, 1.0);
CHECK(!grLampLit(s));
CHECK(s.reductionDb == 0.0);
}
// 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();
testPeakRisesAtOnceAndFallsAtTwentyDbPerSecond();
testPeakHoldSitsForItsFullWindowThenReleases();
testClipLatchesFromThePublishedFlagAndClearsOnDemand();
testGrLampLitOnlyWhileTheLimiterReduces();
testDrawEqualityCoversTheDrawnQuantities();
testDegenerateColumnYieldsNothing();
if (g_fail) {
std::printf("%d FAILURE(S)\n", g_fail);
return 1;
}
std::printf("master_meter tests passed\n");
return 0;
}
+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);