// Standalone tests for reasampler::instrument::ui::deck_groups — no VST3, no REAPER, no
// framework. Pins WHICH descriptors the Sample face carries and how they resolve to a layout:
// the signal-flow group order (pitch -> filter -> amp), the Filter group's contents, the
// VELOCITY group's exclusive ownership of the three curve cells and its placement immediately
// left of VOICE, row membership, that no face leaves slack where its dropped controls were,
// that a Gate/Spline/Gate round trip restores the layout exactly, the hit-test reaching the new
// filter controls, and the bipolar knob law's inverse pair. The width-BUDGET fixtures (the
// editor floor's derivation, the row/gutter arithmetic at the floor, MASTER's interior) live in
// test_deck_groups_measured.cpp, which needs sample_bands/master_meter and this file does not.
// The commit-tier routing and the overlay-selection state machine live in
// test_deck_groups_state.cpp — they touch no layout at all.

#include "../src/core/instrument/ui/deck_groups.h"

#include <cmath>
#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)

// A pad and an available width for exercising layoutDeck, kept independent of sample_bands.h —
// this file pins what the deck IS, not the window-floor budget. kSampleAvail equals the real
// 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) {
    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); }

static void testDeckReadsPitchThenFilterThenAmpLeftToRight() {
    for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
        const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
        const int pitch  = indexOfGroup(g, kGroupPitch);
        const int penv   = indexOfGroup(g, kGroupPitchEnv);
        const int filt   = indexOfGroup(g, kGroupFilter);
        const int fenv   = indexOfGroup(g, kGroupFilterEnv);
        const int amp    = indexOfGroup(g, kGroupAmpEnv);
        CHECK(pitch >= 0 && penv >= 0 && filt >= 0 && fenv >= 0 && amp >= 0);
        // The signal flow, left to right. Each envelope group trails its own stage.
        CHECK(pitch < penv);
        CHECK(penv < filt);
        CHECK(filt < fenv);
        CHECK(fenv < amp);
        // VELOCITY then the two instance-wide groups at the end. Velocity sits IMMEDIATELY
        // left of VOICE — MASTER is reserved for post-voice-mixer concerns, so the curves
        // must not drift into it.
        const int vel = indexOfGroup(g, kGroupVelocity);
        CHECK(amp < vel);
        CHECK(vel + 1 == indexOfGroup(g, kGroupVoice));
        CHECK(indexOfGroup(g, kGroupVoice) < indexOfGroup(g, kGroupMaster));
    }
}

// The three velocity curves live together in VELOCITY and nowhere else: no other group may
// carry a curve cell, or the "one home" the group exists for is not one.
static void testVelocityGroupOwnsTheThreeCurvesExclusively() {
    for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
        const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
        const DeckGroupDesc& v =
            g[static_cast<std::size_t>(indexOfGroup(g, kGroupVelocity))];
        const std::vector<int> expected = {cell(DeckParam::kAmpVelCurve),
                                           cell(DeckParam::kPitchVelCurve),
                                           cell(DeckParam::kFilterVelCurve)};
        CHECK(v.cellIds == expected);
        CHECK(v.captionToggle.id == -1 && v.rowToggle.id == -1 && v.captionRadio.id == -1);
        for (const DeckGroupDesc& d : g) {
            if (d.id == kGroupVelocity) continue;
            for (int id : d.cellIds) CHECK(curveTargetFor(id) == CurveTarget::kNone);
            CHECK(curveTargetFor(d.captionToggle.id) == CurveTarget::kNone);
            CHECK(curveTargetFor(d.rowToggle.id) == CurveTarget::kNone);
        }
    }
}

// Each curve cell names its OWN destination, and an ordinary knob names none — the predicate
// the shell uses to tell a popup opener from a dial.
static void testCurveTargetNamesEachCellsOwnDestination() {
    CHECK(curveTargetFor(cell(DeckParam::kAmpVelCurve)) == CurveTarget::kAmp);
    CHECK(curveTargetFor(cell(DeckParam::kPitchVelCurve)) == CurveTarget::kPitch);
    CHECK(curveTargetFor(cell(DeckParam::kFilterVelCurve)) == CurveTarget::kFilter);
    CHECK(curveTargetFor(cell(DeckParam::kFilterCutoff)) == CurveTarget::kNone);
    CHECK(curveTargetFor(cell(DeckParam::kMasterGain)) == CurveTarget::kNone);
    CHECK(curveTargetFor(-1) == CurveTarget::kNone);   // a width reserve, not a control
    CHECK(curveTargetFor(9999) == CurveTarget::kNone); // out of the id space
}

