deck: filter mod moves to FILTER ENV, cell runs centre in their reserves, two-segment toggles become single buttons, deck focuses its overlay
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();
|
||||
|
||||
Reference in New Issue
Block a user