Fix stale post-widen deck-reflow figures (90/144/1190px) across docs and tests, and split test_deck_groups.cpp's width-budget fixtures into a new file.
This commit is contained in:
+5
-3
@@ -613,7 +613,9 @@ folded into the tracks below:
|
|||||||
- **Γ-F4** — there **is** an explicit loop enable, and it lives on the **chrome row**, not
|
- **Γ-F4** — there **is** an explicit loop enable, and it lives on the **chrome row**, not
|
||||||
in a deck. W2-T2's scope grows accordingly — spec §6.4.
|
in a deck. W2-T2's scope grows accordingly — spec §6.4.
|
||||||
- **Γ-F5** — MASTER's reserved slot is **one** cell. The 90 px headroom argument behind
|
- **Γ-F5** — MASTER's reserved slot is **one** cell. The 90 px headroom argument behind
|
||||||
that is spec §1.6 and governs every future control addition.
|
that is spec §1.6 and governs every future control addition. (**Moved afterwards:**
|
||||||
|
Γ-W3-T1 widened the row block, so §1.6's ledger is now 82 px — the ruling and its
|
||||||
|
purchasing power are unchanged, only the number.)
|
||||||
- **Γ-F6** — **ship dynamic latency as ruled.** The `restartComponent(kLatencyChanged)`
|
- **Γ-F6** — **ship dynamic latency as ruled.** The `restartComponent(kLatencyChanged)`
|
||||||
deactivate/reactivate the SDK mandates is accepted: *"the limiter will either be on or off
|
deactivate/reactivate the SDK mandates is accepted: *"the limiter will either be on or off
|
||||||
on its instance, toggling during playback is not a use case."* No constant-latency
|
on its instance, toggling during playback is not a use case."* No constant-latency
|
||||||
@@ -1083,9 +1085,9 @@ waveform band.
|
|||||||
Row 1's natural width fits the block **only after this track's `Band|Notch` move**: 1030
|
Row 1's natural width fits the block **only after this track's `Band|Notch` move**: 1030
|
||||||
today, +42 from W2-T1's PITCH/RATE, −92 here, = **980**. That is this track's fit assertion
|
today, +42 from W2-T1's PITCH/RATE, −92 here, = **980**. That is this track's fit assertion
|
||||||
and W1-T4 deliberately left it open.
|
and W1-T4 deliberately left it open.
|
||||||
- **The 90 px of remaining headroom is the budget for the life of this layout**, and one deck
|
- **The 82 px of remaining headroom is the budget for the life of this layout**, and one deck
|
||||||
cell is 60 px. **This is why MASTER's reserved slot is ONE cell** (Γ-F5, ruled): two would
|
cell is 60 px. **This is why MASTER's reserved slot is ONE cell** (Γ-F5, ruled): two would
|
||||||
spend 60 of the 90 up front on a control nobody has named, leaving 30 — which would freeze
|
spend 60 of the 82 up front on a control nobody has named, leaving 22 — which would freeze
|
||||||
row 1 forever, since any later row-1 addition needs 60. Widening MASTER later costs the same
|
row 1 forever, since any later row-1 addition needs 60. Widening MASTER later costs the same
|
||||||
60 it would cost now, and by then the trade is against a real control instead of a guess.
|
60 it would cost now, and by then the trade is against a real control instead of a guess.
|
||||||
**State this ledger where a future reader will hit it** — spec §1.6 is its home, and a
|
**State this ledger where a future reader will hit it** — spec §1.6 is its home, and a
|
||||||
|
|||||||
+1
-1
@@ -365,7 +365,7 @@ The within-deck stacking idea is retired, not deferred.
|
|||||||
**The measured-geometry block that used to live here has been deleted, not moved.** It was
|
**The measured-geometry block that used to live here has been deleted, not moved.** It was
|
||||||
taken at the 840 px floor with `kDeckCellW = 48` and is wrong twice over — Θ-W6-T1 changed
|
taken at the 840 px floor with `kDeckCellW = 48` and is wrong twice over — Θ-W6-T1 changed
|
||||||
both the floor (980) and the cell metrics (60 × 74). The current, re-derived geometry — every
|
both the floor (980) and the cell metrics (60 × 74). The current, re-derived geometry — every
|
||||||
group's width, both row totals, and the resulting 1190 × 680 floor — is the table in
|
group's width, both row totals, and the resulting 1198 × 680 floor — is the table in
|
||||||
`docs/product/instrument-control-surface.md` §1.2. **Do not resurrect the old numbers.**
|
`docs/product/instrument-control-surface.md` §1.2. **Do not resurrect the old numbers.**
|
||||||
The unresolved 864-vs-872 px VELOCITY↔VOICE adjacency-threshold discrepancy is retired with
|
The unresolved 864-vs-872 px VELOCITY↔VOICE adjacency-threshold discrepancy is retired with
|
||||||
them; it was measured against a layout that no longer exists.
|
them; it was measured against a layout that no longer exists.
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ Two corollaries for a reader who wants to add something:
|
|||||||
FILTER's `Band|Notch` move exploits). A cell always costs its 60 px.
|
FILTER's `Band|Notch` move exploits). A cell always costs its 60 px.
|
||||||
- **The chrome row is a separate budget.** The toolbar row's right-anchored control run is
|
- **The chrome row is a separate budget.** The toolbar row's right-anchored control run is
|
||||||
paid for out of the *title* slot, not out of the window floor — which is why the loop
|
paid for out of the *title* slot, not out of the window floor — which is why the loop
|
||||||
enable (§6.5) costs zero of the 90. That is a genuinely different purse and must not be
|
enable (§6.5) costs zero of the 82. That is a genuinely different purse and must not be
|
||||||
confused with this one.
|
confused with this one.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ anything for a trigger shape.
|
|||||||
### `ui/`
|
### `ui/`
|
||||||
|
|
||||||
- `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own.
|
- `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own.
|
||||||
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for), and `kEditorCeilingWidth`, the floor's sibling window fact (the hard cap the floor may not exceed) — moved here from `knob_deck.h` since it is a window fact, not a deck one; the derivation identity against the deck's width budget stays in `test_deck_groups.cpp`, the one place that already includes both headers. Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
|
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for), and `kEditorCeilingWidth`, the floor's sibling window fact (the hard cap the floor may not exceed) — moved here from `knob_deck.h` since it is a window fact, not a deck one; the derivation identity against the deck's width budget stays in `test_deck_groups_measured.cpp`, the one place that already includes both headers. Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
|
||||||
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — bake Hold cell, bake, preview, velocity knob cell, loop enable, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. Every run member's width is RESERVED unconditionally, the Hold cell included — the only conditionally-drawn one, and the leftmost, so what its reservation buys is a title slot that does not re-measure when a loop is dialled in or out (`sample_chrome.h` records the cost). Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset.
|
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — bake Hold cell, bake, preview, velocity knob cell, loop enable, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. Every run member's width is RESERVED unconditionally, the Hold cell included — the only conditionally-drawn one, and the leftmost, so what its reservation buys is a title slot that does not re-measure when a loop is dialled in or out (`sample_chrome.h` records the cost). Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset.
|
||||||
- `bake_hold` — the Hold knob's value domain and nothing else: the knob's normalized [0,1] mapped onto the note-length ladder and back, ordered by LENGTH rather than by the ladder's presentation order. Split from `sample_chrome` on the same axis `deck_values` was split from `knob_deck` — that says where the cell is, this says what its position means.
|
- `bake_hold` — the Hold knob's value domain and nothing else: the knob's normalized [0,1] mapped onto the note-length ladder and back, ordered by LENGTH rather than by the ladder's presentation order. Split from `sample_chrome` on the same axis `deck_values` was split from `knob_deck` — that says where the cell is, this says what its position means.
|
||||||
- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins.
|
- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins.
|
||||||
|
|||||||
@@ -75,10 +75,13 @@ reasampler_test(master_meter LINK master_meter waveform_view)
|
|||||||
reasampler_pure_library(deck_groups
|
reasampler_pure_library(deck_groups
|
||||||
SOURCES deck_groups.cpp
|
SOURCES deck_groups.cpp
|
||||||
LINK PUBLIC knob_deck velocity_curve peaks curve_law PRIVATE master_meter)
|
LINK PUBLIC knob_deck velocity_curve peaks curve_law PRIVATE master_meter)
|
||||||
# sample_bands and master_meter are linked directly for the test: the deck-fits-the-floor-window
|
# WHICH descriptors the deck carries, and how they resolve to a layout — no window-floor budget
|
||||||
# assertion needs the band allocator, and the MASTER-reserve identity needs the column width the
|
# assertion here, so this target needs neither sample_bands nor master_meter.
|
||||||
# PRIVATE edge above does not re-export.
|
reasampler_test(deck_groups LINK deck_groups)
|
||||||
reasampler_test(deck_groups LINK deck_groups sample_bands master_meter)
|
# The width-BUDGET half, split out on the same seam PRIVATE master_meter already draws above:
|
||||||
|
# the deck-fits-the-floor-window assertion needs the band allocator, and the MASTER-reserve
|
||||||
|
# identity needs the column width the PRIVATE edge on deck_groups does not re-export.
|
||||||
|
reasampler_test(deck_groups_measured LINK deck_groups sample_bands master_meter)
|
||||||
# The commit-tier + overlay-selection state machine, split out of deck_groups_tests on the seam
|
# The commit-tier + overlay-selection state machine, split out of deck_groups_tests on the seam
|
||||||
# those fixtures already had: deckParamCommit/liveCommitFor and the overlay predicates are pure
|
# those fixtures already had: deckParamCommit/liveCommitFor and the overlay predicates are pure
|
||||||
# control-id/enum logic that touches no layout, so this target needs no sample_bands/master_meter.
|
# control-id/enum logic that touches no layout, so this target needs no sample_bands/master_meter.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi
|
|||||||
// Segment width of the three Staged|Spline toggles. Sized so each env group's caption row stays
|
// Segment width of the three Staged|Spline toggles. Sized so each env group's caption row stays
|
||||||
// no wider than its knob row; the binding group is PITCH ENV, which reaches its four-cell knob
|
// no wider than its knob row; the binding group is PITCH ENV, which reaches its four-cell knob
|
||||||
// row at 47 (AMP, the next tightest, at 55). Well inside the ceiling — raising it would widen
|
// row at 47 (AMP, the next tightest, at 55). Well inside the ceiling — raising it would widen
|
||||||
// the CONTOUR row, which has 144px of slack, not the SOUND row.
|
// the CONTOUR row, which has 152px of slack, not the SOUND row.
|
||||||
constexpr int kEnvModeSegW = 23;
|
constexpr int kEnvModeSegW = 23;
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
|||||||
{
|
{
|
||||||
// The lower slot is reserved and draws NOTHING: blank reads as breathing room where a
|
// The lower slot is reserved and draws NOTHING: blank reads as breathing room where a
|
||||||
// dashed placeholder would read as unfinished. It is one cell, not two — a second
|
// dashed placeholder would read as unfinished. It is one cell, not two — a second
|
||||||
// would spend 60 of the layout's whole 90px budget on a control nobody has named.
|
// would spend 60 of the layout's whole 82px budget on a control nobody has named.
|
||||||
DeckGroupDesc master;
|
DeckGroupDesc master;
|
||||||
master.id = kGroupMaster;
|
master.id = kGroupMaster;
|
||||||
master.captionWidth = 46;
|
master.captionWidth = 46;
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ struct SampleBands {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Divide a (w x h) client area into the three bands. `deckHeight` is the knob deck's own
|
// Divide a (w x h) client area into the three bands. `deckHeight` is the knob deck's own
|
||||||
// wrapped height (from knob_deck) — the only interior measurement the allocator needs, so
|
// height (from knob_deck) — the only interior measurement the allocator needs, so
|
||||||
// the deck band is exactly as tall as its content. Pure.
|
// the deck band is exactly as tall as its content. Pure.
|
||||||
SampleBands computeSampleBands(int w, int h, int deckHeight);
|
SampleBands computeSampleBands(int w, int h, int deckHeight);
|
||||||
|
|
||||||
|
|||||||
+57
-415
@@ -1,18 +1,16 @@
|
|||||||
// Standalone tests for reasampler::instrument::ui::deck_groups — no VST3, no REAPER, no
|
// Standalone tests for reasampler::instrument::ui::deck_groups — no VST3, no REAPER, no
|
||||||
// framework. knob_deck's own tests pin how a descriptor list LAYS OUT; these pin WHICH
|
// framework. Pins WHICH descriptors the Sample face carries and how they resolve to a layout:
|
||||||
// descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp),
|
// the signal-flow group order (pitch -> filter -> amp), the Filter group's contents, the
|
||||||
// the Filter group's contents, the VELOCITY group's exclusive ownership of the three curve
|
// VELOCITY group's exclusive ownership of the three curve cells and its placement immediately
|
||||||
// cells and its placement immediately left of VOICE, the wrapped deck height at the editor's
|
// left of VOICE, row membership, that no face leaves slack where its dropped controls were,
|
||||||
// floor width and its fit inside the floor window, the pinned Gate group widths, the editor
|
// that a Gate/Spline/Gate round trip restores the layout exactly, the hit-test reaching the new
|
||||||
// floor derived from the deck's width budget and each group's categorical row,
|
// filter controls, and the bipolar knob law's inverse pair. The width-BUDGET fixtures (the
|
||||||
// that no face leaves slack where its dropped controls were and that a Gate/Spline/Gate round
|
// editor floor's derivation, the row/gutter arithmetic at the floor, MASTER's interior) live in
|
||||||
// trip restores the layout exactly, the hit-test reaching the new filter controls, and the
|
// test_deck_groups_measured.cpp, which needs sample_bands/master_meter and this file does not.
|
||||||
// bipolar knob law's inverse pair. The commit-tier routing and the overlay-selection state
|
// The commit-tier routing and the overlay-selection state machine live in
|
||||||
// machine live in test_deck_groups_state.cpp — they touch no layout at all.
|
// test_deck_groups_state.cpp — they touch no layout at all.
|
||||||
|
|
||||||
#include "../src/core/instrument/ui/deck_groups.h"
|
#include "../src/core/instrument/ui/deck_groups.h"
|
||||||
#include "../src/core/instrument/ui/master_meter.h" // kMeterColumnW: MASTER's reserve IS this
|
|
||||||
#include "../src/core/instrument/ui/sample_bands.h"
|
|
||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
@@ -25,9 +23,14 @@ static int g_fail = 0;
|
|||||||
#define CHECK(cond) do { if(!(cond)) { \
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
// The editor's floor width, which is also its default (checkSizeConstraint clamps to it), less
|
// A pad and an available width for exercising layoutDeck, kept independent of sample_bands.h —
|
||||||
// the band allocator's kPad inset on each side.
|
// this file pins what the deck IS, not the window-floor budget. kSampleAvail equals the real
|
||||||
static constexpr int kAvailAtMinWidth = kEditorMinWidth - 2 * kPad;
|
// floor's available width because it is derived the same way (block + gap + spanning deck);
|
||||||
|
// that identity, and the window-fact constants (kPad, kEditorMinWidth) it derives from, are
|
||||||
|
// test_deck_groups_measured.cpp's to own.
|
||||||
|
static constexpr int kSamplePad = 8;
|
||||||
|
static constexpr int kSampleAvail = kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW;
|
||||||
|
static constexpr int kSampleAvailWide = kSampleAvail + 200; // comfortably above the block
|
||||||
|
|
||||||
static int indexOfGroup(const std::vector<DeckGroupDesc>& g, int id) {
|
static int indexOfGroup(const std::vector<DeckGroupDesc>& g, int id) {
|
||||||
for (std::size_t i = 0; i < g.size(); ++i) {
|
for (std::size_t i = 0; i < g.size(); ++i) {
|
||||||
@@ -99,7 +102,7 @@ static void testCurveTargetNamesEachCellsOwnDestination() {
|
|||||||
// treats them as knob cells, so the popup routing rides an ordinary Knob hit.
|
// treats them as knob cells, so the popup routing rides an ordinary Knob hit.
|
||||||
static void testVelocityCellsHitTestWithinTheirGroup() {
|
static void testVelocityCellsHitTestWithinTheirGroup() {
|
||||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||||
const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth);
|
const DeckLayout dl = layoutDeck(g, kSamplePad, 40, kSampleAvail);
|
||||||
const DeckGroupLayout& v =
|
const DeckGroupLayout& v =
|
||||||
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupVelocity))];
|
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupVelocity))];
|
||||||
CHECK(v.cells.size() == 3);
|
CHECK(v.cells.size() == 3);
|
||||||
@@ -257,8 +260,8 @@ static void testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction() {
|
|||||||
CHECK(deckHeight(g) == 2 * kDeckGroupH + kDeckRowGap);
|
CHECK(deckHeight(g) == 2 * kDeckGroupH + kDeckRowGap);
|
||||||
CHECK(deckHeight(g) == 216);
|
CHECK(deckHeight(g) == 216);
|
||||||
|
|
||||||
for (int avail : {kAvailAtMinWidth, kAvailAtMinWidth + 200, 4000}) {
|
for (int avail : {kSampleAvail, kSampleAvail + 200, 4000}) {
|
||||||
const DeckLayout dl = layoutDeck(g, kPad, 0, avail);
|
const DeckLayout dl = layoutDeck(g, kSamplePad, 0, avail);
|
||||||
CHECK(dl.rowCount == 2);
|
CHECK(dl.rowCount == 2);
|
||||||
CHECK(dl.height == 216);
|
CHECK(dl.height == 216);
|
||||||
CHECK(dl.groups.size() == g.size());
|
CHECK(dl.groups.size() == g.size());
|
||||||
@@ -268,7 +271,7 @@ static void testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction() {
|
|||||||
if (row == DeckRow::Spanning) {
|
if (row == DeckRow::Spanning) {
|
||||||
CHECK(gl.box.y == 0);
|
CHECK(gl.box.y == 0);
|
||||||
CHECK(gl.box.height == kDeckSpanningH);
|
CHECK(gl.box.height == kDeckSpanningH);
|
||||||
CHECK(gl.box.right() == kPad + avail); // right-anchored at every width
|
CHECK(gl.box.right() == kSamplePad + avail); // right-anchored at every width
|
||||||
} else {
|
} else {
|
||||||
CHECK(gl.box.y == rowTops[row == DeckRow::Contour ? 1 : 0]);
|
CHECK(gl.box.y == rowTops[row == DeckRow::Contour ? 1 : 0]);
|
||||||
CHECK(gl.box.height == kDeckGroupH);
|
CHECK(gl.box.height == kDeckGroupH);
|
||||||
@@ -278,54 +281,6 @@ static void testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The guard the raised floor exists to provide: at the smallest window the host can produce,
|
|
||||||
// the deck band still lands inside the client area AND the waveform still gets its two-lane
|
|
||||||
// floor. Growing the deck past what the floor height can hold fails HERE instead of silently pushing
|
|
||||||
// FILTER ENV / AMP / VOICE / MASTER off-screen, where there is no scroll to reach them.
|
|
||||||
static void testDeckFitsInsideTheEnforcedMinimumWindow() {
|
|
||||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
|
||||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
|
||||||
const int h = deckHeight(g);
|
|
||||||
const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h);
|
|
||||||
CHECK(deckRowCount(g) == 2); // either face
|
|
||||||
CHECK(b.decks.height == h);
|
|
||||||
// The reflow's 112 px land in the waveform: at two rows the deck band is 216 and the
|
|
||||||
// waveform 358, against 328/246 before. Pinned now that both are reached by
|
|
||||||
// construction rather than by a pack outcome.
|
|
||||||
CHECK(b.decks.height == 2 * kDeckGroupH + kDeckRowGap);
|
|
||||||
CHECK(b.waveform.height == 358);
|
|
||||||
// Bottom-anchored INSIDE the pad is the whole assertion: the degrade path pushes the
|
|
||||||
// deck down until the waveform hits its floor, so any deck too tall to fit stops
|
|
||||||
// landing on this exact line. A `<= kEditorMinHeight` bound would not catch it — the
|
|
||||||
// degrade can still leave the deck ending at the window edge.
|
|
||||||
CHECK(b.decks.bottom() == kEditorMinHeight - kPad);
|
|
||||||
CHECK(b.waveform.height >= kWaveformMinHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The floor is a DERIVED number, and this is the one place the derivation is written down —
|
|
||||||
// sample_bands stays independent of knob_deck, so neither header can hold it. This fixture is
|
|
||||||
// the only one that includes both.
|
|
||||||
static void testTheEditorFloorIsDerivedFromTheDeckWidthBudget() {
|
|
||||||
CHECK(kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW + 2 * kPad == kEditorMinWidth);
|
|
||||||
// The budget: what is left between the derived floor and the hard ceiling, and it is spent
|
|
||||||
// once. A cell costs 60 of it.
|
|
||||||
CHECK(kEditorCeilingWidth - kEditorMinWidth == 82);
|
|
||||||
// 82 still buys one more deck cell (60), which is the only purchase the ledger promises —
|
|
||||||
// the widen below spent 8 px of slack, not the layout's purchasing power.
|
|
||||||
CHECK(kEditorCeilingWidth - kEditorMinWidth >= kDeckCellW);
|
|
||||||
// The reflow's 112 px goes entirely to the waveform, so the height does not move.
|
|
||||||
CHECK(kEditorMinHeight == 680);
|
|
||||||
// 1190 + 8: the row block was widened 1020 -> 1028 to put the two rows' filter edges on
|
|
||||||
// one pixel, which is the only reason the floor moved off Γ-W1-T4's number.
|
|
||||||
CHECK(kEditorMinWidth == 1198);
|
|
||||||
CHECK(kEditorMinWidth <= kEditorCeilingWidth);
|
|
||||||
CHECK(kEditorMinHeight <= 720);
|
|
||||||
// And the row block really is what the two rows justify inside — derived from the floor
|
|
||||||
// and the spanning reserve, not restated.
|
|
||||||
CHECK(kEditorMinWidth - 2 * kPad - kDeckSpanningW - kDeckGroupGap == kDeckRowBlockW);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void testEveryDeckGroupBelongsToExactlyOneRow() {
|
static void testEveryDeckGroupBelongsToExactlyOneRow() {
|
||||||
CHECK(deckRowFor(kGroupPitch) == DeckRow::Sound);
|
CHECK(deckRowFor(kGroupPitch) == DeckRow::Sound);
|
||||||
CHECK(deckRowFor(kGroupFilter) == DeckRow::Sound);
|
CHECK(deckRowFor(kGroupFilter) == DeckRow::Sound);
|
||||||
@@ -351,210 +306,46 @@ static void testEveryDeckGroupBelongsToExactlyOneRow() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both rows now fit their block, in BOTH play modes. Row 1's fit is the one this track closes:
|
// The gap fix as a property of the shipped descriptors, not a picture: whichever face a
|
||||||
// it was 1030, +42 from PITCH/RATE's third cell and −92 from FILTER's Band|Notch caption move
|
// mode-dependent group shows, its knob row still spans the group's whole reserved run. The
|
||||||
// take it to 980. Row 2's 876 is mode-stable because FILTER ENV's and AMP's reserve slots hold
|
// Trigger faces drop Sustain and Release and get wider cells for it — never a hole where the
|
||||||
// them at 312 in Trigger too — asserted here rather than assumed.
|
// dropped control was. What the run does not cover is the indivisible residue alone, strictly
|
||||||
static void testBothRowsAndTheSpanningDeckFitTheBudget() {
|
// under one pixel per cell. Checked at both a tight and a genuinely wider width.
|
||||||
|
static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() {
|
||||||
|
for (int avail : {kSampleAvail, kSampleAvailWide}) {
|
||||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
||||||
int width[3] = {0, 0, 0};
|
const DeckLayout dl = layoutDeck(g, kSamplePad, 0, avail);
|
||||||
int count[3] = {0, 0, 0};
|
CHECK(dl.groups.size() == g.size());
|
||||||
for (const DeckGroupDesc& d : g) {
|
for (std::size_t i = 0; i < dl.groups.size(); ++i) {
|
||||||
const int r = static_cast<int>(deckRowFor(static_cast<DeckGroupId>(d.id)));
|
// The spanning deck's slots STACK — the run-division law this pins is the
|
||||||
width[r] += deckGroupWidth(d);
|
// horizontal one, and its vertical guard is its own test.
|
||||||
++count[r];
|
if (g[i].row == DeckRow::Spanning) continue;
|
||||||
|
const DeckGroupLayout& lay = dl.groups[i];
|
||||||
|
const int reserved = static_cast<int>(g[i].cellIds.size()) * kDeckCellW;
|
||||||
|
const std::size_t present = lay.cells.size();
|
||||||
|
CHECK(present > 0);
|
||||||
|
for (std::size_t k = 0; k < present; ++k) {
|
||||||
|
const DeckCellLayout& c = lay.cells[k];
|
||||||
|
CHECK(c.id >= 0); // a reserve yields width, never a dead rect
|
||||||
|
CHECK(c.cell.width == lay.cells[0].cell.width);
|
||||||
|
if (k > 0) CHECK(c.cell.x == lay.cells[k - 1].cell.right());
|
||||||
|
}
|
||||||
|
const int covered = lay.cells.back().cell.right() - lay.cells.front().cell.x;
|
||||||
|
CHECK(reserved - covered < static_cast<int>(present));
|
||||||
|
CHECK(lay.cells.front().cell.x >= lay.box.x + kDeckGroupPadX);
|
||||||
|
CHECK(lay.cells.back().cell.right() <= lay.box.right() - kDeckGroupPadX);
|
||||||
}
|
}
|
||||||
const int sound = static_cast<int>(DeckRow::Sound);
|
|
||||||
const int contour = static_cast<int>(DeckRow::Contour);
|
|
||||||
const int spanning = static_cast<int>(DeckRow::Spanning);
|
|
||||||
|
|
||||||
CHECK(count[sound] == 4);
|
|
||||||
CHECK(width[sound] == 980); // 192 + 432 + 192 + 164
|
|
||||||
CHECK(count[contour] == 3);
|
|
||||||
CHECK(width[contour] == 876); // 252 + 312 + 312
|
|
||||||
CHECK(count[spanning] == 1);
|
|
||||||
CHECK(width[spanning] == kDeckSpanningW); // 142 exactly — the reserve is now spent
|
|
||||||
|
|
||||||
for (int r : {sound, contour}) {
|
|
||||||
CHECK(width[r] <= kDeckRowBlockW);
|
|
||||||
// Slack enough that no gutter in the row falls under the minimum.
|
|
||||||
CHECK(kDeckRowBlockW - width[r] >= (count[r] - 1) * kDeckGroupGap);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The gutters the justification law produces at the floor, and the alignment they buy. The
|
// The "residue lands in symmetric end margins" rule is knob_deck's own (layoutGroup), pinned
|
||||||
// At the 1028 block the justification law makes the tie-line exact by arithmetic rather than
|
// once by its synthetic residue>=2 fixture in test_knob_deck.cpp rather than restated here.
|
||||||
// by a special rule: row 1's slack is 48 over three gutters (16 each, no residue) and row 2's
|
|
||||||
// is 152 over two (76 each), which lands both filter edges on 640. Only two of the three
|
|
||||||
// properties §1.3 once claimed can hold at once — a smallest gutter of exactly kDeckGroupGap
|
|
||||||
// needs a 1016 block — and 12 is a floor, not a target, so 16 satisfies the real rule.
|
|
||||||
static void testGutterArithmeticAndTheFilterTieLineAtTheFloor() {
|
|
||||||
const std::vector<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
|
|
||||||
// with no residue, so no gutter carries a leftover pixel.
|
|
||||||
CHECK(box(kGroupPitch).x == kPad);
|
|
||||||
CHECK(box(kGroupFilter).x - box(kGroupPitch).right() == 16);
|
|
||||||
CHECK(box(kGroupVelocity).x - box(kGroupFilter).right() == 16);
|
|
||||||
CHECK(box(kGroupVoice).x - box(kGroupVelocity).right() == 16);
|
|
||||||
CHECK(box(kGroupVoice).right() == kPad + kDeckRowBlockW);
|
|
||||||
|
|
||||||
// Row 2: flush left, flush right, two gutters exactly equal.
|
|
||||||
CHECK(box(kGroupPitchEnv).x == kPad);
|
|
||||||
CHECK(box(kGroupFilterEnv).x - box(kGroupPitchEnv).right() == 76);
|
|
||||||
CHECK(box(kGroupAmpEnv).x - box(kGroupFilterEnv).right() == 76);
|
|
||||||
CHECK(box(kGroupAmpEnv).right() == kPad + kDeckRowBlockW);
|
|
||||||
|
|
||||||
// The tie-line, block-relative: both filter edges on ONE pixel, which is what the widen
|
|
||||||
// bought. Pinned as an identity too, so a group-width change cannot pass by moving both.
|
|
||||||
CHECK(box(kGroupFilterEnv).right() - kPad == 640);
|
|
||||||
CHECK(box(kGroupFilter).right() - kPad == 640);
|
|
||||||
CHECK(box(kGroupFilter).right() == box(kGroupFilterEnv).right());
|
|
||||||
|
|
||||||
// MASTER is right-anchored outside the block, one kDeckGroupGap clear of it.
|
|
||||||
CHECK(box(kGroupMaster).x - box(kGroupVoice).right() == kDeckGroupGap);
|
|
||||||
CHECK(box(kGroupMaster).right() == kPad + kAvailAtMinWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No gutter is ever narrower than kDeckGroupGap at or above the floor, and both rows stay
|
|
||||||
// flush at every width — the property the exact-at-the-floor numbers above are one point of.
|
|
||||||
// Above the floor the tie-line DRIFTS, which is accepted and deliberate (§1.3): row 1 divides
|
|
||||||
// its slack over three gutters and row 2 over two, so row 2's filter edge pulls right past
|
|
||||||
// row 1's and the gap widens monotonically. Encoded as EXPECTED, not as a failure.
|
|
||||||
//
|
|
||||||
// Checked per ROW (tracking the last-seen box in each of the two categorical rows while
|
|
||||||
// walking dl.groups in deck order), not just deck-order neighbours: two same-row groups can
|
|
||||||
// sit apart in deck order with a different-row group between them, and a deck-order-only
|
|
||||||
// check would silently skip that gutter.
|
|
||||||
static void testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor() {
|
|
||||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
|
||||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
|
||||||
int lastDrift = 1 << 20; // sentinel above any real drift
|
|
||||||
for (int avail = kAvailAtMinWidth; avail <= kAvailAtMinWidth + 600; avail += 37) {
|
|
||||||
const DeckLayout dl = layoutDeck(g, kPad, 0, avail);
|
|
||||||
const DeckGroupLayout* prevInRow[2] = {nullptr, nullptr};
|
|
||||||
for (const DeckGroupLayout& gl : dl.groups) {
|
|
||||||
const DeckRow row = deckRowFor(static_cast<DeckGroupId>(gl.id));
|
|
||||||
if (row == DeckRow::Spanning) continue;
|
|
||||||
const int r = row == DeckRow::Contour ? 1 : 0;
|
|
||||||
if (prevInRow[r]) {
|
|
||||||
CHECK(gl.box.x - prevInRow[r]->box.right() >= kDeckGroupGap);
|
|
||||||
}
|
|
||||||
prevInRow[r] = ≷
|
|
||||||
}
|
|
||||||
const auto right = [&](int id) {
|
|
||||||
return dl.groups[static_cast<std::size_t>(indexOfGroup(g, id))].box.right();
|
|
||||||
};
|
|
||||||
// Flush right on the block at every width, both rows.
|
|
||||||
CHECK(right(kGroupVoice) == right(kGroupAmpEnv));
|
|
||||||
// Monotone in width rather than oscillating: row 2's two gutters absorb slack
|
|
||||||
// faster than row 1's three, so the gap only ever opens.
|
|
||||||
const int drift = right(kGroupFilter) - right(kGroupFilterEnv);
|
|
||||||
CHECK(drift <= lastDrift);
|
|
||||||
lastDrift = drift;
|
|
||||||
}
|
|
||||||
// It really does open up: the tie-line is exact AT the floor and separates above it,
|
|
||||||
// which is the accepted outcome rather than a near-miss to be pinned back.
|
|
||||||
CHECK(lastDrift < -50);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MASTER's interior, exact to the pixel (§1.4). The two left slots sit on the two rows' own
|
|
||||||
// knob baselines — that is what "stitched to both rows" means — and the meter is ONE rect
|
|
||||||
// across both, never a readout per row.
|
|
||||||
static void testTheMasterDeckInteriorLandsOnBothRowBaselines() {
|
|
||||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
|
||||||
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
|
|
||||||
const DeckGroupLayout& m =
|
|
||||||
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
|
|
||||||
|
|
||||||
CHECK(m.box.width == 142);
|
|
||||||
CHECK(m.box.height == 216);
|
|
||||||
// 6 + 60 + 8 + 62 + 6 — the decomposition, not just the total, and the 62 is the meter
|
|
||||||
// module's own kMeterColumnW rather than a copy of it. That link is the whole point: the
|
|
||||||
// column is banked to GROW (§1.2), and a reserve that did not track it would leave the
|
|
||||||
// interior underfilling or overrunning with every test still green.
|
|
||||||
CHECK(kDeckGroupPadX + kDeckCellW + kDeckColumnGap + kMeterColumnW + kDeckGroupPadX == 142);
|
|
||||||
CHECK(m.column.id == cell(DeckParam::kMasterMeter));
|
|
||||||
CHECK(m.column.box.width == kMeterColumnW);
|
|
||||||
|
|
||||||
// One cell drawn (gain) and one slot RESERVED below it: the reserve is height at a fixed
|
|
||||||
// position and draws nothing.
|
|
||||||
CHECK(m.cells.size() == 1);
|
|
||||||
CHECK(m.cells[0].id == cell(DeckParam::kMasterGain));
|
|
||||||
CHECK(m.cells[0].cell.y - m.box.y == 26);
|
|
||||||
const int reserveTop = m.cells[0].cell.y + kDeckGroupH + kDeckRowGap;
|
|
||||||
CHECK(reserveTop - m.box.y == 138);
|
|
||||||
|
|
||||||
// The two baselines are row 1's and row 2's own.
|
|
||||||
const DeckGroupLayout& filter =
|
|
||||||
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
|
|
||||||
const DeckGroupLayout& amp =
|
|
||||||
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupAmpEnv))];
|
|
||||||
CHECK(m.cells[0].cell.y == filter.cells[0].cell.y);
|
|
||||||
CHECK(reserveTop == amp.cells[0].cell.y);
|
|
||||||
|
|
||||||
// The meter: one rect spanning both baselines, 62 x 186.
|
|
||||||
CHECK(m.column.id == cell(DeckParam::kMasterMeter));
|
|
||||||
CHECK(m.column.box.width == 62);
|
|
||||||
CHECK(m.column.box.height == 186);
|
|
||||||
CHECK(m.column.box.y == m.cells[0].cell.y);
|
|
||||||
CHECK(m.column.box.bottom() - m.box.y == 212);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The regression guard for the rule most likely to be "generalised" wrongly: MASTER's left
|
|
||||||
// column is FIXED slots at the two baselines, NOT knob_deck's horizontal run-division law
|
|
||||||
// applied vertically — which would stretch the one gain knob over the whole 186 px.
|
|
||||||
static void testTheMasterColumnDoesNotDivideItsRunVertically() {
|
|
||||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
|
||||||
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
|
|
||||||
const DeckGroupLayout& m =
|
|
||||||
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
|
|
||||||
CHECK(m.cells[0].cell.height == kDeckCellH);
|
|
||||||
CHECK(m.cells[0].cell.width == kDeckCellW);
|
|
||||||
// Under the run-division law the lone present cell would take the whole two-slot run;
|
|
||||||
// here it takes exactly one slot and leaves the rest empty.
|
|
||||||
CHECK(m.cells[0].cell.height < m.column.box.height);
|
|
||||||
CHECK(m.cells[0].cell.bottom() < m.column.box.bottom());
|
|
||||||
CHECK(m.cells[0].knob.width == kDeckKnobSize && m.cells[0].knob.height == kDeckKnobSize);
|
|
||||||
// And dropping the reserve does not move the gain knob or the meter — the slot below it is
|
|
||||||
// reserved height, so nothing above it depends on whether it is there.
|
|
||||||
std::vector<DeckGroupDesc> noReserve = g;
|
|
||||||
for (DeckGroupDesc& d : noReserve) {
|
|
||||||
if (d.id == kGroupMaster) d.cellIds = {cell(DeckParam::kMasterGain)};
|
|
||||||
}
|
|
||||||
const DeckLayout dl2 = layoutDeck(noReserve, kPad, 0, kAvailAtMinWidth);
|
|
||||||
const DeckGroupLayout& m2 =
|
|
||||||
dl2.groups[static_cast<std::size_t>(indexOfGroup(noReserve, kGroupMaster))];
|
|
||||||
CHECK(m2.cells[0].cell == m.cells[0].cell);
|
|
||||||
CHECK(m2.column.box == m.column.box);
|
|
||||||
}
|
|
||||||
|
|
||||||
// MASTER's caption row and knob row measure exactly equal (130 == 130) today, so a column
|
|
||||||
// derived from either edge lands in the same place — that balance is what let a left-derived
|
|
||||||
// offset masquerade as right-anchored. Widen the caption reserve alone (as a wider caption or
|
|
||||||
// a limiter-toggle change would) and the column must still land flush against the group's own
|
|
||||||
// right padding, derived from innerRight rather than measured past the cell slots.
|
|
||||||
static void testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow() {
|
|
||||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
|
||||||
DeckGroupDesc probe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
|
|
||||||
probe.captionWidth += 40; // unbalances it: the caption row now measures past the knob row
|
|
||||||
const std::vector<DeckGroupDesc> one = {probe};
|
|
||||||
const DeckLayout dl = layoutDeck(one, kPad, 0, kAvailAtMinWidth);
|
|
||||||
const DeckGroupLayout& m = dl.groups[0];
|
|
||||||
CHECK(m.box.width > 142); // the widen is real, not absorbed elsewhere
|
|
||||||
CHECK(m.column.box.right() == m.box.right() - kDeckGroupPadX);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void testHitTestResolvesTheNewFilterControls() {
|
static void testHitTestResolvesTheNewFilterControls() {
|
||||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||||
const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth);
|
const DeckLayout dl = layoutDeck(g, kSamplePad, 40, kSampleAvail);
|
||||||
const DeckGroupLayout& f =
|
const DeckGroupLayout& f =
|
||||||
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
|
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
|
||||||
|
|
||||||
@@ -617,143 +408,6 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
|
|||||||
CHECK(deckNormFromBipolar(3.0) == 1.0);
|
CHECK(deckNormFromBipolar(3.0) == 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The three mode toggles ride each env group's caption slack, so the deck's wrapped geometry
|
|
||||||
// is unchanged by them: raising their segment width past the caption headroom would reflow the
|
|
||||||
// first row and push the deck to a fourth one (see testDeckFitsInsideTheEnforcedMinimumWindow).
|
|
||||||
static void testTheModeTogglesCostNoGroupWidth() {
|
|
||||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
|
||||||
for (const DeckGroupDesc& g : sampleDeckGroups(mode)) {
|
|
||||||
if (g.captionToggle2.id < 0) continue;
|
|
||||||
DeckGroupDesc without = g;
|
|
||||||
without.captionToggle2 = DeckToggleDesc{};
|
|
||||||
CHECK(deckGroupWidth(g) == deckGroupWidth(without));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A typical larger window, to check the same properties once the deck has re-wrapped.
|
|
||||||
static constexpr int kAvailAtLargerWidth = 1100 - 2 * kPad;
|
|
||||||
|
|
||||||
// The gap fix as a property of the shipped descriptors, not a picture: whichever face a
|
|
||||||
// mode-dependent group shows, its knob row still spans the group's whole reserved run. The
|
|
||||||
// Trigger faces drop Sustain and Release and get wider cells for it — never a hole where the
|
|
||||||
// dropped control was. What the run does not cover is the indivisible residue alone, strictly
|
|
||||||
// under one pixel per cell.
|
|
||||||
static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() {
|
|
||||||
for (int avail : {kAvailAtMinWidth, kAvailAtLargerWidth}) {
|
|
||||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
|
||||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
|
||||||
const DeckLayout dl = layoutDeck(g, kPad, 0, avail);
|
|
||||||
CHECK(dl.groups.size() == g.size());
|
|
||||||
for (std::size_t i = 0; i < dl.groups.size(); ++i) {
|
|
||||||
// The spanning deck's slots STACK — the run-division law this pins is the
|
|
||||||
// horizontal one, and its vertical guard is its own test.
|
|
||||||
if (g[i].row == DeckRow::Spanning) continue;
|
|
||||||
const DeckGroupLayout& lay = dl.groups[i];
|
|
||||||
const int reserved = static_cast<int>(g[i].cellIds.size()) * kDeckCellW;
|
|
||||||
const std::size_t present = lay.cells.size();
|
|
||||||
CHECK(present > 0);
|
|
||||||
for (std::size_t k = 0; k < present; ++k) {
|
|
||||||
const DeckCellLayout& c = lay.cells[k];
|
|
||||||
CHECK(c.id >= 0); // a reserve yields width, never a dead rect
|
|
||||||
CHECK(c.cell.width == lay.cells[0].cell.width);
|
|
||||||
if (k > 0) CHECK(c.cell.x == lay.cells[k - 1].cell.right());
|
|
||||||
}
|
|
||||||
const int covered = lay.cells.back().cell.right() - lay.cells.front().cell.x;
|
|
||||||
CHECK(reserved - covered < static_cast<int>(present));
|
|
||||||
CHECK(lay.cells.front().cell.x >= lay.box.x + kDeckGroupPadX);
|
|
||||||
CHECK(lay.cells.back().cell.right() <= lay.box.right() - kDeckGroupPadX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The "residue lands in symmetric end margins" rule is knob_deck's own (layoutGroup), pinned
|
|
||||||
// once by its synthetic residue>=2 fixture in test_knob_deck.cpp rather than restated here.
|
|
||||||
|
|
||||||
// PITCH/RATE carries three cells and measures exactly 192 — the KNOB row (3 x kDeckCellW plus
|
|
||||||
// padding) is what it measures from, and the caption row must stay under that. The ceiling is
|
|
||||||
// asserted by construction rather than as a comment: at a caption reserve of 80 the group is
|
|
||||||
// still 192, and at 81 it is not, which is the whole content of "hard ceiling 80". Widening the
|
|
||||||
// group is not the remedy if the caption text ever outgrows it — narrowing the mode toggle is.
|
|
||||||
static void testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo() {
|
|
||||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
|
||||||
const DeckGroupDesc* pitch = nullptr;
|
|
||||||
for (const DeckGroupDesc& d : g) if (d.id == kGroupPitch) pitch = &d;
|
|
||||||
CHECK(pitch != nullptr);
|
|
||||||
if (!pitch) return;
|
|
||||||
CHECK(pitch->cellIds.size() == 3);
|
|
||||||
CHECK(pitch->cellIds[0] == static_cast<int>(DeckParam::kKeyTrack));
|
|
||||||
CHECK(pitch->cellIds[1] == static_cast<int>(DeckParam::kRate));
|
|
||||||
CHECK(pitch->cellIds[2] == static_cast<int>(DeckParam::kPitch));
|
|
||||||
CHECK(deckGroupWidth(*pitch) == 192);
|
|
||||||
CHECK(3 * kDeckCellW + 2 * kDeckGroupPadX == 192); // the knob row IS the measurement
|
|
||||||
|
|
||||||
DeckGroupDesc probe = *pitch;
|
|
||||||
probe.captionWidth = 80;
|
|
||||||
CHECK(deckGroupWidth(probe) == 192); // at the ceiling the caption row still fits under it
|
|
||||||
probe.captionWidth = 81;
|
|
||||||
CHECK(deckGroupWidth(probe) > 192); // one past it, the caption row takes over
|
|
||||||
}
|
|
||||||
|
|
||||||
// The kEnvModeSegW ceilings recorded in deck_groups.cpp's own comment (PITCH ENV binds at 47,
|
|
||||||
// AMP at 55) pinned against the descriptors they derive from, the same way the Pitch/Rate
|
|
||||||
// caption ceiling above is: a change to either group's caption width or its enable toggle
|
|
||||||
// would otherwise invalidate the recorded numbers with nothing failing.
|
|
||||||
static void testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp() {
|
|
||||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
|
||||||
const DeckGroupDesc& penv = g[static_cast<std::size_t>(indexOfGroup(g, kGroupPitchEnv))];
|
|
||||||
const DeckGroupDesc& amp = g[static_cast<std::size_t>(indexOfGroup(g, kGroupAmpEnv))];
|
|
||||||
CHECK(deckGroupWidth(penv) == 252);
|
|
||||||
CHECK(deckGroupWidth(amp) == 312);
|
|
||||||
|
|
||||||
DeckGroupDesc penvProbe = penv;
|
|
||||||
penvProbe.captionToggle2.segWidth = 47;
|
|
||||||
CHECK(deckGroupWidth(penvProbe) == 252); // at the ceiling, still knob-row-driven
|
|
||||||
penvProbe.captionToggle2.segWidth = 48;
|
|
||||||
CHECK(deckGroupWidth(penvProbe) > 252); // one past it, the caption row takes over
|
|
||||||
|
|
||||||
DeckGroupDesc ampProbe = amp;
|
|
||||||
ampProbe.captionToggle2.segWidth = 55;
|
|
||||||
CHECK(deckGroupWidth(ampProbe) == 312);
|
|
||||||
ampProbe.captionToggle2.segWidth = 56;
|
|
||||||
CHECK(deckGroupWidth(ampProbe) > 312);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every group's width, in BOTH play modes, against the measured layout table
|
|
||||||
// (instrument-control-surface.md §1.2). Mode-independence is the second half of the claim: the
|
|
||||||
// reserve slots hold the two mode-dependent groups at 312 either way, which is what makes the
|
|
||||||
// contour row's 876 a constant rather than a Gate-only fact.
|
|
||||||
static void testEveryGroupWidthMatchesTheMeasuredLayout() {
|
|
||||||
const struct { int id; int width; } want[] = {
|
|
||||||
{kGroupPitch, 192}, {kGroupPitchEnv, 252}, {kGroupFilter, 432},
|
|
||||||
{kGroupFilterEnv, 312}, {kGroupAmpEnv, 312}, {kGroupVelocity, 192},
|
|
||||||
{kGroupVoice, 164}, {kGroupMaster, 142},
|
|
||||||
};
|
|
||||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
|
||||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
|
||||||
CHECK(g.size() == sizeof(want) / sizeof(want[0]));
|
|
||||||
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
|
|
||||||
for (const auto& w : want) {
|
|
||||||
const int i = indexOfGroup(g, w.id);
|
|
||||||
CHECK(i >= 0);
|
|
||||||
if (i < 0) continue;
|
|
||||||
CHECK(deckGroupWidth(g[static_cast<std::size_t>(i)]) == w.width);
|
|
||||||
const DeckGroupLayout& lay =
|
|
||||||
dl.groups[static_cast<std::size_t>(indexOfGroup(g, w.id))];
|
|
||||||
CHECK(lay.box.width == w.width);
|
|
||||||
}
|
|
||||||
// Gate carries no reserves, so its cells are the deck's base size; Trigger's two
|
|
||||||
// reduced faces divide the same reserved run between fewer cells and get wider ones.
|
|
||||||
for (const DeckGroupLayout& lay : dl.groups) {
|
|
||||||
for (const DeckCellLayout& c : lay.cells) {
|
|
||||||
CHECK(c.cell.width >= kDeckCellW);
|
|
||||||
if (mode == PlayMode::Gate) CHECK(c.cell.width == kDeckCellW);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool sameToggle(const DeckToggleLayout& a, const DeckToggleLayout& b) {
|
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;
|
||||||
}
|
}
|
||||||
@@ -789,12 +443,12 @@ static bool sameLayout(const DeckLayout& a, const DeckLayout& b) {
|
|||||||
// forcing rule the real callers do not use.
|
// forcing rule the real callers do not use.
|
||||||
static void testGateSplineGateRoundTripsToTheSameLayout() {
|
static void testGateSplineGateRoundTripsToTheSameLayout() {
|
||||||
PlayParams p; // Gate, all three envelopes staged
|
PlayParams p; // Gate, all three envelopes staged
|
||||||
const DeckLayout before = layoutDeck(sampleDeckGroups(p.playMode), kPad, 0, kAvailAtMinWidth);
|
const DeckLayout before = layoutDeck(sampleDeckGroups(p.playMode), kSamplePad, 0, kSampleAvail);
|
||||||
|
|
||||||
p.ampSpline.mode = EnvMode::Spline;
|
p.ampSpline.mode = EnvMode::Spline;
|
||||||
enforceGateUnavailableWhileDrawn(p); // the shared helper both real callers route through
|
enforceGateUnavailableWhileDrawn(p); // the shared helper both real callers route through
|
||||||
CHECK(p.playMode == PlayMode::Trigger);
|
CHECK(p.playMode == PlayMode::Trigger);
|
||||||
const DeckLayout drawn = layoutDeck(sampleDeckGroups(p.playMode), kPad, 0, kAvailAtMinWidth);
|
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's cells are strictly wider than Gate's.
|
||||||
const DeckGroupLayout& gateAmp =
|
const DeckGroupLayout& gateAmp =
|
||||||
before.groups[static_cast<std::size_t>(indexOfGroup(sampleDeckGroups(PlayMode::Gate),
|
before.groups[static_cast<std::size_t>(indexOfGroup(sampleDeckGroups(PlayMode::Gate),
|
||||||
@@ -809,12 +463,11 @@ static void testGateSplineGateRoundTripsToTheSameLayout() {
|
|||||||
p.ampSpline.mode = EnvMode::Staged;
|
p.ampSpline.mode = EnvMode::Staged;
|
||||||
CHECK(!splineActive(p));
|
CHECK(!splineActive(p));
|
||||||
p.playMode = PlayMode::Gate; // Gate is selectable again once nothing is drawn
|
p.playMode = PlayMode::Gate; // Gate is selectable again once nothing is drawn
|
||||||
const DeckLayout after = layoutDeck(sampleDeckGroups(p.playMode), kPad, 0, kAvailAtMinWidth);
|
const DeckLayout after = layoutDeck(sampleDeckGroups(p.playMode), kSamplePad, 0, kSampleAvail);
|
||||||
CHECK(sameLayout(before, after));
|
CHECK(sameLayout(before, after));
|
||||||
}
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testTheModeTogglesCostNoGroupWidth();
|
|
||||||
testDeckReadsPitchThenFilterThenAmpLeftToRight();
|
testDeckReadsPitchThenFilterThenAmpLeftToRight();
|
||||||
testVelocityGroupOwnsTheThreeCurvesExclusively();
|
testVelocityGroupOwnsTheThreeCurvesExclusively();
|
||||||
testCurveTargetNamesEachCellsOwnDestination();
|
testCurveTargetNamesEachCellsOwnDestination();
|
||||||
@@ -825,22 +478,11 @@ int main() {
|
|||||||
testOnlySlopedStageKnobsCarryAnInnerCurveDial();
|
testOnlySlopedStageKnobsCarryAnInnerCurveDial();
|
||||||
testAmpGroupWidthSurvivesAGateTriggerFlip();
|
testAmpGroupWidthSurvivesAGateTriggerFlip();
|
||||||
testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction();
|
testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction();
|
||||||
testDeckFitsInsideTheEnforcedMinimumWindow();
|
|
||||||
testNoFaceLeavesSlackWhereItsDroppedControlsWere();
|
|
||||||
testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo();
|
|
||||||
testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp();
|
|
||||||
testEveryGroupWidthMatchesTheMeasuredLayout();
|
|
||||||
testGateSplineGateRoundTripsToTheSameLayout();
|
|
||||||
testTheEditorFloorIsDerivedFromTheDeckWidthBudget();
|
|
||||||
testEveryDeckGroupBelongsToExactlyOneRow();
|
testEveryDeckGroupBelongsToExactlyOneRow();
|
||||||
testBothRowsAndTheSpanningDeckFitTheBudget();
|
testNoFaceLeavesSlackWhereItsDroppedControlsWere();
|
||||||
testGutterArithmeticAndTheFilterTieLineAtTheFloor();
|
|
||||||
testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor();
|
|
||||||
testTheMasterDeckInteriorLandsOnBothRowBaselines();
|
|
||||||
testTheMasterColumnDoesNotDivideItsRunVertically();
|
|
||||||
testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow();
|
|
||||||
testHitTestResolvesTheNewFilterControls();
|
testHitTestResolvesTheNewFilterControls();
|
||||||
testBipolarKnobLawRoundTripsAndIsExactAtCentre();
|
testBipolarKnobLawRoundTripsAndIsExactAtCentre();
|
||||||
|
testGateSplineGateRoundTripsToTheSameLayout();
|
||||||
if (g_fail == 0) std::printf("deck_groups: all tests passed\n");
|
if (g_fail == 0) std::printf("deck_groups: all tests passed\n");
|
||||||
return g_fail == 0 ? 0 : 1;
|
return g_fail == 0 ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
// Layout-BUDGET tests for reasampler::instrument::ui::deck_groups, split from
|
||||||
|
// test_deck_groups.cpp on the seam CMakeLists.txt already named: these fixtures need
|
||||||
|
// sample_bands (the window-floor constants, computeSampleBands) and master_meter
|
||||||
|
// (kMeterColumnW, MASTER's reserve), which test_deck_groups.cpp's WHICH-descriptors fixtures do
|
||||||
|
// not. Pins the editor floor's derivation from the deck's width budget, the row/gutter
|
||||||
|
// justification arithmetic at and above the floor, the pinned Gate group widths, and MASTER's
|
||||||
|
// interior to the pixel. test_deck_groups.cpp pins WHICH descriptors the deck carries; this
|
||||||
|
// file pins what the width budget MEASURES them at.
|
||||||
|
|
||||||
|
#include "../src/core/instrument/ui/deck_groups.h"
|
||||||
|
#include "../src/core/instrument/ui/master_meter.h" // kMeterColumnW: MASTER's reserve IS this
|
||||||
|
#include "../src/core/instrument/ui/sample_bands.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace reasampler;
|
||||||
|
using namespace reasampler::instrument::ui;
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
// The editor's floor width, which is also its default (checkSizeConstraint clamps to it), less
|
||||||
|
// the band allocator's kPad inset on each side.
|
||||||
|
static constexpr int kAvailAtMinWidth = kEditorMinWidth - 2 * kPad;
|
||||||
|
|
||||||
|
static int indexOfGroup(const std::vector<DeckGroupDesc>& g, int id) {
|
||||||
|
for (std::size_t i = 0; i < g.size(); ++i) {
|
||||||
|
if (g[i].id == id) return static_cast<int>(i);
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int cell(DeckParam p) { return static_cast<int>(p); }
|
||||||
|
|
||||||
|
// The guard the raised floor exists to provide: at the smallest window the host can produce,
|
||||||
|
// the deck band still lands inside the client area AND the waveform still gets its two-lane
|
||||||
|
// floor. Growing the deck past what the floor height can hold fails HERE instead of silently pushing
|
||||||
|
// FILTER ENV / AMP / VOICE / MASTER off-screen, where there is no scroll to reach them.
|
||||||
|
static void testDeckFitsInsideTheEnforcedMinimumWindow() {
|
||||||
|
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||||
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
||||||
|
const int h = deckHeight(g);
|
||||||
|
const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h);
|
||||||
|
CHECK(deckRowCount(g) == 2); // either face
|
||||||
|
CHECK(b.decks.height == h);
|
||||||
|
// The reflow's 112 px land in the waveform: at two rows the deck band is 216 and the
|
||||||
|
// waveform 358, against 328/246 before. Pinned now that both are reached by
|
||||||
|
// construction rather than by a pack outcome.
|
||||||
|
CHECK(b.decks.height == 2 * kDeckGroupH + kDeckRowGap);
|
||||||
|
CHECK(b.waveform.height == 358);
|
||||||
|
// Bottom-anchored INSIDE the pad is the whole assertion: the degrade path pushes the
|
||||||
|
// deck down until the waveform hits its floor, so any deck too tall to fit stops
|
||||||
|
// landing on this exact line. A `<= kEditorMinHeight` bound would not catch it — the
|
||||||
|
// degrade can still leave the deck ending at the window edge.
|
||||||
|
CHECK(b.decks.bottom() == kEditorMinHeight - kPad);
|
||||||
|
CHECK(b.waveform.height >= kWaveformMinHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The floor is a DERIVED number, and this is the one place the derivation is written down —
|
||||||
|
// sample_bands stays independent of knob_deck, so neither header can hold it. This fixture is
|
||||||
|
// the only one that includes both.
|
||||||
|
static void testTheEditorFloorIsDerivedFromTheDeckWidthBudget() {
|
||||||
|
CHECK(kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW + 2 * kPad == kEditorMinWidth);
|
||||||
|
// The budget: what is left between the derived floor and the hard ceiling, and it is spent
|
||||||
|
// once. A cell costs 60 of it.
|
||||||
|
CHECK(kEditorCeilingWidth - kEditorMinWidth == 82);
|
||||||
|
// 82 still buys one more deck cell (60), which is the only purchase the ledger promises —
|
||||||
|
// the widen below spent 8 px of slack, not the layout's purchasing power.
|
||||||
|
CHECK(kEditorCeilingWidth - kEditorMinWidth >= kDeckCellW);
|
||||||
|
// The reflow's 112 px goes entirely to the waveform, so the height does not move.
|
||||||
|
CHECK(kEditorMinHeight == 680);
|
||||||
|
// 1190 + 8: the row block was widened 1020 -> 1028 to put the two rows' filter edges on
|
||||||
|
// one pixel, which is the only reason the floor moved off its originally specified value.
|
||||||
|
CHECK(kEditorMinWidth == 1198);
|
||||||
|
CHECK(kEditorMinWidth <= kEditorCeilingWidth);
|
||||||
|
CHECK(kEditorMinHeight <= 720);
|
||||||
|
// And the row block really is what the two rows justify inside — derived from the floor
|
||||||
|
// and the spanning reserve, not restated.
|
||||||
|
CHECK(kEditorMinWidth - 2 * kPad - kDeckSpanningW - kDeckGroupGap == kDeckRowBlockW);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both rows now fit their block, in BOTH play modes. Row 1's fit is the one this track closes:
|
||||||
|
// it was 1030, +42 from PITCH/RATE's third cell and −92 from FILTER's Band|Notch caption move
|
||||||
|
// take it to 980. Row 2's 876 is mode-stable because FILTER ENV's and AMP's reserve slots hold
|
||||||
|
// them at 312 in Trigger too — asserted here rather than assumed.
|
||||||
|
static void testBothRowsAndTheSpanningDeckFitTheBudget() {
|
||||||
|
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||||
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
||||||
|
int width[3] = {0, 0, 0};
|
||||||
|
int count[3] = {0, 0, 0};
|
||||||
|
for (const DeckGroupDesc& d : g) {
|
||||||
|
const int r = static_cast<int>(deckRowFor(static_cast<DeckGroupId>(d.id)));
|
||||||
|
width[r] += deckGroupWidth(d);
|
||||||
|
++count[r];
|
||||||
|
}
|
||||||
|
const int sound = static_cast<int>(DeckRow::Sound);
|
||||||
|
const int contour = static_cast<int>(DeckRow::Contour);
|
||||||
|
const int spanning = static_cast<int>(DeckRow::Spanning);
|
||||||
|
|
||||||
|
CHECK(count[sound] == 4);
|
||||||
|
CHECK(width[sound] == 980); // 192 + 432 + 192 + 164
|
||||||
|
CHECK(count[contour] == 3);
|
||||||
|
CHECK(width[contour] == 876); // 252 + 312 + 312
|
||||||
|
CHECK(count[spanning] == 1);
|
||||||
|
CHECK(width[spanning] == kDeckSpanningW); // 142 exactly — the reserve is now spent
|
||||||
|
|
||||||
|
for (int r : {sound, contour}) {
|
||||||
|
CHECK(width[r] <= kDeckRowBlockW);
|
||||||
|
// Slack enough that no gutter in the row falls under the minimum.
|
||||||
|
CHECK(kDeckRowBlockW - width[r] >= (count[r] - 1) * kDeckGroupGap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gutters the justification law produces at the floor, and the alignment they buy.
|
||||||
|
// At the 1028 block the justification law makes the tie-line exact by arithmetic rather than
|
||||||
|
// by a special rule: row 1's slack is 48 over three gutters (16 each, no residue) and row 2's
|
||||||
|
// is 152 over two (76 each), which lands both filter edges on 640. Only two of the three
|
||||||
|
// properties §1.3 once claimed can hold at once — a smallest gutter of exactly kDeckGroupGap
|
||||||
|
// needs a 1016 block — and 12 is a floor, not a target, so 16 satisfies the real rule.
|
||||||
|
static void testGutterArithmeticAndTheFilterTieLineAtTheFloor() {
|
||||||
|
const std::vector<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
|
||||||
|
// with no residue, so no gutter carries a leftover pixel.
|
||||||
|
CHECK(box(kGroupPitch).x == kPad);
|
||||||
|
CHECK(box(kGroupFilter).x - box(kGroupPitch).right() == 16);
|
||||||
|
CHECK(box(kGroupVelocity).x - box(kGroupFilter).right() == 16);
|
||||||
|
CHECK(box(kGroupVoice).x - box(kGroupVelocity).right() == 16);
|
||||||
|
CHECK(box(kGroupVoice).right() == kPad + kDeckRowBlockW);
|
||||||
|
|
||||||
|
// Row 2: flush left, flush right, two gutters exactly equal.
|
||||||
|
CHECK(box(kGroupPitchEnv).x == kPad);
|
||||||
|
CHECK(box(kGroupFilterEnv).x - box(kGroupPitchEnv).right() == 76);
|
||||||
|
CHECK(box(kGroupAmpEnv).x - box(kGroupFilterEnv).right() == 76);
|
||||||
|
CHECK(box(kGroupAmpEnv).right() == kPad + kDeckRowBlockW);
|
||||||
|
|
||||||
|
// The tie-line, block-relative: both filter edges on ONE pixel, which is what the widen
|
||||||
|
// bought. Pinned as an identity too, so a group-width change cannot pass by moving both.
|
||||||
|
CHECK(box(kGroupFilterEnv).right() - kPad == 640);
|
||||||
|
CHECK(box(kGroupFilter).right() - kPad == 640);
|
||||||
|
CHECK(box(kGroupFilter).right() == box(kGroupFilterEnv).right());
|
||||||
|
|
||||||
|
// MASTER is right-anchored outside the block, one kDeckGroupGap clear of it.
|
||||||
|
CHECK(box(kGroupMaster).x - box(kGroupVoice).right() == kDeckGroupGap);
|
||||||
|
CHECK(box(kGroupMaster).right() == kPad + kAvailAtMinWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No gutter is ever narrower than kDeckGroupGap at or above the floor, and both rows stay
|
||||||
|
// flush at every width — the property the exact-at-the-floor numbers above are one point of.
|
||||||
|
// Above the floor the tie-line DRIFTS, which is accepted and deliberate (§1.3): row 1 divides
|
||||||
|
// its slack over three gutters and row 2 over two, so row 2's filter edge pulls right past
|
||||||
|
// row 1's and the gap widens monotonically. Encoded as EXPECTED, not as a failure.
|
||||||
|
//
|
||||||
|
// Checked per ROW (tracking the last-seen box in each of the two categorical rows while
|
||||||
|
// walking dl.groups in deck order), not just deck-order neighbours: two same-row groups can
|
||||||
|
// sit apart in deck order with a different-row group between them, and a deck-order-only
|
||||||
|
// check would silently skip that gutter.
|
||||||
|
static void testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor() {
|
||||||
|
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||||
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
||||||
|
int lastDrift = 1 << 20; // sentinel above any real drift
|
||||||
|
for (int avail = kAvailAtMinWidth; avail <= kAvailAtMinWidth + 600; avail += 37) {
|
||||||
|
const DeckLayout dl = layoutDeck(g, kPad, 0, avail);
|
||||||
|
const DeckGroupLayout* prevInRow[2] = {nullptr, nullptr};
|
||||||
|
for (const DeckGroupLayout& gl : dl.groups) {
|
||||||
|
const DeckRow row = deckRowFor(static_cast<DeckGroupId>(gl.id));
|
||||||
|
if (row == DeckRow::Spanning) continue;
|
||||||
|
const int r = row == DeckRow::Contour ? 1 : 0;
|
||||||
|
if (prevInRow[r]) {
|
||||||
|
CHECK(gl.box.x - prevInRow[r]->box.right() >= kDeckGroupGap);
|
||||||
|
}
|
||||||
|
prevInRow[r] = ≷
|
||||||
|
}
|
||||||
|
const auto right = [&](int id) {
|
||||||
|
return dl.groups[static_cast<std::size_t>(indexOfGroup(g, id))].box.right();
|
||||||
|
};
|
||||||
|
// Flush right on the block at every width, both rows.
|
||||||
|
CHECK(right(kGroupVoice) == right(kGroupAmpEnv));
|
||||||
|
// Monotone in width rather than oscillating: row 2's two gutters absorb slack
|
||||||
|
// faster than row 1's three, so the gap only ever opens.
|
||||||
|
const int drift = right(kGroupFilter) - right(kGroupFilterEnv);
|
||||||
|
CHECK(drift <= lastDrift);
|
||||||
|
lastDrift = drift;
|
||||||
|
}
|
||||||
|
// It really does open up: the tie-line is exact AT the floor and separates above it,
|
||||||
|
// which is the accepted outcome rather than a near-miss to be pinned back.
|
||||||
|
CHECK(lastDrift < -50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MASTER's interior, exact to the pixel (§1.4). The two left slots sit on the two rows' own
|
||||||
|
// knob baselines — that is what "stitched to both rows" means — and the meter is ONE rect
|
||||||
|
// across both, never a readout per row.
|
||||||
|
static void testTheMasterDeckInteriorLandsOnBothRowBaselines() {
|
||||||
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||||
|
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
|
||||||
|
const DeckGroupLayout& m =
|
||||||
|
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
|
||||||
|
|
||||||
|
CHECK(m.box.width == kDeckSpanningW);
|
||||||
|
CHECK(m.box.height == 216);
|
||||||
|
// 6 + 60 + 8 + 62 + 6 — the decomposition, not just the total, and the 62 is the meter
|
||||||
|
// module's own kMeterColumnW rather than a copy of it. That link is the whole point: the
|
||||||
|
// column is banked to GROW (§1.2), and a reserve that did not track it would leave the
|
||||||
|
// interior underfilling or overrunning with every test still green.
|
||||||
|
CHECK(kDeckGroupPadX + kDeckCellW + kDeckColumnGap + kMeterColumnW + kDeckGroupPadX ==
|
||||||
|
kDeckSpanningW);
|
||||||
|
CHECK(m.column.id == cell(DeckParam::kMasterMeter));
|
||||||
|
CHECK(m.column.box.width == kMeterColumnW);
|
||||||
|
|
||||||
|
// One cell drawn (gain) and one slot RESERVED below it: the reserve is height at a fixed
|
||||||
|
// position and draws nothing.
|
||||||
|
CHECK(m.cells.size() == 1);
|
||||||
|
CHECK(m.cells[0].id == cell(DeckParam::kMasterGain));
|
||||||
|
CHECK(m.cells[0].cell.y - m.box.y == 26);
|
||||||
|
const int reserveTop = m.cells[0].cell.y + kDeckGroupH + kDeckRowGap;
|
||||||
|
CHECK(reserveTop - m.box.y == 138);
|
||||||
|
|
||||||
|
// The two baselines are row 1's and row 2's own.
|
||||||
|
const DeckGroupLayout& filter =
|
||||||
|
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
|
||||||
|
const DeckGroupLayout& amp =
|
||||||
|
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupAmpEnv))];
|
||||||
|
CHECK(m.cells[0].cell.y == filter.cells[0].cell.y);
|
||||||
|
CHECK(reserveTop == amp.cells[0].cell.y);
|
||||||
|
|
||||||
|
// The meter: one rect spanning both baselines, 62 x 186.
|
||||||
|
CHECK(m.column.id == cell(DeckParam::kMasterMeter));
|
||||||
|
CHECK(m.column.box.width == kMeterColumnW);
|
||||||
|
CHECK(m.column.box.height == 186);
|
||||||
|
CHECK(m.column.box.y == m.cells[0].cell.y);
|
||||||
|
CHECK(m.column.box.bottom() - m.box.y == 212);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The regression guard for the rule most likely to be "generalised" wrongly: MASTER's left
|
||||||
|
// column is FIXED slots at the two baselines, NOT knob_deck's horizontal run-division law
|
||||||
|
// applied vertically — which would stretch the one gain knob over the whole 186 px.
|
||||||
|
static void testTheMasterColumnDoesNotDivideItsRunVertically() {
|
||||||
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||||
|
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
|
||||||
|
const DeckGroupLayout& m =
|
||||||
|
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
|
||||||
|
CHECK(m.cells[0].cell.height == kDeckCellH);
|
||||||
|
CHECK(m.cells[0].cell.width == kDeckCellW);
|
||||||
|
// Under the run-division law the lone present cell would take the whole two-slot run;
|
||||||
|
// here it takes exactly one slot and leaves the rest empty.
|
||||||
|
CHECK(m.cells[0].cell.height < m.column.box.height);
|
||||||
|
CHECK(m.cells[0].cell.bottom() < m.column.box.bottom());
|
||||||
|
CHECK(m.cells[0].knob.width == kDeckKnobSize && m.cells[0].knob.height == kDeckKnobSize);
|
||||||
|
// And dropping the reserve does not move the gain knob or the meter — the slot below it is
|
||||||
|
// reserved height, so nothing above it depends on whether it is there.
|
||||||
|
std::vector<DeckGroupDesc> noReserve = g;
|
||||||
|
for (DeckGroupDesc& d : noReserve) {
|
||||||
|
if (d.id == kGroupMaster) d.cellIds = {cell(DeckParam::kMasterGain)};
|
||||||
|
}
|
||||||
|
const DeckLayout dl2 = layoutDeck(noReserve, kPad, 0, kAvailAtMinWidth);
|
||||||
|
const DeckGroupLayout& m2 =
|
||||||
|
dl2.groups[static_cast<std::size_t>(indexOfGroup(noReserve, kGroupMaster))];
|
||||||
|
CHECK(m2.cells[0].cell == m.cells[0].cell);
|
||||||
|
CHECK(m2.column.box == m.column.box);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MASTER's caption row and knob row measure exactly equal (130 == 130) today, so a column
|
||||||
|
// derived from either edge lands in the same place — that balance is what let a left-derived
|
||||||
|
// offset masquerade as right-anchored. Widen the caption reserve alone (as a wider caption or
|
||||||
|
// a limiter-toggle change would) and the column must still land flush against the group's own
|
||||||
|
// right padding, derived from innerRight rather than measured past the cell slots.
|
||||||
|
static void testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow() {
|
||||||
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||||
|
DeckGroupDesc probe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
|
||||||
|
probe.captionWidth += 40; // unbalances it: the caption row now measures past the knob row
|
||||||
|
const std::vector<DeckGroupDesc> one = {probe};
|
||||||
|
const DeckLayout dl = layoutDeck(one, kPad, 0, kAvailAtMinWidth);
|
||||||
|
const DeckGroupLayout& m = dl.groups[0];
|
||||||
|
CHECK(m.box.width > kDeckSpanningW); // the widen is real, not absorbed elsewhere
|
||||||
|
CHECK(m.column.box.right() == m.box.right() - kDeckGroupPadX);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three mode toggles ride each env group's caption slack, on the CONTOUR row: raising
|
||||||
|
// their segment width past the caption headroom would widen that row and eat its gutters,
|
||||||
|
// not add a row — row count is a property of the group inventory, not of width.
|
||||||
|
static void testTheModeTogglesCostNoGroupWidth() {
|
||||||
|
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||||
|
for (const DeckGroupDesc& g : sampleDeckGroups(mode)) {
|
||||||
|
if (g.captionToggle2.id < 0) continue;
|
||||||
|
DeckGroupDesc without = g;
|
||||||
|
without.captionToggle2 = DeckToggleDesc{};
|
||||||
|
CHECK(deckGroupWidth(g) == deckGroupWidth(without));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PITCH/RATE carries three cells and measures exactly 192 — the KNOB row (3 x kDeckCellW plus
|
||||||
|
// padding) is what it measures from, and the caption row must stay under that. The ceiling is
|
||||||
|
// asserted by construction rather than as a comment: at a caption reserve of 80 the group is
|
||||||
|
// still 192, and at 81 it is not, which is the whole content of "hard ceiling 80". Widening the
|
||||||
|
// group is not the remedy if the caption text ever outgrows it — narrowing the mode toggle is.
|
||||||
|
static void testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo() {
|
||||||
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||||
|
const DeckGroupDesc* pitch = nullptr;
|
||||||
|
for (const DeckGroupDesc& d : g) if (d.id == kGroupPitch) pitch = &d;
|
||||||
|
CHECK(pitch != nullptr);
|
||||||
|
if (!pitch) return;
|
||||||
|
CHECK(pitch->cellIds.size() == 3);
|
||||||
|
CHECK(pitch->cellIds[0] == static_cast<int>(DeckParam::kKeyTrack));
|
||||||
|
CHECK(pitch->cellIds[1] == static_cast<int>(DeckParam::kRate));
|
||||||
|
CHECK(pitch->cellIds[2] == static_cast<int>(DeckParam::kPitch));
|
||||||
|
CHECK(deckGroupWidth(*pitch) == 192);
|
||||||
|
CHECK(3 * kDeckCellW + 2 * kDeckGroupPadX == 192); // the knob row IS the measurement
|
||||||
|
|
||||||
|
DeckGroupDesc probe = *pitch;
|
||||||
|
probe.captionWidth = 80;
|
||||||
|
CHECK(deckGroupWidth(probe) == 192); // at the ceiling the caption row still fits under it
|
||||||
|
probe.captionWidth = 81;
|
||||||
|
CHECK(deckGroupWidth(probe) > 192); // one past it, the caption row takes over
|
||||||
|
}
|
||||||
|
|
||||||
|
// The kEnvModeSegW ceilings recorded in deck_groups.cpp's own comment (PITCH ENV binds at 47,
|
||||||
|
// AMP at 55) pinned against the descriptors they derive from, the same way the Pitch/Rate
|
||||||
|
// caption ceiling above is: a change to either group's caption width or its enable toggle
|
||||||
|
// would otherwise invalidate the recorded numbers with nothing failing.
|
||||||
|
static void testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp() {
|
||||||
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||||
|
const DeckGroupDesc& penv = g[static_cast<std::size_t>(indexOfGroup(g, kGroupPitchEnv))];
|
||||||
|
const DeckGroupDesc& amp = g[static_cast<std::size_t>(indexOfGroup(g, kGroupAmpEnv))];
|
||||||
|
CHECK(deckGroupWidth(penv) == 252);
|
||||||
|
CHECK(deckGroupWidth(amp) == 312);
|
||||||
|
|
||||||
|
DeckGroupDesc penvProbe = penv;
|
||||||
|
penvProbe.captionToggle2.segWidth = 47;
|
||||||
|
CHECK(deckGroupWidth(penvProbe) == 252); // at the ceiling, still knob-row-driven
|
||||||
|
penvProbe.captionToggle2.segWidth = 48;
|
||||||
|
CHECK(deckGroupWidth(penvProbe) > 252); // one past it, the caption row takes over
|
||||||
|
|
||||||
|
DeckGroupDesc ampProbe = amp;
|
||||||
|
ampProbe.captionToggle2.segWidth = 55;
|
||||||
|
CHECK(deckGroupWidth(ampProbe) == 312);
|
||||||
|
ampProbe.captionToggle2.segWidth = 56;
|
||||||
|
CHECK(deckGroupWidth(ampProbe) > 312);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every group's width, in BOTH play modes, against the measured layout table
|
||||||
|
// (instrument-control-surface.md §1.2). Mode-independence is the second half of the claim: the
|
||||||
|
// reserve slots hold the two mode-dependent groups at 312 either way, which is what makes the
|
||||||
|
// contour row's 876 a constant rather than a Gate-only fact.
|
||||||
|
static void testEveryGroupWidthMatchesTheMeasuredLayout() {
|
||||||
|
const struct { int id; int width; } want[] = {
|
||||||
|
{kGroupPitch, 192}, {kGroupPitchEnv, 252}, {kGroupFilter, 432},
|
||||||
|
{kGroupFilterEnv, 312}, {kGroupAmpEnv, 312}, {kGroupVelocity, 192},
|
||||||
|
{kGroupVoice, 164}, {kGroupMaster, 142},
|
||||||
|
};
|
||||||
|
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||||
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
||||||
|
CHECK(g.size() == sizeof(want) / sizeof(want[0]));
|
||||||
|
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
|
||||||
|
for (const auto& w : want) {
|
||||||
|
const int i = indexOfGroup(g, w.id);
|
||||||
|
CHECK(i >= 0);
|
||||||
|
if (i < 0) continue;
|
||||||
|
CHECK(deckGroupWidth(g[static_cast<std::size_t>(i)]) == w.width);
|
||||||
|
const DeckGroupLayout& lay =
|
||||||
|
dl.groups[static_cast<std::size_t>(indexOfGroup(g, w.id))];
|
||||||
|
CHECK(lay.box.width == w.width);
|
||||||
|
}
|
||||||
|
// Gate carries no reserves, so its cells are the deck's base size; Trigger's two
|
||||||
|
// reduced faces divide the same reserved run between fewer cells and get wider ones.
|
||||||
|
for (const DeckGroupLayout& lay : dl.groups) {
|
||||||
|
for (const DeckCellLayout& c : lay.cells) {
|
||||||
|
CHECK(c.cell.width >= kDeckCellW);
|
||||||
|
if (mode == PlayMode::Gate) CHECK(c.cell.width == kDeckCellW);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testDeckFitsInsideTheEnforcedMinimumWindow();
|
||||||
|
testTheEditorFloorIsDerivedFromTheDeckWidthBudget();
|
||||||
|
testBothRowsAndTheSpanningDeckFitTheBudget();
|
||||||
|
testGutterArithmeticAndTheFilterTieLineAtTheFloor();
|
||||||
|
testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor();
|
||||||
|
testTheMasterDeckInteriorLandsOnBothRowBaselines();
|
||||||
|
testTheMasterColumnDoesNotDivideItsRunVertically();
|
||||||
|
testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow();
|
||||||
|
testTheModeTogglesCostNoGroupWidth();
|
||||||
|
testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo();
|
||||||
|
testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp();
|
||||||
|
testEveryGroupWidthMatchesTheMeasuredLayout();
|
||||||
|
if (g_fail == 0) std::printf("deck_groups_measured: all tests passed\n");
|
||||||
|
return g_fail == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user