// The cells hit-test inside their own group, from the centre of each cell — the deck grammar
// treats them as knob cells, so the popup routing rides an ordinary Knob hit.
static void testVelocityCellsHitTestWithinTheirGroup() {
    const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
    const DeckLayout dl = layoutDeck(g, kSamplePad, 40, kSampleAvail);
    const DeckGroupLayout& v =
        dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupVelocity))];
    CHECK(v.cells.size() == 3);
    const CurveTarget want[] = {CurveTarget::kAmp, CurveTarget::kPitch, CurveTarget::kFilter};
    for (std::size_t i = 0; i < v.cells.size(); ++i) {
        const DeckCellLayout& c = v.cells[i];
        const DeckHit hit = hitTestDeck(dl, c.cell.x + c.cell.width / 2,
                                        c.cell.y + c.cell.height / 2);
        CHECK(hit.kind == DeckHitKind::Knob);
        CHECK(hit.id == c.id);
        CHECK(curveTargetFor(hit.id) == want[i]);
        // Inside its own group box, and the cell the hit resolved is this one.
        CHECK(c.cell.x >= v.box.x && c.cell.right() <= v.box.right());
    }
}

static void testFilterGroupCarriesItsToneControlsPlusModulation() {
    const std::vector<DeckGroupDesc>& g = sampleDeckGroups(PlayMode::Gate);
    const DeckGroupDesc& f = g[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
    const std::vector<int> expected = {
        cell(DeckParam::kFilterMorph),  cell(DeckParam::kFilterCutoff),
        cell(DeckParam::kFilterQ),      cell(DeckParam::kFilterDrive),
        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 372.
    CHECK(f.captionToggle.id == cell(DeckParam::kFilterEnable));
    CHECK(f.captionToggle2.id == cell(DeckParam::kFilterLaw));
    CHECK(f.rowToggle.id == -1);

    const DeckGroupDesc& fe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupFilterEnv))];
    const std::vector<int> env = {
        cell(DeckParam::kFilterEnvAttack), cell(DeckParam::kFilterEnvHold),
        cell(DeckParam::kFilterEnvDecay),  cell(DeckParam::kFilterEnvSustain),
        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);
}

// 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);
        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;
            CHECK(d.captionRadio.passive);
            CHECK(d.id == kGroupMaster);
            CHECK(d.captionRadio.id == cell(DeckParam::kMasterGr));
            ++lamps;
        }
        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);
    }
}

// The mode-driven shape switch, on BOTH the amp and the filter envelope: Gate shows the
// AHDSR's five stages, Trigger the AHD's three (behind the play span on the amp deck), and
// neither mode leaks the other's controls onto the deck.
static void testGateAndTriggerFacesCarryTheirOwnShapes() {
    const std::vector<DeckGroupDesc> gate = sampleDeckGroups(PlayMode::Gate);
    const std::vector<DeckGroupDesc> trig = sampleDeckGroups(PlayMode::Trigger);
    const DeckGroupDesc& gAmp = gate[static_cast<std::size_t>(indexOfGroup(gate, kGroupAmpEnv))];
    const DeckGroupDesc& tAmp = trig[static_cast<std::size_t>(indexOfGroup(trig, kGroupAmpEnv))];
    const std::vector<int> gateAmp = {cell(DeckParam::kAttack), cell(DeckParam::kHold),
                                      cell(DeckParam::kDecay), cell(DeckParam::kSustain),
                                      cell(DeckParam::kRelease)};
    const std::vector<int> trigAmp = {cell(DeckParam::kTrigLength), cell(DeckParam::kTrigAttack),
                                      cell(DeckParam::kTrigHold), cell(DeckParam::kTrigDecay),
                                      -1};
    CHECK(gAmp.cellIds == gateAmp);
    CHECK(tAmp.cellIds == trigAmp);

    const DeckGroupDesc& gFe = gate[static_cast<std::size_t>(indexOfGroup(gate, kGroupFilterEnv))];
    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::kFilterModAmt)};
    CHECK(tFe.cellIds == trigFe);
    CHECK(gFe.cellIds != tFe.cellIds);
    // Same cell count either way, so the group's width — and its neighbours' placement —
    // survives a mode flip.
    CHECK(gFe.cellIds.size() == tFe.cellIds.size());
    CHECK(deckGroupWidth(gFe) == deckGroupWidth(tFe));
}

