Merge Ω-W1-T4: deck reflow, focus-by-click overlay selection, single-button toggles
This commit is contained in:
+155
-54
@@ -125,11 +125,10 @@ static void testFilterGroupCarriesItsToneControlsPlusModulation() {
|
||||
const std::vector<int> expected = {
|
||||
cell(DeckParam::kFilterMorph), cell(DeckParam::kFilterCutoff),
|
||||
cell(DeckParam::kFilterQ), cell(DeckParam::kFilterDrive),
|
||||
cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterVel),
|
||||
cell(DeckParam::kFilterKeyTrack)};
|
||||
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.
|
||||
// ride the caption row, which is what takes the group from 524 to 372.
|
||||
CHECK(f.captionToggle.id == cell(DeckParam::kFilterEnable));
|
||||
CHECK(f.captionToggle2.id == cell(DeckParam::kFilterLaw));
|
||||
CHECK(f.rowToggle.id == -1);
|
||||
@@ -138,39 +137,111 @@ static void testFilterGroupCarriesItsToneControlsPlusModulation() {
|
||||
const std::vector<int> env = {
|
||||
cell(DeckParam::kFilterEnvAttack), cell(DeckParam::kFilterEnvHold),
|
||||
cell(DeckParam::kFilterEnvDecay), cell(DeckParam::kFilterEnvSustain),
|
||||
cell(DeckParam::kFilterEnvRelease)};
|
||||
cell(DeckParam::kFilterEnvRelease), cell(DeckParam::kFilterModAmt)};
|
||||
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() {
|
||||
// The mod DEPTH sits with the envelope it scales, LAST in that group's run, in both faces —
|
||||
// the kPitchEnvDepth shape. And it left FILTER: a control drawn in two groups would be two
|
||||
// controls to the user even though it is one parameter.
|
||||
static void testTheFilterModDepthLivesWithTheFilterEnvelopeInBothFaces() {
|
||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
||||
int radios = 0;
|
||||
const DeckGroupDesc& fe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupFilterEnv))];
|
||||
CHECK(fe.cellIds.back() == cell(DeckParam::kFilterModAmt));
|
||||
// Exactly once across the WHOLE deck, and not in FILTER.
|
||||
int seen = 0;
|
||||
for (const DeckGroupDesc& d : g) {
|
||||
for (int c : d.cellIds) {
|
||||
if (c != cell(DeckParam::kFilterModAmt)) continue;
|
||||
++seen;
|
||||
CHECK(d.id == kGroupFilterEnv);
|
||||
}
|
||||
}
|
||||
CHECK(seen == 1);
|
||||
// The depth knob mirrors kPitchEnvDepth: last in its envelope's run, and neither is a
|
||||
// staged segment, so neither carries an inner curve dial.
|
||||
const DeckGroupDesc& pe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupPitchEnv))];
|
||||
CHECK(pe.cellIds.back() == cell(DeckParam::kPitchEnvDepth));
|
||||
CHECK(curveParamFor(DeckParam::kFilterModAmt) == DeckParam::kCount);
|
||||
}
|
||||
}
|
||||
|
||||
// No group carries a selectable overlay radio any more — the deck itself is the target, and a
|
||||
// radio beside it would be a second way to say the same thing. MASTER keeps the corner slot for
|
||||
// its PASSIVE gain-reduction lamp, which is a readout and must never become a selector.
|
||||
static void testNoGroupCarriesASelectableRadioAndMasterKeepsItsLamp() {
|
||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
||||
int lamps = 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(d.captionRadio.passive);
|
||||
CHECK(d.id == kGroupMaster);
|
||||
CHECK(d.captionRadio.id == cell(DeckParam::kMasterGr));
|
||||
++lamps;
|
||||
}
|
||||
CHECK(radios == 3);
|
||||
CHECK(lamps == 1);
|
||||
}
|
||||
}
|
||||
|
||||
// The focus map: exactly the three envelope decks name an overlay, every other group and every
|
||||
// off-deck point (-1) names kNone — which is how a click outside them CLEARS the focus. Setting
|
||||
// is idempotent by construction: the map is a function of the group alone, so re-clicking a
|
||||
// focused deck cannot toggle it off.
|
||||
static void testTheOverlayFocusMapNamesTheThreeEnvelopeDecksAndNothingElse() {
|
||||
CHECK(overlayEnvForGroup(kGroupAmpEnv) == OverlayEnv::kAmp);
|
||||
CHECK(overlayEnvForGroup(kGroupPitchEnv) == OverlayEnv::kPitch);
|
||||
CHECK(overlayEnvForGroup(kGroupFilterEnv) == OverlayEnv::kFilter);
|
||||
for (int id : {kGroupPitch, kGroupFilter, kGroupVelocity, kGroupVoice, kGroupMaster}) {
|
||||
CHECK(overlayEnvForGroup(id) == OverlayEnv::kNone);
|
||||
}
|
||||
CHECK(overlayEnvForGroup(-1) == OverlayEnv::kNone); // outside every deck
|
||||
CHECK(overlayEnvForGroup(9999) == OverlayEnv::kNone); // not a group id at all
|
||||
|
||||
// The map is TOTAL over the shipped inventory: every group answers, and exactly three
|
||||
// answer with an envelope, so a group added without a decision here shows up as a miscount.
|
||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||
int named = 0;
|
||||
for (const DeckGroupDesc& d : sampleDeckGroups(mode)) {
|
||||
if (overlayEnvForGroup(d.id) != OverlayEnv::kNone) ++named;
|
||||
}
|
||||
CHECK(named == 3);
|
||||
}
|
||||
}
|
||||
|
||||
// Every converted control is ONE button, and the two variants are told apart structurally
|
||||
// rather than by what they are labelled: an enable has an off state, a mode selector's label
|
||||
// IS the state. The five explicitly-not-converted controls keep their two segments — a named
|
||||
// boundary, not an oversight. Eleven toggles ship; the count is asserted so a new one cannot
|
||||
// arrive without a style decision here.
|
||||
static void testTheConvertedTogglesAreSingleButtonsAndTheRestStaySegmented() {
|
||||
const DeckParam enables[] = {DeckParam::kPitchEnvEnable, DeckParam::kFilterEnable,
|
||||
DeckParam::kLimiterEnable};
|
||||
const DeckParam modes[] = {DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode,
|
||||
DeckParam::kFilterEnvMode};
|
||||
const DeckParam segmented[] = {DeckParam::kPlayMode, DeckParam::kPitchEngine,
|
||||
DeckParam::kVoiceMode, DeckParam::kFilterLaw,
|
||||
DeckParam::kMonoTrigger};
|
||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||
int seen = 0;
|
||||
for (const DeckGroupDesc& d : sampleDeckGroups(mode)) {
|
||||
for (const DeckToggleDesc* t : {&d.captionToggle, &d.captionToggle2, &d.rowToggle}) {
|
||||
if (t->id < 0) continue;
|
||||
++seen;
|
||||
DeckToggleStyle want = DeckToggleStyle::kSegmented;
|
||||
for (DeckParam p : enables) if (t->id == cell(p)) want = DeckToggleStyle::kEnable;
|
||||
for (DeckParam p : modes) if (t->id == cell(p)) want = DeckToggleStyle::kMode;
|
||||
bool named = want != DeckToggleStyle::kSegmented;
|
||||
for (DeckParam p : segmented) if (t->id == cell(p)) named = true;
|
||||
CHECK(named); // every shipped toggle is one of the eight named above
|
||||
CHECK(t->style == want);
|
||||
}
|
||||
}
|
||||
CHECK(seen == 11);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +266,8 @@ static void testGateAndTriggerFacesCarryTheirOwnShapes() {
|
||||
const DeckGroupDesc& tFe = trig[static_cast<std::size_t>(indexOfGroup(trig, kGroupFilterEnv))];
|
||||
const std::vector<int> trigFe = {cell(DeckParam::kFilterTrigAttack),
|
||||
cell(DeckParam::kFilterTrigHold),
|
||||
cell(DeckParam::kFilterTrigDecay), -1, -1};
|
||||
cell(DeckParam::kFilterTrigDecay), -1, -1,
|
||||
cell(DeckParam::kFilterModAmt)};
|
||||
CHECK(tFe.cellIds == trigFe);
|
||||
CHECK(gFe.cellIds != tFe.cellIds);
|
||||
// Same cell count either way, so the group's width — and its neighbours' placement —
|
||||
@@ -306,19 +378,19 @@ static void testEveryDeckGroupBelongsToExactlyOneRow() {
|
||||
}
|
||||
}
|
||||
|
||||
// The gap fix as a property of the shipped descriptors, not a picture: whichever face a
|
||||
// mode-dependent group shows, its knob row still spans the group's whole reserved run. The
|
||||
// Trigger faces drop Sustain and Release and get wider cells for it — never a hole where the
|
||||
// dropped control was. What the run does not cover is the indivisible residue alone, strictly
|
||||
// under one pixel per cell. Checked at both a tight and a genuinely wider width.
|
||||
static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() {
|
||||
// Every knob on the deck sits at its natural pitch in BOTH faces, and a reduced face pays for
|
||||
// its dropped controls in symmetric end margins rather than in wider cells — the defect this
|
||||
// track closes was Trigger's FILTER ENV at ~100px cells and its AMP at ~75 against the standard
|
||||
// 60. Checked at both a tight and a genuinely wider width, since the group box moves with the
|
||||
// justification but the run inside it must not change shape.
|
||||
static void testEveryCellKeepsItsNaturalPitchInBothFaces() {
|
||||
for (int avail : {kSampleAvail, kSampleAvailWide}) {
|
||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
||||
const DeckLayout dl = layoutDeck(g, kSamplePad, 0, avail);
|
||||
CHECK(dl.groups.size() == g.size());
|
||||
for (std::size_t i = 0; i < dl.groups.size(); ++i) {
|
||||
// The spanning deck's slots STACK — the run-division law this pins is the
|
||||
// The spanning deck's slots STACK — the centring 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];
|
||||
@@ -328,20 +400,43 @@ static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() {
|
||||
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);
|
||||
CHECK(c.cell.width == kDeckCellW);
|
||||
if (k > 0) CHECK(c.cell.x == lay.cells[k - 1].cell.right());
|
||||
}
|
||||
const int covered = lay.cells.back().cell.right() - lay.cells.front().cell.x;
|
||||
CHECK(reserved - covered < static_cast<int>(present));
|
||||
CHECK(lay.cells.front().cell.x >= lay.box.x + kDeckGroupPadX);
|
||||
CHECK(lay.cells.back().cell.right() <= lay.box.right() - kDeckGroupPadX);
|
||||
const int lead = lay.cells.front().cell.x - (lay.box.x + kDeckGroupPadX);
|
||||
const int trail =
|
||||
(lay.box.right() - kDeckGroupPadX) - lay.cells.back().cell.right();
|
||||
CHECK(lead >= 0 && trail >= 0);
|
||||
// A group whose knob row is not what it measures from (VOICE's row toggle, or
|
||||
// a caption-bound group) has trailing box width beyond the run; the LEAD margin
|
||||
// is the reserve's own half either way.
|
||||
CHECK(lead == (reserved - static_cast<int>(present) * kDeckCellW) / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// The two mode-dependent groups are where the defect lived: their reserves buy a stable box
|
||||
// width, and after the reflow they buy it without stretching a single knob.
|
||||
static void testTheReducedTriggerFacesAreTheSameKnobsAsGateJustCentred() {
|
||||
const std::vector<DeckGroupDesc> gate = sampleDeckGroups(PlayMode::Gate);
|
||||
const std::vector<DeckGroupDesc> trig = sampleDeckGroups(PlayMode::Trigger);
|
||||
const DeckLayout gl = layoutDeck(gate, kSamplePad, 0, kSampleAvail);
|
||||
const DeckLayout tl = layoutDeck(trig, kSamplePad, 0, kSampleAvail);
|
||||
for (int id : {kGroupFilterEnv, kGroupAmpEnv}) {
|
||||
const DeckGroupLayout& a = gl.groups[static_cast<std::size_t>(indexOfGroup(gate, id))];
|
||||
const DeckGroupLayout& b = tl.groups[static_cast<std::size_t>(indexOfGroup(trig, id))];
|
||||
CHECK(a.box == b.box); // the box does not move — what the reserves are for
|
||||
CHECK(b.cells.size() < a.cells.size());
|
||||
for (const DeckCellLayout& c : b.cells) CHECK(c.cell.width == kDeckCellW);
|
||||
// Centred: the two margins match, and together they are the dropped cells' width.
|
||||
const int lead = b.cells.front().cell.x - (b.box.x + kDeckGroupPadX);
|
||||
const int trail = (b.box.right() - kDeckGroupPadX) - b.cells.back().cell.right();
|
||||
CHECK(lead == trail);
|
||||
CHECK(lead + trail ==
|
||||
static_cast<int>(a.cells.size() - b.cells.size()) * kDeckCellW);
|
||||
}
|
||||
}
|
||||
|
||||
static void testHitTestResolvesTheNewFilterControls() {
|
||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||
@@ -356,17 +451,17 @@ static void testHitTestResolvesTheNewFilterControls() {
|
||||
CHECK(hit.kind == DeckHitKind::Knob);
|
||||
CHECK(hit.id == c.id);
|
||||
}
|
||||
CHECK(f.cells.size() == 7);
|
||||
CHECK(f.cells.size() == 6);
|
||||
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 enable is ONE button now: both ends of it answer the same hit with no segment, so
|
||||
// the commit has to derive the next state rather than read one off the click.
|
||||
for (int px : {f.captionToggle.seg0.x + 2, f.captionToggle.seg0.right() - 2}) {
|
||||
const DeckHit en = hitTestDeck(dl, px, f.captionToggle.seg0.y + 2);
|
||||
CHECK(en.kind == DeckHitKind::CaptionToggle);
|
||||
CHECK(en.id == cell(DeckParam::kFilterEnable) && en.segment == -1);
|
||||
CHECK(en.group == kGroupFilter);
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -409,7 +504,7 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
|
||||
}
|
||||
|
||||
static bool sameToggle(const DeckToggleLayout& a, const DeckToggleLayout& b) {
|
||||
return a.id == b.id && a.seg0 == b.seg0 && a.seg1 == b.seg1;
|
||||
return a.id == b.id && a.seg0 == b.seg0 && a.seg1 == b.seg1 && a.style == b.style;
|
||||
}
|
||||
|
||||
static bool sameLayout(const DeckLayout& a, const DeckLayout& b) {
|
||||
@@ -449,7 +544,8 @@ static void testGateSplineGateRoundTripsToTheSameLayout() {
|
||||
enforceGateUnavailableWhileDrawn(p); // the shared helper both real callers route through
|
||||
CHECK(p.playMode == PlayMode::Trigger);
|
||||
const DeckLayout drawn = layoutDeck(sampleDeckGroups(p.playMode), kSamplePad, 0, kSampleAvail);
|
||||
// The excursion is real: the amp face's cells are strictly wider than Gate's.
|
||||
// The excursion is real: the amp face drops a cell and the shorter run re-centres, so its
|
||||
// first knob starts further in than Gate's. (It is not WIDER — the cells hold their pitch.)
|
||||
const DeckGroupLayout& gateAmp =
|
||||
before.groups[static_cast<std::size_t>(indexOfGroup(sampleDeckGroups(PlayMode::Gate),
|
||||
kGroupAmpEnv))];
|
||||
@@ -457,7 +553,8 @@ static void testGateSplineGateRoundTripsToTheSameLayout() {
|
||||
drawn.groups[static_cast<std::size_t>(indexOfGroup(sampleDeckGroups(PlayMode::Trigger),
|
||||
kGroupAmpEnv))];
|
||||
CHECK(trigAmp.cells.size() < gateAmp.cells.size());
|
||||
CHECK(trigAmp.cells[0].cell.width > gateAmp.cells[0].cell.width);
|
||||
CHECK(trigAmp.cells[0].cell.width == gateAmp.cells[0].cell.width);
|
||||
CHECK(trigAmp.cells[0].cell.x > gateAmp.cells[0].cell.x);
|
||||
CHECK(!sameLayout(before, drawn));
|
||||
|
||||
p.ampSpline.mode = EnvMode::Staged;
|
||||
@@ -473,13 +570,17 @@ int main() {
|
||||
testCurveTargetNamesEachCellsOwnDestination();
|
||||
testVelocityCellsHitTestWithinTheirGroup();
|
||||
testFilterGroupCarriesItsToneControlsPlusModulation();
|
||||
testOnlyTheThreeEnvelopeDecksCarryASelectableRadio();
|
||||
testTheFilterModDepthLivesWithTheFilterEnvelopeInBothFaces();
|
||||
testNoGroupCarriesASelectableRadioAndMasterKeepsItsLamp();
|
||||
testTheOverlayFocusMapNamesTheThreeEnvelopeDecksAndNothingElse();
|
||||
testTheConvertedTogglesAreSingleButtonsAndTheRestStaySegmented();
|
||||
testGateAndTriggerFacesCarryTheirOwnShapes();
|
||||
testOnlySlopedStageKnobsCarryAnInnerCurveDial();
|
||||
testAmpGroupWidthSurvivesAGateTriggerFlip();
|
||||
testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction();
|
||||
testEveryDeckGroupBelongsToExactlyOneRow();
|
||||
testNoFaceLeavesSlackWhereItsDroppedControlsWere();
|
||||
testEveryCellKeepsItsNaturalPitchInBothFaces();
|
||||
testTheReducedTriggerFacesAreTheSameKnobsAsGateJustCentred();
|
||||
testHitTestResolvesTheNewFilterControls();
|
||||
testBipolarKnobLawRoundTripsAndIsExactAtCentre();
|
||||
testGateSplineGateRoundTripsToTheSameLayout();
|
||||
|
||||
@@ -82,10 +82,10 @@ static void testTheEditorFloorIsDerivedFromTheDeckWidthBudget() {
|
||||
CHECK(kEditorMinWidth - 2 * kPad - kDeckSpanningW - kDeckGroupGap == kDeckRowBlockW);
|
||||
}
|
||||
|
||||
// Both rows now fit their block, in BOTH play modes. Row 1's fit is the one this track closes:
|
||||
// it was 1030, +42 from PITCH/RATE's third cell and −92 from FILTER's Band|Notch caption move
|
||||
// take it to 980. Row 2's 876 is mode-stable because FILTER ENV's and AMP's reserve slots hold
|
||||
// them at 312 in Trigger too — asserted here rather than assumed.
|
||||
// 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);
|
||||
@@ -101,9 +101,9 @@ static void testBothRowsAndTheSpanningDeckFitTheBudget() {
|
||||
const int spanning = static_cast<int>(DeckRow::Spanning);
|
||||
|
||||
CHECK(count[sound] == 4);
|
||||
CHECK(width[sound] == 980); // 192 + 432 + 192 + 164
|
||||
CHECK(width[sound] == 920); // 192 + 372 + 192 + 164
|
||||
CHECK(count[contour] == 3);
|
||||
CHECK(width[contour] == 876); // 252 + 312 + 312
|
||||
CHECK(width[contour] == 936); // 252 + 372 + 312
|
||||
CHECK(count[spanning] == 1);
|
||||
CHECK(width[spanning] == kDeckSpanningW); // 142 exactly — the reserve is now spent
|
||||
|
||||
@@ -115,38 +115,46 @@ static void testBothRowsAndTheSpanningDeckFitTheBudget() {
|
||||
}
|
||||
}
|
||||
|
||||
// The gutters the justification law produces at the floor, and the alignment they buy.
|
||||
// At the 1028 block the justification law makes the tie-line exact by arithmetic rather than
|
||||
// by a special rule: row 1's slack is 48 over three gutters (16 each, no residue) and row 2's
|
||||
// is 152 over two (76 each), which lands both filter edges on 640. Only two of the three
|
||||
// properties §1.3 once claimed can hold at once — a smallest gutter of exactly kDeckGroupGap
|
||||
// needs a 1016 block — and 12 is a floor, not a target, so 16 satisfies the real rule.
|
||||
static void testGutterArithmeticAndTheFilterTieLineAtTheFloor() {
|
||||
// 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 — 48 divides by 3
|
||||
// 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() == 16);
|
||||
CHECK(box(kGroupVelocity).x - box(kGroupFilter).right() == 16);
|
||||
CHECK(box(kGroupVoice).x - box(kGroupVelocity).right() == 16);
|
||||
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.
|
||||
// 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() == 76);
|
||||
CHECK(box(kGroupAmpEnv).x - box(kGroupFilterEnv).right() == 76);
|
||||
CHECK(box(kGroupFilterEnv).x - box(kGroupPitchEnv).right() == 46);
|
||||
CHECK(box(kGroupAmpEnv).x - box(kGroupFilterEnv).right() == 46);
|
||||
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());
|
||||
// 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);
|
||||
@@ -155,9 +163,9 @@ static void testGutterArithmeticAndTheFilterTieLineAtTheFloor() {
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
@@ -190,9 +198,8 @@ static void testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor() {
|
||||
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);
|
||||
// It really does open up, from the −70 the floor already carries.
|
||||
CHECK(lastDrift < -70);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,11 +275,74 @@ static void testTheMasterColumnDoesNotDivideItsRunVertically() {
|
||||
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.
|
||||
// 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))];
|
||||
@@ -323,11 +393,11 @@ static void testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo() {
|
||||
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
|
||||
// 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 testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp() {
|
||||
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))];
|
||||
@@ -335,26 +405,29 @@ static void testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp() {
|
||||
CHECK(deckGroupWidth(amp) == 312);
|
||||
|
||||
DeckGroupDesc penvProbe = penv;
|
||||
penvProbe.captionToggle2.segWidth = 47;
|
||||
penvProbe.captionToggle2.width = 122;
|
||||
CHECK(deckGroupWidth(penvProbe) == 252); // at the ceiling, still knob-row-driven
|
||||
penvProbe.captionToggle2.segWidth = 48;
|
||||
penvProbe.captionToggle2.width = 123;
|
||||
CHECK(deckGroupWidth(penvProbe) > 252); // one past it, the caption row takes over
|
||||
|
||||
DeckGroupDesc ampProbe = amp;
|
||||
ampProbe.captionToggle2.segWidth = 55;
|
||||
ampProbe.captionToggle2.width = 126;
|
||||
CHECK(deckGroupWidth(ampProbe) == 312);
|
||||
ampProbe.captionToggle2.segWidth = 56;
|
||||
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.
|
||||
// reserve slots hold the two mode-dependent groups at 372 (FILTER ENV) and 312 (AMP ENVELOPE)
|
||||
// either way, which is what makes the contour row's 936 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},
|
||||
{kGroupPitch, 192}, {kGroupPitchEnv, 252}, {kGroupFilter, 372},
|
||||
{kGroupFilterEnv, 372}, {kGroupAmpEnv, 312}, {kGroupVelocity, 192},
|
||||
{kGroupVoice, 164}, {kGroupMaster, 142},
|
||||
};
|
||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||
@@ -370,29 +443,77 @@ static void testEveryGroupWidthMatchesTheMeasuredLayout() {
|
||||
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.
|
||||
// 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);
|
||||
if (mode == PlayMode::Gate) CHECK(c.cell.width == kDeckCellW);
|
||||
}
|
||||
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
|
||||
{kGroupMaster, 1040, 142, 1}, // unchanged — the claim above actually pins it
|
||||
};
|
||||
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();
|
||||
testGutterArithmeticAndTheFilterTieLineAtTheFloor();
|
||||
testGutterArithmeticAndTheLostFilterTieLineAtTheFloor();
|
||||
testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor();
|
||||
testTheMasterDeckInteriorLandsOnBothRowBaselines();
|
||||
testTheMasterColumnDoesNotDivideItsRunVertically();
|
||||
testTheLimiterButtonIsAtMostSixtyFourPxBeforeMasterGrows();
|
||||
testNoShippedToggleReachesADrawnKnobFace();
|
||||
testTheEditorWidthBudgetIsStillUnspent();
|
||||
testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow();
|
||||
testTheModeTogglesCostNoGroupWidth();
|
||||
testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo();
|
||||
testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp();
|
||||
testEnvModeCeilingsArePinnedForPitchEnvAndAmp();
|
||||
testEveryGroupWidthMatchesTheMeasuredLayout();
|
||||
if (g_fail == 0) std::printf("deck_groups_measured: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
|
||||
@@ -120,47 +120,58 @@ static void testOnlyALiveControlsDragTakesTheLiveTier() {
|
||||
LiveCommit::Reload);
|
||||
}
|
||||
|
||||
// --- The overlay selection state machine ---------------------------------------
|
||||
// --- The overlay focus state machine -------------------------------------------
|
||||
|
||||
static int radio(DeckParam p) { return static_cast<int>(p); }
|
||||
|
||||
// EXCLUSIVITY: picking another deck's radio switches to it outright — two envelopes can never
|
||||
// be overlay-active at once, whatever the previous selection was.
|
||||
static void testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks() {
|
||||
const OverlayEnv states[] = {OverlayEnv::kNone, OverlayEnv::kAmp, OverlayEnv::kPitch,
|
||||
OverlayEnv::kFilter};
|
||||
for (OverlayEnv from : states) {
|
||||
if (from != OverlayEnv::kAmp) {
|
||||
CHECK(nextOverlaySelection(from, radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kAmp);
|
||||
}
|
||||
if (from != OverlayEnv::kPitch) {
|
||||
CHECK(nextOverlaySelection(from, radio(DeckParam::kPitchEnvSelect)) ==
|
||||
OverlayEnv::kPitch);
|
||||
}
|
||||
if (from != OverlayEnv::kFilter) {
|
||||
CHECK(nextOverlaySelection(from, radio(DeckParam::kFilterEnvSelect)) ==
|
||||
OverlayEnv::kFilter);
|
||||
// EXCLUSIVITY, and the whole of it: the focus is a function of the clicked GROUP alone, so
|
||||
// wherever it was before, clicking an envelope deck lands on that deck's envelope. Two
|
||||
// envelopes can never be overlay-active at once, and no previous state can change the answer.
|
||||
static void testOverlayFocusIsExclusiveAndIndependentOfThePreviousSelection() {
|
||||
const struct { int group; OverlayEnv env; } decks[] = {
|
||||
{kGroupAmpEnv, OverlayEnv::kAmp},
|
||||
{kGroupPitchEnv, OverlayEnv::kPitch},
|
||||
{kGroupFilterEnv, OverlayEnv::kFilter},
|
||||
};
|
||||
for (const auto& d : decks) CHECK(overlayEnvForGroup(d.group) == d.env);
|
||||
// Distinct answers, so no two decks can select the same overlay.
|
||||
CHECK(overlayEnvForGroup(kGroupAmpEnv) != overlayEnvForGroup(kGroupPitchEnv));
|
||||
CHECK(overlayEnvForGroup(kGroupPitchEnv) != overlayEnvForGroup(kGroupFilterEnv));
|
||||
CHECK(overlayEnvForGroup(kGroupAmpEnv) != overlayEnvForGroup(kGroupFilterEnv));
|
||||
}
|
||||
|
||||
// Focus SETS; it does not toggle. Driven as the SHELL drives it — `focus = f(group)` over a
|
||||
// click sequence starting from every prior focus — because that composition is the thing the
|
||||
// retired re-click-clears branch broke: a second click on the focused deck (which is every
|
||||
// knob tweak on it) landed back on kNone. The map taking no current focus is what makes that
|
||||
// unreachable; this pins the sequence a reader would otherwise have to reconstruct.
|
||||
static void testAClickSequenceOnOneDeckNeverLeavesIt() {
|
||||
for (OverlayEnv prior : {OverlayEnv::kNone, OverlayEnv::kAmp, OverlayEnv::kPitch,
|
||||
OverlayEnv::kFilter}) {
|
||||
OverlayEnv focus = prior;
|
||||
// Panel, then knob, then button — all three land in the same group, so all three are
|
||||
// the same assignment, whatever the click before them was.
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
focus = overlayEnvForGroup(kGroupFilterEnv);
|
||||
CHECK(focus == OverlayEnv::kFilter);
|
||||
}
|
||||
// And leaving is a click ELSEWHERE, never a repeat of the one that got here.
|
||||
focus = overlayEnvForGroup(kGroupVoice);
|
||||
CHECK(focus == OverlayEnv::kNone);
|
||||
}
|
||||
}
|
||||
|
||||
// kNone is a RESTING STATE the user can get back to: clicking the active radio clears it.
|
||||
static void testClickingTheActiveOverlayRadioClearsToNone() {
|
||||
CHECK(nextOverlaySelection(OverlayEnv::kAmp, radio(DeckParam::kAmpEnvSelect)) ==
|
||||
OverlayEnv::kNone);
|
||||
CHECK(nextOverlaySelection(OverlayEnv::kPitch, radio(DeckParam::kPitchEnvSelect)) ==
|
||||
OverlayEnv::kNone);
|
||||
CHECK(nextOverlaySelection(OverlayEnv::kFilter, radio(DeckParam::kFilterEnvSelect)) ==
|
||||
OverlayEnv::kNone);
|
||||
}
|
||||
|
||||
// A control that is not one of the three radios selects nothing and clears nothing.
|
||||
static void testANonRadioIdLeavesTheOverlaySelectionAlone() {
|
||||
CHECK(overlayEnvForRadio(radio(DeckParam::kFilterCutoff)) == OverlayEnv::kNone);
|
||||
CHECK(overlayEnvForRadio(-1) == OverlayEnv::kNone);
|
||||
CHECK(nextOverlaySelection(OverlayEnv::kFilter, radio(DeckParam::kFilterCutoff)) ==
|
||||
OverlayEnv::kFilter);
|
||||
CHECK(nextOverlaySelection(OverlayEnv::kAmp, 9999) == OverlayEnv::kAmp);
|
||||
// kNone is still a reachable resting state — reached by clicking a control surface OUTSIDE the
|
||||
// envelope decks rather than by clicking the active one again.
|
||||
static void testClickingAnyNonEnvelopeDeckClearsTheFocus() {
|
||||
for (int id : {kGroupPitch, kGroupFilter, kGroupVelocity, kGroupVoice, kGroupMaster}) {
|
||||
CHECK(overlayEnvForGroup(id) == OverlayEnv::kNone);
|
||||
}
|
||||
CHECK(overlayEnvForGroup(-1) == OverlayEnv::kNone); // off the deck entirely
|
||||
// A CONTROL id is not a group id: the map keys on groups now, and a stray control id must
|
||||
// never light an overlay by numeric coincidence.
|
||||
CHECK(overlayEnvForGroup(radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kNone);
|
||||
CHECK(overlayEnvForGroup(radio(DeckParam::kFilterCutoff)) == OverlayEnv::kNone);
|
||||
}
|
||||
|
||||
// The two group gates, spelled the way the predicates read them. Spline flags default off, so
|
||||
@@ -215,25 +226,22 @@ static void testDeckKnobIsInertExactlyWithItsGroupsEnableToggle() {
|
||||
// A drawn envelope's STAGED segment knobs go inert; the mode toggle itself and the depth knobs
|
||||
// that scale either shape stay live. (Which segment knobs, per envelope, is pinned in
|
||||
// spline_egs_tests alongside the rest of the spline rules.)
|
||||
static void testAModeToggleIsNeitherLiveNorAnOverlayRadio() {
|
||||
static void testAModeToggleIsNotALiveControl() {
|
||||
CHECK(deckParamCommit(DeckParam::kAmpEnvMode) == LiveCommit::Reload);
|
||||
CHECK(deckParamCommit(DeckParam::kPitchEnvMode) == LiveCommit::Reload);
|
||||
CHECK(deckParamCommit(DeckParam::kFilterEnvMode) == LiveCommit::Reload);
|
||||
CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kAmp);
|
||||
CHECK(overlayEnvForModeToggle(radio(DeckParam::kPitchEnvMode)) == OverlayEnv::kPitch);
|
||||
CHECK(overlayEnvForModeToggle(radio(DeckParam::kFilterEnvMode)) == OverlayEnv::kFilter);
|
||||
// A mode toggle must not be mistaken for the overlay-select radio beside it.
|
||||
CHECK(overlayEnvForRadio(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kNone);
|
||||
CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kNone);
|
||||
// It needs no overlay map of its own: the toggle sits INSIDE its envelope's deck, so the
|
||||
// click that flips it already focuses that envelope through the group map.
|
||||
CHECK(overlayEnvForGroup(kGroupAmpEnv) == OverlayEnv::kAmp);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks();
|
||||
testClickingTheActiveOverlayRadioClearsToNone();
|
||||
testANonRadioIdLeavesTheOverlaySelectionAlone();
|
||||
testOverlayFocusIsExclusiveAndIndependentOfThePreviousSelection();
|
||||
testAClickSequenceOnOneDeckNeverLeavesIt();
|
||||
testClickingAnyNonEnvelopeDeckClearsTheFocus();
|
||||
testOverlayIsInertExactlyWhenItsGroupToggleIsOff();
|
||||
testDeckKnobIsInertExactlyWithItsGroupsEnableToggle();
|
||||
testAModeToggleIsNeitherLiveNorAnOverlayRadio();
|
||||
testAModeToggleIsNotALiveControl();
|
||||
testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers();
|
||||
testOnlyALiveControlsDragTakesTheLiveTier();
|
||||
if (g_fail == 0) std::printf("deck_groups_state: all tests passed\n");
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "../src/core/instrument/ui/deck_values.h"
|
||||
|
||||
#include "../src/core/instrument/engine/master_gain.h"
|
||||
#include "../src/core/instrument/ui/deck_groups.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
@@ -400,8 +401,84 @@ static void testTheFilterFourKeepTheirIdentityTaper() {
|
||||
}
|
||||
}
|
||||
|
||||
// The single-button commit seam. A one-button toggle carries no segment, so the commit derives
|
||||
// the NEXT state from the parameter set and hands it to setDeckParam's unchanged segment
|
||||
// contract. Driven end-to-end — derive, apply, re-derive — because the property that matters is
|
||||
// that repeated clicks alternate the stored field rather than latching it.
|
||||
static void testASingleButtonsDerivedSegmentFlipsTheFieldItNames() {
|
||||
PlaySeconds p;
|
||||
// Enables: off by default, so the first derived segment must be ON.
|
||||
CHECK(!p.pitchEnv.enabled);
|
||||
CHECK(nextToggleSegment(DeckParam::kPitchEnvEnable, p) == 1);
|
||||
setDeckParam(DeckParam::kPitchEnvEnable, p, 0.0,
|
||||
nextToggleSegment(DeckParam::kPitchEnvEnable, p));
|
||||
CHECK(p.pitchEnv.enabled);
|
||||
CHECK(nextToggleSegment(DeckParam::kPitchEnvEnable, p) == 0);
|
||||
setDeckParam(DeckParam::kPitchEnvEnable, p, 0.0,
|
||||
nextToggleSegment(DeckParam::kPitchEnvEnable, p));
|
||||
CHECK(!p.pitchEnv.enabled);
|
||||
|
||||
CHECK(!p.filter.enabled);
|
||||
CHECK(nextToggleSegment(DeckParam::kFilterEnable, p) == 1);
|
||||
setDeckParam(DeckParam::kFilterEnable, p, 0.0,
|
||||
nextToggleSegment(DeckParam::kFilterEnable, p));
|
||||
CHECK(p.filter.enabled);
|
||||
|
||||
// Mode selectors: Staged by default, so the first derived segment is Spline. Flipping the
|
||||
// amp to Spline also forces Trigger (the drawn-EG rule), which is setDeckParam's own job
|
||||
// and must survive the derived segment reaching it unchanged.
|
||||
CHECK(p.ampSpline.mode == EnvMode::Staged);
|
||||
CHECK(nextToggleSegment(DeckParam::kAmpEnvMode, p) == 1);
|
||||
setDeckParam(DeckParam::kAmpEnvMode, p, 0.0, nextToggleSegment(DeckParam::kAmpEnvMode, p));
|
||||
CHECK(p.ampSpline.mode == EnvMode::Spline);
|
||||
CHECK(p.playMode == PlayMode::Trigger);
|
||||
CHECK(nextToggleSegment(DeckParam::kAmpEnvMode, p) == 0);
|
||||
setDeckParam(DeckParam::kAmpEnvMode, p, 0.0, nextToggleSegment(DeckParam::kAmpEnvMode, p));
|
||||
CHECK(p.ampSpline.mode == EnvMode::Staged);
|
||||
|
||||
for (DeckParam id : {DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode}) {
|
||||
setDeckParam(id, p, 0.0, nextToggleSegment(id, p));
|
||||
}
|
||||
CHECK(p.pitchSpline.mode == EnvMode::Spline);
|
||||
CHECK(p.filterSpline.mode == EnvMode::Spline);
|
||||
|
||||
// Every control that still carries its own segment answers "not mine", so the shell can
|
||||
// tell the two commit paths apart on the answer alone.
|
||||
for (DeckParam id : {DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kFilterLaw,
|
||||
DeckParam::kVoiceMode, DeckParam::kMonoTrigger,
|
||||
DeckParam::kFilterCutoff, DeckParam::kCount}) {
|
||||
CHECK(nextToggleSegment(id, p) == -1);
|
||||
}
|
||||
}
|
||||
|
||||
// The cross-check the hand-maintained list above cannot catch: a control RE-STYLED to a
|
||||
// single button (kEnable/kMode) with no nextToggleSegment entry silently commits segment -1,
|
||||
// which setDeckParam reads as "off" — a latch, not a toggle. Swept over every group
|
||||
// sampleDeckGroups actually ships, in both play modes, rather than a fixed id list, so a
|
||||
// future re-style is caught the moment it lands here with no entry above. kLimiterEnable is
|
||||
// the one shipped kEnable that is excluded: it lives on `InstrumentParams::limiterEnabled`,
|
||||
// outside `PlaySeconds`, and commits through its own handler (editor_input_deck.cpp) rather
|
||||
// than through nextToggleSegment/setDeckParam at all.
|
||||
static void testEveryShippedSingleButtonToggleHasADerivedSegment() {
|
||||
const PlaySeconds p;
|
||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||
for (const DeckGroupDesc& g : sampleDeckGroups(mode)) {
|
||||
for (const DeckToggleDesc* t : {&g.captionToggle, &g.captionToggle2, &g.rowToggle}) {
|
||||
if (t->id < 0) continue;
|
||||
if (t->style != DeckToggleStyle::kEnable && t->style != DeckToggleStyle::kMode)
|
||||
continue;
|
||||
const DeckParam id = static_cast<DeckParam>(t->id);
|
||||
if (id == DeckParam::kLimiterEnable) continue;
|
||||
CHECK(nextToggleSegment(id, p) != -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testTheTwoCeilingNamesAreOneNumber();
|
||||
testASingleButtonsDerivedSegmentFlipsTheFieldItNames();
|
||||
testEveryShippedSingleButtonToggleHasADerivedSegment();
|
||||
testNormRoundTripsThroughEveryValueDomain();
|
||||
testRateKnobEndsAreTheStretchersOwnBounds();
|
||||
testRateAndPitchBindTheirOwnFields();
|
||||
|
||||
+159
-90
@@ -4,11 +4,13 @@
|
||||
// * group width — caption row vs knob row max + padding; row-toggle and caption-toggle widths.
|
||||
// * 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.
|
||||
// * reserves — a -1 id holds the group's width and pays for it in the two end margins, with
|
||||
// the run of present cells centred at their natural width.
|
||||
// * 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.
|
||||
// * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, the single-button
|
||||
// styles' no-segment answer, the group id every hit carries, fence padding misses,
|
||||
// outside-deck misses.
|
||||
// * knob-FACE hit-test — the reset resolve against the drawn circles: inner disc, outer ring,
|
||||
// both exclusive boundaries, and the points where it deliberately disagrees with the cell.
|
||||
|
||||
@@ -30,25 +32,30 @@ static int g_fail = 0;
|
||||
// toggle + row toggle), MASTER (1 cell, no toggle).
|
||||
static std::vector<DeckGroupDesc> shellLikeDeck() {
|
||||
std::vector<DeckGroupDesc> g;
|
||||
g.push_back({0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}});
|
||||
g.push_back({1, 38, {}, {101, 48}, {}, {6}, {}});
|
||||
g.push_back({2, 58, {}, {102, 32}, {}, {7, 8, 9}, {}});
|
||||
g.push_back({3, 38, {}, {103, 40}, {}, {10}, {104, 44}});
|
||||
g.push_back({0, 78, {}, {100, 88}, {}, {1, 2, 3, 4, 5}, {}});
|
||||
g.push_back({1, 38, {}, {101, 96}, {}, {6}, {}});
|
||||
g.push_back({2, 58, {}, {102, 64}, {}, {7, 8, 9}, {}});
|
||||
g.push_back({3, 38, {}, {103, 80}, {}, {10}, {104, 88}});
|
||||
g.push_back({4, 46, {}, {}, {}, {11}, {}});
|
||||
return g;
|
||||
}
|
||||
|
||||
static void testGroupWidth() {
|
||||
// Knob row dominates: 5 cells (240) > caption row (78 + 4 + 88 = 170) -> 240 + 2*6.
|
||||
DeckGroupDesc amp{0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}};
|
||||
// Knob row dominates: 5 cells (300) > caption row (78 + 4 + 88 = 170) -> 300 + 2*6.
|
||||
DeckGroupDesc amp{0, 78, {}, {100, 88}, {}, {1, 2, 3, 4, 5}, {}};
|
||||
CHECK(deckGroupWidth(amp) == 5 * kDeckCellW + 2 * kDeckGroupPadX);
|
||||
// Caption row dominates: 38 + 4 + 96 = 138 > 48 -> 138 + 12.
|
||||
DeckGroupDesc pitch{1, 38, {}, {101, 48}, {}, {6}, {}};
|
||||
CHECK(deckGroupWidth(pitch) == 38 + kDeckToggleGap + 2 * 48 + 2 * kDeckGroupPadX);
|
||||
// Row toggle counts into the knob row: 48 + 4 + 88 = 140 > caption 38+4+80=122.
|
||||
DeckGroupDesc voice{3, 38, {}, {103, 40}, {}, {10}, {104, 44}};
|
||||
// Caption row dominates: 38 + 4 + 96 = 138 > 60 -> 138 + 12.
|
||||
DeckGroupDesc pitch{1, 38, {}, {101, 96}, {}, {6}, {}};
|
||||
CHECK(deckGroupWidth(pitch) == 38 + kDeckToggleGap + 96 + 2 * kDeckGroupPadX);
|
||||
// Row toggle counts into the knob row: 60 + 4 + 88 = 152 > caption 38+4+80=122.
|
||||
DeckGroupDesc voice{3, 38, {}, {103, 80}, {}, {10}, {104, 88}};
|
||||
CHECK(deckGroupWidth(voice) ==
|
||||
kDeckCellW + kDeckToggleGap + 2 * 44 + 2 * kDeckGroupPadX);
|
||||
kDeckCellW + kDeckToggleGap + 88 + 2 * kDeckGroupPadX);
|
||||
// A toggle's `width` is the WHOLE control either way, so a single button and a segmented
|
||||
// one of the same declared width cost the group exactly the same.
|
||||
DeckGroupDesc single = voice;
|
||||
single.captionToggle.style = DeckToggleStyle::kEnable;
|
||||
CHECK(deckGroupWidth(single) == deckGroupWidth(voice));
|
||||
// No toggles: max(caption, cells) + padding.
|
||||
DeckGroupDesc master{4, 46, {}, {}, {}, {11}, {}};
|
||||
CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX);
|
||||
@@ -64,11 +71,11 @@ static void testGroupWidth() {
|
||||
// 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}, {},
|
||||
g.push_back({0, 78, {}, {100, 88}, {}, {1, 2, 3, 4, 5}, {}, DeckRow::Sound, {}});
|
||||
g.push_back({1, 38, {}, {101, 96}, {}, {6, 7}, {}, DeckRow::Sound, {}});
|
||||
g.push_back({2, 58, {}, {102, 64}, {}, {8, 9, 10}, {}, DeckRow::Contour, {}});
|
||||
g.push_back({3, 38, {}, {103, 80}, {}, {11}, {}, DeckRow::Contour, {}});
|
||||
g.push_back({4, 46, {200, true}, {104, 64}, {}, {12, -1}, {},
|
||||
DeckRow::Spanning, {300, 62}});
|
||||
return g;
|
||||
}
|
||||
@@ -291,41 +298,101 @@ static void testHitTest() {
|
||||
h = hitTestDeck(dl, voice.rowToggle.seg1.x + 1, voice.rowToggle.seg1.y + 1);
|
||||
CHECK(h.kind == DeckHitKind::RowToggle && h.id == 104 && h.segment == 1);
|
||||
|
||||
// A reserve (id -1) yields no cell of its own. This fixture's reserve divides its present
|
||||
// cells evenly (5 slots / 3 present -> 240/3, no residue), so every point of the knob row
|
||||
// lands on a real control: no dead rect survives for a grab to fall into. That does NOT
|
||||
// generalize to an indivisible reserve — a residue leaves a few uncovered margin pixels by
|
||||
// design (testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds, below).
|
||||
// A reserve (id -1) yields no cell of its own, and the cells present cover their CENTRED
|
||||
// run contiguously — no dead rect between them for a grab to fall into. What the reserve
|
||||
// buys is margin at the two ends, which is a deliberate miss and pinned as one below.
|
||||
std::vector<DeckGroupDesc> trig;
|
||||
trig.push_back({0, 78, {}, {100, 44}, {}, {20, 21, 22, -1, -1}, {}});
|
||||
trig.push_back({0, 78, {}, {100, 88}, {}, {20, 21, 22, -1, -1}, {}});
|
||||
const DeckLayout tl = layoutDeck(trig, 0, 0, 824);
|
||||
const DeckGroupLayout& tg = tl.groups[0];
|
||||
CHECK(tg.cells.size() == 3);
|
||||
for (const DeckCellLayout& c : tg.cells) CHECK(c.id >= 0);
|
||||
// Bound the sweep against the RESERVED run (5 slots, not the 3 present cells) rather than
|
||||
// the cells' own extent — the cells are what's under test, so deriving the bound from them
|
||||
// could never catch a layout that under-covers the run they were reserved out of.
|
||||
const int runStart = tg.box.x + kDeckGroupPadX;
|
||||
const int runEnd = runStart + static_cast<int>(trig[0].cellIds.size()) * kDeckCellW;
|
||||
const int rowY = tg.cells.back().cell.y + 5;
|
||||
for (int px = runStart; px < runEnd; ++px) {
|
||||
for (int px = tg.cells.front().cell.x; px < tg.cells.back().cell.right(); ++px) {
|
||||
const DeckHit rowHit = hitTestDeck(tl, px, rowY);
|
||||
CHECK(rowHit.kind == DeckHitKind::Knob && rowHit.id >= 0);
|
||||
}
|
||||
// The reserve's own pixels answer no control — but they still name the group, which is
|
||||
// what makes the deck panel's background a target for the overlay focus.
|
||||
const DeckHit margin = hitTestDeck(tl, tg.box.x + kDeckGroupPadX + 1, rowY);
|
||||
CHECK(margin.kind == DeckHitKind::None && margin.group == 0);
|
||||
|
||||
// The fence padding inside the box misses; outside the deck misses.
|
||||
// The fence padding inside the box misses as a control and names its group; outside the
|
||||
// deck misses entirely, group included.
|
||||
h = hitTestDeck(dl, amp.box.x + 1, amp.box.bottom() - 1);
|
||||
CHECK(h.kind == DeckHitKind::None);
|
||||
CHECK(h.kind == DeckHitKind::None && h.id == -1 && h.group == 0);
|
||||
h = hitTestDeck(dl, -50, -50);
|
||||
CHECK(h.kind == DeckHitKind::None);
|
||||
CHECK(h.kind == DeckHitKind::None && h.group == -1);
|
||||
|
||||
// Every hit kind carries the group it landed in, so the shell never has to re-scan the
|
||||
// layout to find out which deck a click belongs to.
|
||||
CHECK(hitTestDeck(dl, c0.cell.x + 1, c0.cell.y + 1).group == 0);
|
||||
CHECK(hitTestDeck(dl, amp.captionToggle.seg1.x, amp.captionToggle.seg1.y + 1).group == 0);
|
||||
CHECK(hitTestDeck(dl, voice.rowToggle.seg1.x + 1, voice.rowToggle.seg1.y + 1).group == 3);
|
||||
}
|
||||
|
||||
// A reserve holds the group's WIDTH and hands its pixels to the cells that are present. The
|
||||
// three properties together are what stops a narrower face reading as a hole: the group is
|
||||
// exactly as wide as the full-face one, the cells are uniform and abutting, and what they do
|
||||
// not cover is smaller than one pixel per cell.
|
||||
static void testReservedCellWidthGoesToTheCellsPresent() {
|
||||
const DeckGroupDesc full{0, 78, {}, {100, 44}, {}, {20, 21, 22, 23, 24}, {}};
|
||||
// A single-button toggle takes the WHOLE declared width in seg0, leaves seg1 empty, and — the
|
||||
// property the commit seam rests on — answers with NO segment, so a caller cannot mistake it
|
||||
// for the left half of a two-segment control.
|
||||
static void testSingleButtonToggleTakesTheWholeSlotAndCarriesNoSegment() {
|
||||
for (DeckToggleStyle style : {DeckToggleStyle::kEnable, DeckToggleStyle::kMode}) {
|
||||
DeckGroupDesc g{0, 40, {}, {200, 52, style}, {}, {1, 2, 3}, {}};
|
||||
std::vector<DeckGroupDesc> gs{g};
|
||||
const DeckLayout dl = layoutDeck(gs, 0, 0, 400);
|
||||
const DeckToggleLayout& t = dl.groups[0].captionToggle;
|
||||
CHECK(t.id == 200);
|
||||
CHECK(t.style == style);
|
||||
CHECK(t.seg0.width == 52);
|
||||
CHECK(t.seg1.empty());
|
||||
// Right-anchored in the caption row exactly as a segmented toggle is.
|
||||
CHECK(t.seg0.right() == dl.groups[0].box.right() - kDeckGroupPadX);
|
||||
CHECK(t.seg0.height == kDeckToggleH);
|
||||
|
||||
// Both ends of the button answer the same hit, with segment -1.
|
||||
for (int px : {t.seg0.x, t.seg0.x + 26, t.seg0.right() - 1}) {
|
||||
const DeckHit h = hitTestDeck(dl, px, t.seg0.y + 1);
|
||||
CHECK(h.kind == DeckHitKind::CaptionToggle);
|
||||
CHECK(h.id == 200 && h.segment == -1 && h.group == 0);
|
||||
}
|
||||
// Where the right half of a segmented toggle would have been is now the same button,
|
||||
// not segment 1 — the regression this style exists to make impossible.
|
||||
CHECK(hitTestDeck(dl, t.seg0.right() - 1, t.seg0.y + 1).segment != 1);
|
||||
}
|
||||
}
|
||||
|
||||
// The caption toggles sit in the caption row and the knob circles in the cell row, so no
|
||||
// button rect can overlap a dial. hitTestKnobFace runs NO toggle-precedence pass, and this is
|
||||
// the property that lets it get away with that — re-checked here because the single-button
|
||||
// styles made every one of those rects wider.
|
||||
static void testNoToggleRectOverlapsAKnobCircle() {
|
||||
DeckGroupDesc g{0, 40, {}, {200, 96, DeckToggleStyle::kEnable},
|
||||
{201, 96, DeckToggleStyle::kMode}, {1, 2, 3}, {202, 96}};
|
||||
std::vector<DeckGroupDesc> gs{g};
|
||||
const DeckLayout dl = layoutDeck(gs, 0, 0, 600);
|
||||
const DeckGroupLayout& lay = dl.groups[0];
|
||||
const DeckToggleLayout* toggles[] = {&lay.captionToggle, &lay.captionToggle2,
|
||||
&lay.rowToggle};
|
||||
for (const DeckToggleLayout* t : toggles) {
|
||||
for (const Rect& seg : {t->seg0, t->seg1}) {
|
||||
if (seg.empty()) continue;
|
||||
for (const DeckCellLayout& c : lay.cells) {
|
||||
// Sweep the segment's own pixels: none of them may land on a drawn dial.
|
||||
for (int px = seg.x; px < seg.right(); ++px) {
|
||||
for (int py = seg.y; py < seg.bottom(); ++py) {
|
||||
CHECK(!inKnobFace(c.knob, px, py));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A reserve holds the group's WIDTH and gives its pixels to the two END MARGINS, never to the
|
||||
// cells: every cell keeps kDeckCellW whatever face the group is showing, and the run of them is
|
||||
// centred. That is the whole spacing law — a reduced face is the same knobs at the same pitch,
|
||||
// sitting in the middle of a box that did not move.
|
||||
static void testAReserveCentresTheRunAndNeverWidensACell() {
|
||||
const DeckGroupDesc full{0, 78, {}, {100, 88}, {}, {20, 21, 22, 23, 24}, {}};
|
||||
// Three, four, and a lone cell against the same five-slot reserve.
|
||||
const std::vector<std::vector<int>> faces = {
|
||||
{20, 21, 22, -1, -1}, {20, 21, 22, 23, -1}, {20, -1, -1, -1, -1}};
|
||||
@@ -340,30 +407,42 @@ static void testReservedCellWidthGoesToTheCellsPresent() {
|
||||
const int present = static_cast<int>(lay.cells.size());
|
||||
CHECK(present == 5 - static_cast<int>(std::count(ids.begin(), ids.end(), -1)));
|
||||
|
||||
const int run = 5 * kDeckCellW;
|
||||
for (int i = 0; i < present; ++i) {
|
||||
const DeckCellLayout& c = lay.cells[static_cast<std::size_t>(i)];
|
||||
CHECK(c.cell.width == lay.cells[0].cell.width); // uniform
|
||||
CHECK(c.knob.width == kDeckKnobSize); // the dial itself is fixed
|
||||
// Centred as exactly as integers allow: a cell whose spare width is odd cannot
|
||||
// split it evenly, and the layout's integer division gives the odd pixel to the
|
||||
// RIGHT margin. Pinned as a directional identity rather than a tolerance, so a
|
||||
// future off-by-one on the other side would still fail here.
|
||||
const int leftGap = c.knob.x - c.cell.x;
|
||||
const int rightGap = c.cell.right() - c.knob.right();
|
||||
CHECK(rightGap - leftGap == (c.cell.width - kDeckKnobSize) % 2);
|
||||
CHECK(c.cell.width == kDeckCellW); // natural pitch, never the divided run
|
||||
CHECK(c.knob.width == kDeckKnobSize);
|
||||
// The dial sits centred in its cell — 60 and 40 are both even, so exactly so.
|
||||
CHECK(c.knob.x - c.cell.x == c.cell.right() - c.knob.right());
|
||||
if (i > 0) CHECK(c.cell.x == lay.cells[static_cast<std::size_t>(i - 1)].cell.right());
|
||||
}
|
||||
// Uncovered run is the indivisible residue only, split evenly at the two ends.
|
||||
const int covered = lay.cells.back().cell.right() - lay.cells[0].cell.x;
|
||||
CHECK(run - covered < present);
|
||||
const int leadPad = lay.cells[0].cell.x - (lay.box.x + kDeckGroupPadX);
|
||||
CHECK(leadPad == (run - covered) / 2);
|
||||
// What the run does not cover is the reserve, split evenly at the two ends. The
|
||||
// reserve is a whole number of 60px cells, so the split is exact — never off by one.
|
||||
const int leadPad = lay.cells.front().cell.x - (lay.box.x + kDeckGroupPadX);
|
||||
const int trailPad = (lay.box.right() - kDeckGroupPadX) - lay.cells.back().cell.right();
|
||||
CHECK(leadPad == trailPad);
|
||||
CHECK(leadPad + trailPad == (5 - present) * kDeckCellW);
|
||||
}
|
||||
|
||||
// Only the reserve COUNT matters, not where a -1 sits: with the run centred, three faces
|
||||
// that reserve two slots in three different places lay out identically.
|
||||
const std::vector<std::vector<int>> sameCount = {
|
||||
{20, 21, 22, -1, -1}, {-1, 20, 21, -1, 22}, {-1, -1, 20, 21, 22}};
|
||||
std::vector<Rect> firstRun;
|
||||
for (const std::vector<int>& ids : sameCount) {
|
||||
DeckGroupDesc d = full;
|
||||
d.cellIds = ids;
|
||||
std::vector<DeckGroupDesc> g{d};
|
||||
const DeckLayout dl = layoutDeck(g, 0, 0, 824);
|
||||
std::vector<Rect> cells;
|
||||
for (const DeckCellLayout& c : dl.groups[0].cells) cells.push_back(c.cell);
|
||||
CHECK(cells.size() == 3);
|
||||
if (firstRun.empty()) firstRun = cells;
|
||||
else CHECK(cells == firstRun);
|
||||
}
|
||||
|
||||
// A reserve does not move the row toggle: it anchors past the whole run, so the FILTER
|
||||
// group's law switch cannot drift when a neighbouring face changes shape.
|
||||
DeckGroupDesc withToggle{1, 40, {}, {}, {}, {20, 21, 22, 23, 24}, {104, 44}};
|
||||
DeckGroupDesc withToggle{1, 40, {}, {}, {}, {20, 21, 22, 23, 24}, {104, 88}};
|
||||
std::vector<DeckGroupDesc> a{withToggle};
|
||||
withToggle.cellIds = {20, 21, -1, -1, -1};
|
||||
std::vector<DeckGroupDesc> b{withToggle};
|
||||
@@ -371,35 +450,23 @@ static void testReservedCellWidthGoesToTheCellsPresent() {
|
||||
layoutDeck(b, 0, 0, 824).groups[0].rowToggle.seg0);
|
||||
}
|
||||
|
||||
// The three faces above all divide their run evenly, so none of them actually exercises
|
||||
// "residue in symmetric end margins". An 8-slot reserve with 7 present (480/7 = 68 r4) does:
|
||||
// residue 4 is the smallest case that can tell a symmetric split (2/2) apart from a
|
||||
// trailing-only one (0/4) — a residue of 1 can't, since leadPad = residue/2 rounds to 0 either
|
||||
// way, which is exactly why this seam's earlier test passed without pinning the rule it was
|
||||
// named for.
|
||||
static void testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds() {
|
||||
const DeckGroupDesc g{0, 78, {}, {100, 44}, {}, {20, 21, 22, 23, 24, 25, 26, -1}, {}};
|
||||
std::vector<DeckGroupDesc> gs{g};
|
||||
const DeckLayout dl = layoutDeck(gs, 0, 0, 824);
|
||||
const DeckGroupLayout& lay = dl.groups[0];
|
||||
CHECK(lay.cells.size() == 7);
|
||||
|
||||
const int run = 8 * kDeckCellW;
|
||||
const int present = 7;
|
||||
const int cellW = run / present; // 76: the same integer division the layout uses
|
||||
const int expectedResidue = run - cellW * present; // 4
|
||||
CHECK(expectedResidue == 4);
|
||||
|
||||
const int covered = lay.cells.back().cell.right() - lay.cells.front().cell.x;
|
||||
CHECK(run - covered == expectedResidue);
|
||||
const int leadPad = lay.cells.front().cell.x - (lay.box.x + kDeckGroupPadX);
|
||||
const int trailPad = (lay.box.right() - kDeckGroupPadX) - lay.cells.back().cell.right();
|
||||
// Hard literals, not just the formula: this is the case that actually distinguishes
|
||||
// symmetric (2/2) from trailing-only (0/4) — see the comment above.
|
||||
CHECK(leadPad == 2);
|
||||
CHECK(trailPad == 2);
|
||||
CHECK(leadPad == expectedResidue / 2);
|
||||
CHECK(trailPad == expectedResidue - leadPad); // both ends share it, not one absorbing it
|
||||
// A face with NO reserve is untouched by the centring — the offset is zero by construction, so
|
||||
// the run starts flush against the group's inner padding exactly as it always did. This is what
|
||||
// makes "the Gate deck face is pixel-identical" a structural claim rather than an observation.
|
||||
static void testAFaceWithNoReserveStartsFlushAgainstThePadding() {
|
||||
for (int slots = 1; slots <= 8; ++slots) {
|
||||
DeckGroupDesc g{0, 78, {}, {100, 88}, {}, {}, {}};
|
||||
for (int i = 0; i < slots; ++i) g.cellIds.push_back(20 + i);
|
||||
std::vector<DeckGroupDesc> gs{g};
|
||||
const DeckLayout dl = layoutDeck(gs, 0, 0, 824);
|
||||
const DeckGroupLayout& lay = dl.groups[0];
|
||||
CHECK(static_cast<int>(lay.cells.size()) == slots);
|
||||
CHECK(lay.cells.front().cell.x == lay.box.x + kDeckGroupPadX);
|
||||
// The run covers the whole reserve exactly — no lead margin to absorb, none to leave.
|
||||
// (Not "flush right": a caption-row-bound group's box is wider than its knob row.)
|
||||
CHECK(lay.cells.back().cell.right() - lay.cells.front().cell.x == slots * kDeckCellW);
|
||||
for (const DeckCellLayout& c : lay.cells) CHECK(c.cell.width == kDeckCellW);
|
||||
}
|
||||
}
|
||||
|
||||
// The corner radio widens the caption row, takes the far corner, and pushes the caption
|
||||
@@ -449,7 +516,7 @@ static void testInnerDialHit() {
|
||||
// the caption text stops before the LEFTMOST one), and takes captionToggle's own slot when
|
||||
// captionToggle is absent — the shipped FILTER ENV group's exact shape (deck_groups.cpp).
|
||||
static void testCaptionToggle2() {
|
||||
const DeckGroupDesc both{9, 40, {}, {300, 30}, {301, 20}, {1, 2, 3}, {}};
|
||||
const DeckGroupDesc both{9, 40, {}, {300, 60}, {301, 40}, {1, 2, 3}, {}};
|
||||
std::vector<DeckGroupDesc> g{both};
|
||||
const DeckLayout dl = layoutDeck(g, 0, 0, 800);
|
||||
const DeckGroupLayout& lay = dl.groups[0];
|
||||
@@ -469,7 +536,7 @@ static void testCaptionToggle2() {
|
||||
|
||||
// FILTER ENV's real shape: captionToggle absent, captionToggle2 present with a radio — it
|
||||
// takes the first (rightmost) slot rather than leaving a gap where captionToggle would sit.
|
||||
const DeckGroupDesc filterEnvLike{10, 66, {200}, {}, {302, 23}, {1, 2, 3, 4, 5}, {}};
|
||||
const DeckGroupDesc filterEnvLike{10, 66, {200}, {}, {302, 46}, {1, 2, 3, 4, 5}, {}};
|
||||
std::vector<DeckGroupDesc> g2{filterEnvLike};
|
||||
const DeckLayout dl2 = layoutDeck(g2, 0, 0, 800);
|
||||
const DeckGroupLayout& fe = dl2.groups[0];
|
||||
@@ -558,8 +625,10 @@ int main() {
|
||||
testSpanningOnlyDeckKeepsItsHeight();
|
||||
testGroupInnerGeometry();
|
||||
testHitTest();
|
||||
testReservedCellWidthGoesToTheCellsPresent();
|
||||
testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds();
|
||||
testSingleButtonToggleTakesTheWholeSlotAndCarriesNoSegment();
|
||||
testNoToggleRectOverlapsAKnobCircle();
|
||||
testAReserveCentresTheRunAndNeverWidensACell();
|
||||
testAFaceWithNoReserveStartsFlushAgainstThePadding();
|
||||
testCaptionRadioGeometryAndHit();
|
||||
testInnerDialHit();
|
||||
testKnobFaceResolvesInnerRingOuterRingAndMisses();
|
||||
|
||||
@@ -54,15 +54,14 @@ static void testRowsTileTheBandExactly() {
|
||||
static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() {
|
||||
const Rect band = chromeBand();
|
||||
const ChromeRects r = chromeRects(band, kKnob);
|
||||
// Rightmost first: Browse, stereo, mono, velocity cell, preview, title.
|
||||
// Rightmost first: Browse, channel, loop, velocity cell, preview, title.
|
||||
CHECK(r.navBrowse.right() == band.right() - kPad);
|
||||
CHECK(r.navBrowse.width == kNavButtonWidth);
|
||||
CHECK(r.chanStereo.right() <= r.navBrowse.x);
|
||||
CHECK(r.chanMono.right() == r.chanStereo.x);
|
||||
CHECK(r.loopOn.right() <= r.chanMono.x); // the enable is immediately left of Mono|Stereo
|
||||
CHECK(r.loopOff.right() == r.loopOn.x); // its two segments abut, like the channel pair
|
||||
CHECK(r.loopOff.y == r.chanMono.y && r.loopOff.height == r.chanMono.height);
|
||||
CHECK(r.velCell.right() <= r.loopOff.x);
|
||||
CHECK(r.channel.right() <= r.navBrowse.x);
|
||||
// Both are ONE button now, and they share the run's toggle baseline.
|
||||
CHECK(r.loop.right() <= r.channel.x);
|
||||
CHECK(r.loop.y == r.channel.y && r.loop.height == r.channel.height);
|
||||
CHECK(r.velCell.right() <= r.loop.x);
|
||||
CHECK(r.preview.right() <= r.velCell.x);
|
||||
CHECK(r.bake.right() <= r.preview.x);
|
||||
CHECK(r.bake.width == kBakeButtonWidth);
|
||||
@@ -74,8 +73,8 @@ static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() {
|
||||
CHECK(r.title.width > 0);
|
||||
|
||||
// Every toolbar rect sits inside the toolbar row.
|
||||
const Rect items[] = {r.title, r.holdCell, r.bake, r.preview, r.velCell, r.loopOff,
|
||||
r.loopOn, r.chanMono, r.chanStereo, r.navBrowse};
|
||||
const Rect items[] = {r.title, r.holdCell, r.bake, r.preview, r.velCell, r.loop,
|
||||
r.channel, r.navBrowse};
|
||||
for (const Rect& it : items) {
|
||||
CHECK(it.y >= r.toolbar.y && it.bottom() <= r.toolbar.bottom());
|
||||
}
|
||||
@@ -88,8 +87,8 @@ static void testChromePartsNeverOverlapAtAnyWidth() {
|
||||
// stay inside its own row, clear of every control.
|
||||
CHECK(!overlaps(r.toolbar, r.rootStrip));
|
||||
CHECK(r.rootStrip.y >= r.controls.y && r.rootStrip.bottom() <= r.controls.bottom());
|
||||
const Rect items[] = {r.holdCell, r.bake, r.preview, r.velCell, r.loopOff, r.loopOn,
|
||||
r.chanMono, r.chanStereo, r.navBrowse};
|
||||
const Rect items[] = {r.holdCell, r.bake, r.preview, r.velCell, r.loop,
|
||||
r.channel, r.navBrowse};
|
||||
for (const Rect& it : items) {
|
||||
CHECK(!overlaps(it, r.rootStrip));
|
||||
CHECK(!overlaps(it, r.title));
|
||||
@@ -176,7 +175,7 @@ static void testDegenerateBandYieldsNoInvertedRects() {
|
||||
kKnob);
|
||||
const Rect items[] = {tiny.title, tiny.holdCell, tiny.holdKnob, tiny.holdLabel,
|
||||
tiny.bake, tiny.preview, tiny.velCell, tiny.velKnob, tiny.velLabel,
|
||||
tiny.loopOff, tiny.loopOn, tiny.chanMono, tiny.chanStereo,
|
||||
tiny.loop, tiny.channel,
|
||||
tiny.navBrowse, tiny.rootStrip};
|
||||
for (const Rect& it : items) CHECK(it.right() >= it.x && it.bottom() >= it.y);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user