// Every SLOPED stage knob carries an inner curve dial; Hold, Sustain, and everything that is
// not a stage carries none. This is the "which segments are sloped" rule, asserted rather than
// read.
static void testOnlySlopedStageKnobsCarryAnInnerCurveDial() {
    const DeckParam sloped[] = {
        DeckParam::kAttack, DeckParam::kDecay, DeckParam::kRelease,
        DeckParam::kTrigAttack, DeckParam::kTrigDecay,
        DeckParam::kPitchEnvAttack, DeckParam::kPitchEnvDecay,
        DeckParam::kFilterEnvAttack, DeckParam::kFilterEnvDecay, DeckParam::kFilterEnvRelease,
        DeckParam::kFilterTrigAttack, DeckParam::kFilterTrigDecay,
    };
    for (DeckParam p : sloped) {
        const DeckParam c = curveParamFor(p);
        CHECK(c != DeckParam::kCount);
        // A curve control is itself flat — no inner dial on an inner dial.
        CHECK(curveParamFor(c) == DeckParam::kCount);
    }
    const DeckParam flat[] = {
        DeckParam::kHold, DeckParam::kSustain, DeckParam::kTrigHold,
        DeckParam::kPitchEnvHold, DeckParam::kFilterEnvHold, DeckParam::kFilterEnvSustain,
        DeckParam::kFilterTrigHold, DeckParam::kTrigLength, DeckParam::kPitchEnvDepth,
        DeckParam::kFilterCutoff, DeckParam::kMasterGain, DeckParam::kKeyTrack,
    };
    for (DeckParam p : flat) CHECK(curveParamFor(p) == DeckParam::kCount);

    // Every sloped knob maps to a DISTINCT curve control — a copy-paste that pointed two
    // stages at one exponent would tie two dials together silently.
    for (std::size_t i = 0; i < sizeof(sloped) / sizeof(sloped[0]); ++i) {
        for (std::size_t j = i + 1; j < sizeof(sloped) / sizeof(sloped[0]); ++j) {
            CHECK(curveParamFor(sloped[i]) != curveParamFor(sloped[j]));
        }
    }
}

static void testAmpGroupWidthSurvivesAGateTriggerFlip() {
    // The reserved blanks are what stop a mode flip reflowing the groups beside AMP.
    const std::vector<DeckGroupDesc> gate = sampleDeckGroups(PlayMode::Gate);
    const std::vector<DeckGroupDesc> trig = sampleDeckGroups(PlayMode::Trigger);
    const DeckGroupDesc& a = gate[static_cast<std::size_t>(indexOfGroup(gate, kGroupAmpEnv))];
    const DeckGroupDesc& b = trig[static_cast<std::size_t>(indexOfGroup(trig, kGroupAmpEnv))];
    CHECK(deckGroupWidth(a) == deckGroupWidth(b));
    CHECK(a.cellIds.size() == b.cellIds.size());
    CHECK(b.cellIds[4] == -1);  // the Trigger face's one reserved blank
    // Every other group is mode-independent, so the whole deck's height is too.
    CHECK(deckHeight(gate) == deckHeight(trig));
}

// TWO rows plus the spanning deck, BY CONSTRUCTION: the row count is read off the group
// inventory's own row assignment, not observed as a pack outcome, so it holds at every width.
static void testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction() {
    for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
        const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
        CHECK(deckRowCount(g) == 2);
        CHECK(deckHeight(g) == 2 * kDeckGroupH + kDeckRowGap);
        CHECK(deckHeight(g) == 216);

        for (int avail : {kSampleAvail, kSampleAvail + 200, 4000}) {
            const DeckLayout dl = layoutDeck(g, kSamplePad, 0, avail);
            CHECK(dl.rowCount == 2);
            CHECK(dl.height == 216);
            CHECK(dl.groups.size() == g.size());
            int rowTops[2] = {0, kDeckGroupH + kDeckRowGap};
            for (const DeckGroupLayout& gl : dl.groups) {
                const DeckRow row = deckRowFor(static_cast<DeckGroupId>(gl.id));
                if (row == DeckRow::Spanning) {
                    CHECK(gl.box.y == 0);
                    CHECK(gl.box.height == kDeckSpanningH);
                    CHECK(gl.box.right() == kSamplePad + avail);  // right-anchored at every width
                } else {
                    CHECK(gl.box.y == rowTops[row == DeckRow::Contour ? 1 : 0]);
                    CHECK(gl.box.height == kDeckGroupH);
                }
            }
        }
    }
}

static void testEveryDeckGroupBelongsToExactlyOneRow() {
    CHECK(deckRowFor(kGroupPitch) == DeckRow::Sound);
    CHECK(deckRowFor(kGroupFilter) == DeckRow::Sound);
    CHECK(deckRowFor(kGroupVelocity) == DeckRow::Sound);
    CHECK(deckRowFor(kGroupVoice) == DeckRow::Sound);
    CHECK(deckRowFor(kGroupPitchEnv) == DeckRow::Contour);
    CHECK(deckRowFor(kGroupFilterEnv) == DeckRow::Contour);
    CHECK(deckRowFor(kGroupAmpEnv) == DeckRow::Contour);
    CHECK(deckRowFor(kGroupMaster) == DeckRow::Spanning);

    // Totality against the descriptor list the deck actually carries, not just against the
    // enum: a group that shipped without a row would land here as a miscount.
    for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
        int sound = 0, contour = 0, spanning = 0;
        for (const DeckGroupDesc& d : sampleDeckGroups(mode)) {
            switch (deckRowFor(static_cast<DeckGroupId>(d.id))) {
                case DeckRow::Sound:    ++sound;    break;
                case DeckRow::Contour:  ++contour;  break;
                case DeckRow::Spanning: ++spanning; break;
            }
        }
        CHECK(sound == 4 && contour == 3 && spanning == 1);
    }
}

// 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 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];
                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 == kDeckCellW);
                    if (k > 0) CHECK(c.cell.x == lay.cells[k - 1].cell.right());
                }
                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 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);
    const DeckLayout dl = layoutDeck(g, kSamplePad, 40, kSampleAvail);
    const DeckGroupLayout& f =
        dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];

    // Every knob cell resolves to its own id, from the centre of its cell.
    for (const DeckCellLayout& c : f.cells) {
        const DeckHit hit = hitTestDeck(dl, c.cell.x + c.cell.width / 2,
                                        c.cell.y + c.cell.height / 2);
        CHECK(hit.kind == DeckHitKind::Knob);
        CHECK(hit.id == c.id);
    }
    CHECK(f.cells.size() == 6);
    CHECK(f.cells[1].id == cell(DeckParam::kFilterCutoff));

    // 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
    // segment either.
    const DeckHit band = hitTestDeck(dl, f.captionToggle2.seg0.x + 2,
                                     f.captionToggle2.seg0.y + 2);
    CHECK(band.kind == DeckHitKind::CaptionToggle);
    CHECK(band.id == cell(DeckParam::kFilterLaw) && band.segment == 0);
    const DeckHit notch = hitTestDeck(dl, f.captionToggle2.seg1.x + 2,
                                      f.captionToggle2.seg1.y + 2);
    CHECK(notch.id == cell(DeckParam::kFilterLaw) && notch.segment == 1);

    // The filter-envelope knobs resolve too, and are distinct ids from the amp's.
    const DeckGroupLayout& fe =
        dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilterEnv))];
    const DeckHit attack = hitTestDeck(dl, fe.cells[0].cell.x + 4, fe.cells[0].cell.y + 4);
    CHECK(attack.kind == DeckHitKind::Knob);
    CHECK(attack.id == cell(DeckParam::kFilterEnvAttack));
    CHECK(attack.id != cell(DeckParam::kAttack));
}

static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
    // Centre is EXACT in both directions: a knob parked at 0.5 stores 0, and 0 reads back
    // 0.5 — no residual modulation from a rounding hair.
    CHECK(deckBipolarFromNorm(0.5) == 0.0);
    CHECK(deckNormFromBipolar(0.0) == 0.5);
    CHECK(deckBipolarFromNorm(0.0) == -1.0);
    CHECK(deckBipolarFromNorm(1.0) == 1.0);
    for (int i = 0; i <= 200; ++i) {
        const double norm = static_cast<double>(i) / 200.0;
        CHECK(std::fabs(deckNormFromBipolar(deckBipolarFromNorm(norm)) - norm) < 1e-12);
        const double value = -1.0 + static_cast<double>(i) / 100.0;
        CHECK(std::fabs(deckBipolarFromNorm(deckNormFromBipolar(value)) - value) < 1e-12);
    }
    // Out of range clamps rather than extrapolating.
    CHECK(deckBipolarFromNorm(-3.0) == -1.0);
    CHECK(deckBipolarFromNorm(3.0) == 1.0);
    CHECK(deckNormFromBipolar(-3.0) == 0.0);
    CHECK(deckNormFromBipolar(3.0) == 1.0);
}

static bool sameToggle(const DeckToggleLayout& a, const DeckToggleLayout& b) {
    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) {
    if (a.rowCount != b.rowCount || a.height != b.height ||
        a.groups.size() != b.groups.size()) return false;
    for (std::size_t i = 0; i < a.groups.size(); ++i) {
        const DeckGroupLayout& x = a.groups[i];
        const DeckGroupLayout& y = b.groups[i];
        if (x.id != y.id || !(x.box == y.box) || !(x.caption == y.caption)) return false;
        if (x.captionRadio.id != y.captionRadio.id ||
            !(x.captionRadio.box == y.captionRadio.box) ||
            x.captionRadio.passive != y.captionRadio.passive) return false;
        if (x.column.id != y.column.id || !(x.column.box == y.column.box)) return false;
        if (!sameToggle(x.captionToggle, y.captionToggle) ||
            !sameToggle(x.captionToggle2, y.captionToggle2) ||
            !sameToggle(x.rowToggle, y.rowToggle)) return false;
        if (x.cells.size() != y.cells.size()) return false;
        for (std::size_t k = 0; k < x.cells.size(); ++k) {
            const DeckCellLayout& c = x.cells[k];
            const DeckCellLayout& d = y.cells[k];
            if (c.id != d.id || !(c.cell == d.cell) || !(c.knob == d.knob) ||
                !(c.inner == d.inner) || !(c.label == d.label)) return false;
        }
    }
    return true;
}

// A Spline excursion is fully reversible at the layout level: the mode forcing swaps the amp
// and filter faces onto their wider cells and back, leaving no residue in the geometry. Driven
// through the shared enforceGateUnavailableWhileDrawn helper, so the deck cannot agree with a
// forcing rule the real callers do not use.
static void testGateSplineGateRoundTripsToTheSameLayout() {
    PlayParams p;  // Gate, all three envelopes staged
    const DeckLayout before = layoutDeck(sampleDeckGroups(p.playMode), kSamplePad, 0, kSampleAvail);

    p.ampSpline.mode = EnvMode::Spline;
    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 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))];
    const DeckGroupLayout& trigAmp =
        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.x > gateAmp.cells[0].cell.x);
    CHECK(!sameLayout(before, drawn));

    p.ampSpline.mode = EnvMode::Staged;
    CHECK(!splineActive(p));
    p.playMode = PlayMode::Gate;  // Gate is selectable again once nothing is drawn
    const DeckLayout after = layoutDeck(sampleDeckGroups(p.playMode), kSamplePad, 0, kSampleAvail);
    CHECK(sameLayout(before, after));
}

int main() {
    testDeckReadsPitchThenFilterThenAmpLeftToRight();
    testVelocityGroupOwnsTheThreeCurvesExclusively();
    testCurveTargetNamesEachCellsOwnDestination();
    testVelocityCellsHitTestWithinTheirGroup();
    testFilterGroupCarriesItsToneControlsPlusModulation();
    testTheFilterModDepthLivesWithTheFilterEnvelopeInBothFaces();
    testNoGroupCarriesASelectableRadioAndMasterKeepsItsLamp();
    testTheOverlayFocusMapNamesTheThreeEnvelopeDecksAndNothingElse();
    testTheConvertedTogglesAreSingleButtonsAndTheRestStaySegmented();
    testGateAndTriggerFacesCarryTheirOwnShapes();
    testOnlySlopedStageKnobsCarryAnInnerCurveDial();
    testAmpGroupWidthSurvivesAGateTriggerFlip();
    testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction();
    testEveryDeckGroupBelongsToExactlyOneRow();
    testEveryCellKeepsItsNaturalPitchInBothFaces();
    testTheReducedTriggerFacesAreTheSameKnobsAsGateJustCentred();
    testHitTestResolvesTheNewFilterControls();
    testBipolarKnobLawRoundTripsAndIsExactAtCentre();
    testGateSplineGateRoundTripsToTheSameLayout();
    if (g_fail == 0) std::printf("deck_groups: all tests passed\n");
    return g_fail == 0 ? 0 : 1;
}
