// Standalone tests for reasampler::instrument::ui::knob_deck — no VST3, no REAPER, no framework. Same fast
// assert loop as the sibling pure tests. Assert the r11 deck layout HARD:
//
//  * group width — caption row vs knob row max + padding; row-toggle and caption-toggle widths.
//  * layout — caption toggle right-anchored IN the caption row; cells abutting left-to-right
//    inside the box; knob square centered; label band beneath; row toggle after the cells.
//  * reserves — a -1 id holds the group's width and hands its pixels to the cells present.
//  * rows — membership comes from the group's own DeckRow, never from a wrap outcome;
//    space-between justification inside the row block; the right-anchored spanning deck.
//  * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, fence padding
//    misses, outside-deck misses.
//  * knob-FACE hit-test — the reset resolve against the drawn circles: inner disc, outer ring,
//    both exclusive boundaries, and the points where it deliberately disagrees with the cell.

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

#include <algorithm>
#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 representative deck shaped like the shell's: AMP (5 cells + caption toggle), PITCH
// (1 cell + caption toggle), PITCH ENV (3 cells + caption toggle), VOICE (1 cell + caption
// toggle + row toggle), MASTER (1 cell, no toggle).
static std::vector<DeckGroupDesc> shellLikeDeck() {
    std::vector<DeckGroupDesc> g;
    g.push_back({0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}});
    g.push_back({1, 38, {}, {101, 48}, {}, {6}, {}});
    g.push_back({2, 58, {}, {102, 32}, {}, {7, 8, 9}, {}});
    g.push_back({3, 38, {}, {103, 40}, {}, {10}, {104, 44}});
    g.push_back({4, 46, {}, {}, {}, {11}, {}});
    return g;
}

static void testGroupWidth() {
    // Knob row dominates: 5 cells (240) > caption row (78 + 4 + 88 = 170) -> 240 + 2*6.
    DeckGroupDesc amp{0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}};
    CHECK(deckGroupWidth(amp) == 5 * kDeckCellW + 2 * kDeckGroupPadX);
    // Caption row dominates: 38 + 4 + 96 = 138 > 48 -> 138 + 12.
    DeckGroupDesc pitch{1, 38, {}, {101, 48}, {}, {6}, {}};
    CHECK(deckGroupWidth(pitch) == 38 + kDeckToggleGap + 2 * 48 + 2 * kDeckGroupPadX);
    // Row toggle counts into the knob row: 48 + 4 + 88 = 140 > caption 38+4+80=122.
    DeckGroupDesc voice{3, 38, {}, {103, 40}, {}, {10}, {104, 44}};
    CHECK(deckGroupWidth(voice) ==
          kDeckCellW + kDeckToggleGap + 2 * 44 + 2 * kDeckGroupPadX);
    // No toggles: max(caption, cells) + padding.
    DeckGroupDesc master{4, 46, {}, {}, {}, {11}, {}};
    CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX);
    // A spanning group's cells STACK, so extra slots cost it no width — only its readout
    // column does. Two slots measure the same as one.
    DeckGroupDesc bus{5, 46, {}, {}, {}, {11}, {}, DeckRow::Spanning, {300, 62}};
    CHECK(deckGroupWidth(bus) == kDeckCellW + kDeckColumnGap + 62 + 2 * kDeckGroupPadX);
    bus.cellIds = {11, -1, -1};
    CHECK(deckGroupWidth(bus) == kDeckCellW + kDeckColumnGap + 62 + 2 * kDeckGroupPadX);
}

// A two-row deck with a spanning bus deck, shaped like the shipped one but with synthetic
// widths: two Sound groups, two Contour groups, one Spanning group carrying a column.
static std::vector<DeckGroupDesc> tworowDeck() {
    std::vector<DeckGroupDesc> g;
    g.push_back({0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}, DeckRow::Sound, {}});
    g.push_back({1, 38, {}, {101, 48}, {}, {6, 7}, {}, DeckRow::Sound, {}});
    g.push_back({2, 58, {}, {102, 32}, {}, {8, 9, 10}, {}, DeckRow::Contour, {}});
    g.push_back({3, 38, {}, {103, 40}, {}, {11}, {}, DeckRow::Contour, {}});
    g.push_back({4, 46, {200, true}, {104, 32}, {}, {12, -1}, {},
                 DeckRow::Spanning, {300, 62}});
    return g;
}

// Row membership is the GROUP's, and nothing about the width can change it: the same list at
// three very different widths lays out as the same two rows plus the same spanning deck.
static void testRowMembershipComesFromTheGroupNotTheWidth() {
    const auto deck = tworowDeck();
    CHECK(deckRowCount(deck) == 2);
    CHECK(deckHeight(deck) == 2 * kDeckGroupH + kDeckRowGap);
    CHECK(deckHeight(deck) == kDeckSpanningH);

    for (int avail : {600, 1174, 2000}) {
        const DeckLayout dl = layoutDeck(deck, 8, 100, avail);
        CHECK(dl.rowCount == 2);
        CHECK(dl.height == deckHeight(deck));
        CHECK(dl.groups.size() == 5);
        // The output is in DECK order, not row order — a layout pairs with the descriptor at
        // the same index whichever row it landed in.
        CHECK(dl.groups[0].id == 0 && dl.groups[1].id == 1);
        CHECK(dl.groups[2].id == 2 && dl.groups[3].id == 3);
        CHECK(dl.groups[4].id == 4);
        CHECK(dl.groups[0].box.y == 100 && dl.groups[1].box.y == 100);
        const int row1Top = 100 + kDeckGroupH + kDeckRowGap;
        CHECK(dl.groups[2].box.y == row1Top && dl.groups[3].box.y == row1Top);
        // Both rows start flush left.
        CHECK(dl.groups[0].box.x == 8 && dl.groups[2].box.x == 8);
        // The spanning deck stands across both rows and is right-anchored.
        CHECK(dl.groups[4].box.y == 100);
        CHECK(dl.groups[4].box.height == kDeckSpanningH);
        CHECK(dl.groups[4].box.right() == 8 + avail);
    }
}

// Space-between: slack becomes gutters, divided equally with the integer residue on the
// LEFTMOST ones, and the row ends flush against the block. Decks are never stretched.
static void testJustificationSpreadsSlackIntoEqualGutters() {
    const auto deck = tworowDeck();
    const int soundW = deckGroupWidth(deck[0]) + deckGroupWidth(deck[1]);
    const int contourW = deckGroupWidth(deck[2]) + deckGroupWidth(deck[3]);
    const int spanW = deckGroupWidth(deck[4]);

    const int avail = 900;
    const int block = avail - spanW - kDeckGroupGap;
    const DeckLayout dl = layoutDeck(deck, 8, 0, avail);
    // Natural widths, unstretched.
    CHECK(dl.groups[0].box.width == deckGroupWidth(deck[0]));
    CHECK(dl.groups[1].box.width == deckGroupWidth(deck[1]));
    // One gutter per row here, so it takes the whole slack and both rows end on the block.
    CHECK(dl.groups[1].box.x - dl.groups[0].box.right() == block - soundW);
    CHECK(dl.groups[3].box.x - dl.groups[2].box.right() == block - contourW);
    CHECK(dl.groups[1].box.right() == 8 + block);
    CHECK(dl.groups[3].box.right() == 8 + block);

    // Three gutters over an indivisible slack: base everywhere, +1 on the leftmost ones.
    std::vector<DeckGroupDesc> four = {deck[0], deck[1], deck[1], deck[1]};
    int total = 0;
    for (const auto& g : four) total += deckGroupWidth(g);
    const int block4 = 940;
    const DeckLayout d4 = layoutDeck(four, 0, 0, block4);
    const int slack = block4 - total;
    CHECK(slack % 3 != 0);  // the case the residue rule exists for
    const int base = slack / 3;
    const int residue = slack % 3;
    for (int i = 0; i < 3; ++i) {
        const int gut = d4.groups[static_cast<std::size_t>(i + 1)].box.x -
                        d4.groups[static_cast<std::size_t>(i)].box.right();
        CHECK(gut == base + (i < residue ? 1 : 0));
        CHECK(gut >= kDeckGroupGap);
    }
    CHECK(d4.groups.back().box.right() == block4);  // flush right
}

// Below the width the block needs, gutters floor at kDeckGroupGap and the row overruns to the
// right. It never wraps — the editor clamps its window above this, so the degrade only has to
// be defined, not pretty.
static void testTooNarrowFloorsTheGuttersRatherThanWrapping() {
    const auto deck = tworowDeck();
    CHECK(deckRowCount(deck) == 2);  // unchanged: a row count is not a width outcome
    const DeckLayout dl = layoutDeck(deck, 0, 0, 200);
    CHECK(dl.rowCount == 2);
    CHECK(dl.height == 2 * kDeckGroupH + kDeckRowGap);
    CHECK(dl.groups[1].box.x - dl.groups[0].box.right() == kDeckGroupGap);
    CHECK(dl.groups[3].box.x - dl.groups[2].box.right() == kDeckGroupGap);
    CHECK(dl.groups[1].box.right() > 200);  // overruns rather than wrapping
}

// The spanning deck's left column uses FIXED slots at the row baselines. Applying the
// horizontal run-division law vertically would stretch its one knob over the whole box — this
// is the regression guard against exactly that.
static void testSpanningColumnStacksFixedSlotsAndCarriesItsReadout() {
    const auto deck = tworowDeck();
    const DeckLayout dl = layoutDeck(deck, 8, 100, 900);
    const DeckGroupLayout& bus = dl.groups[4];
    CHECK(bus.cells.size() == 1);  // the -1 slot reserves height without drawing a cell

    const DeckCellLayout& gain = bus.cells[0];
    CHECK(gain.cell.width == kDeckCellW);   // fixed, NOT the box's inner width
    CHECK(gain.cell.height == kDeckCellH);  // fixed, NOT half the double-height box
    CHECK(gain.cell.x == bus.box.x + kDeckGroupPadX);
    // Slot 0 shares row 0's knob baseline; the reserve below it shares row 1's.
    CHECK(gain.cell.y == dl.groups[0].cells[0].cell.y);
    const int reserveTop = gain.cell.y + kDeckGroupH + kDeckRowGap;
    CHECK(reserveTop == dl.groups[2].cells[0].cell.y);

    // ONE readout rect spanning both slots, right of the cell column, flush to the padding.
    CHECK(bus.column.id == 300);
    CHECK(bus.column.box.width == 62);
    CHECK(bus.column.box.x == gain.cell.right() + kDeckColumnGap);
    CHECK(bus.column.box.right() == bus.box.right() - kDeckGroupPadX);
    CHECK(bus.column.box.y == gain.cell.y);
    CHECK(bus.column.box.bottom() == bus.box.bottom() - kDeckGroupPadY);
    CHECK(bus.column.box.height == kDeckSpanningH - kDeckGroupPadY - kDeckCaptionH -
                                       kDeckCaptionGap - kDeckGroupPadY);

    // The group is exactly as wide as its two columns plus padding.
    CHECK(deckGroupWidth(deck[4]) ==
          2 * kDeckGroupPadX + kDeckCellW + kDeckColumnGap + 62);

    // The column answers its own hit kind; the cell above it still answers as a knob.
    const DeckHit col = hitTestDeck(dl, bus.column.box.x + 4, bus.column.box.y + 40);
    CHECK(col.kind == DeckHitKind::Column && col.id == 300);
    const DeckHit knob = hitTestDeck(dl, gain.cell.x + 4, gain.cell.y + 4);
    CHECK(knob.kind == DeckHitKind::Knob && knob.id == 12);
    // The reserved slot draws nothing and answers nothing — it is height, not a control.
    CHECK(hitTestDeck(dl, gain.cell.x + 4, reserveTop + 4).kind == DeckHitKind::None);
}

// A passive corner radio keeps its rect (the shell draws a lamp there) but is unreachable by
// the hit-test, so no gesture can grow on it by accident.
static void testPassiveRadioIsLaidOutButNeverHit() {
    const auto deck = tworowDeck();
    const DeckLayout dl = layoutDeck(deck, 8, 100, 900);
    const DeckGroupLayout& bus = dl.groups[4];
    CHECK(bus.captionRadio.id == 200);
    CHECK(bus.captionRadio.passive);
    CHECK(bus.captionRadio.box.width == kDeckRadioSize);
    CHECK(bus.captionRadio.box.right() == bus.box.right() - kDeckGroupPadX);
    const DeckHit h = hitTestDeck(dl, bus.captionRadio.box.x + 2, bus.captionRadio.box.y + 2);
    CHECK(h.kind == DeckHitKind::None);

    // An INTERACTIVE radio in the same slot still answers — the flag is what changed, not the
    // geometry.
    std::vector<DeckGroupDesc> active{deck[4]};
    active[0].captionRadio.passive = false;
    const DeckLayout dl2 = layoutDeck(active, 0, 0, 400);
    const DeckHit h2 = hitTestDeck(dl2, dl2.groups[0].captionRadio.box.x + 2,
                                   dl2.groups[0].captionRadio.box.y + 2);
    CHECK(h2.kind == DeckHitKind::CaptionRadio && h2.id == 200);
}

// A deck with only a spanning group is as tall as that group, not as tall as zero rows.
static void testSpanningOnlyDeckKeepsItsHeight() {
    std::vector<DeckGroupDesc> only{tworowDeck()[4]};
    CHECK(deckRowCount(only) == 0);
    CHECK(deckHeight(only) == kDeckSpanningH);
    const DeckLayout dl = layoutDeck(only, 0, 0, 400);
    CHECK(dl.rowCount == 0);
    CHECK(dl.height == kDeckSpanningH);
    CHECK(dl.groups.size() == 1);
}

static void testGroupInnerGeometry() {
    const auto deck = shellLikeDeck();
    const DeckLayout dl = layoutDeck(deck, 8, 50, 824);
    const DeckGroupLayout& amp = dl.groups[0];
    // Caption row at the top padding; caption toggle right-anchored inside the box.
    CHECK(amp.caption.y == amp.box.y + kDeckGroupPadY);
    CHECK(amp.captionToggle.id == 100);
    CHECK(amp.captionToggle.seg1.right() == amp.box.right() - kDeckGroupPadX);
    CHECK(amp.captionToggle.seg0.right() == amp.captionToggle.seg1.x);
    CHECK(amp.captionToggle.seg0.width == 44 && amp.captionToggle.seg1.width == 44);
    CHECK(amp.captionToggle.seg0.height == kDeckToggleH);
    // The caption text rect stops before the toggle.
    CHECK(amp.caption.right() <= amp.captionToggle.seg0.x);
    // Cells: five, fixed size, abutting, inside the box, below the caption row.
    CHECK(static_cast<int>(amp.cells.size()) == 5);
    for (std::size_t i = 0; i < amp.cells.size(); ++i) {
        const DeckCellLayout& c = amp.cells[i];
        CHECK(c.cell.width == kDeckCellW && c.cell.height == kDeckCellH);
        CHECK(c.cell.y == amp.box.y + kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap);
        if (i > 0) CHECK(c.cell.x == amp.cells[i - 1].cell.right());
        // Knob square centered horizontally, label band beneath it, both inside the cell.
        CHECK(c.knob.width == kDeckKnobSize && c.knob.height == kDeckKnobSize);
        CHECK(c.knob.x - c.cell.x == c.cell.right() - c.knob.right());
        CHECK(c.label.y >= c.knob.bottom());
        CHECK(c.label.bottom() <= c.cell.bottom());
    }
    // VOICE group's row toggle sits after its cell, vertically centered in the cell row.
    const DeckGroupLayout& voice = dl.groups[3];
    CHECK(voice.rowToggle.id == 104);
    CHECK(voice.rowToggle.seg0.x == voice.cells[0].cell.right() + kDeckToggleGap);
    CHECK(voice.rowToggle.seg0.height == kDeckToggleH);
    CHECK(voice.rowToggle.seg0.y > voice.cells[0].cell.y);
    // MASTER has no toggles.
    CHECK(dl.groups[4].captionToggle.id == -1);
    CHECK(dl.groups[4].rowToggle.id == -1);
}

static void testHitTest() {
    const auto deck = shellLikeDeck();
    const DeckLayout dl = layoutDeck(deck, 8, 50, 824);
    const DeckGroupLayout& amp = dl.groups[0];

    // Knob hit: anywhere in the cell (including the label band) resolves to the cell id.
    const DeckCellLayout& c0 = amp.cells[0];
    DeckHit h = hitTestDeck(dl, c0.cell.x + 1, c0.cell.y + 1);
    CHECK(h.kind == DeckHitKind::Knob && h.id == 1 && h.segment == -1);
    h = hitTestDeck(dl, c0.label.x + 2, c0.label.y + 2);
    CHECK(h.kind == DeckHitKind::Knob && h.id == 1);

    // Caption toggle segments 0/1 at their boundary: last px of seg0, first px of seg1.
    h = hitTestDeck(dl, amp.captionToggle.seg0.right() - 1, amp.captionToggle.seg0.y + 1);
    CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 100 && h.segment == 0);
    h = hitTestDeck(dl, amp.captionToggle.seg1.x, amp.captionToggle.seg1.y + 1);
    CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 100 && h.segment == 1);

    // Row toggle.
    const DeckGroupLayout& voice = dl.groups[3];
    h = hitTestDeck(dl, voice.rowToggle.seg1.x + 1, voice.rowToggle.seg1.y + 1);
    CHECK(h.kind == DeckHitKind::RowToggle && h.id == 104 && h.segment == 1);

    // A reserve (id -1) yields no cell of its own. This fixture's reserve divides its present
    // cells evenly (5 slots / 3 present -> 240/3, no residue), so every point of the knob row
    // lands on a real control: no dead rect survives for a grab to fall into. That does NOT
    // generalize to an indivisible reserve — a residue leaves a few uncovered margin pixels by
    // design (testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds, below).
    std::vector<DeckGroupDesc> trig;
    trig.push_back({0, 78, {}, {100, 44}, {}, {20, 21, 22, -1, -1}, {}});
    const DeckLayout tl = layoutDeck(trig, 0, 0, 824);
    const DeckGroupLayout& tg = tl.groups[0];
    CHECK(tg.cells.size() == 3);
    for (const DeckCellLayout& c : tg.cells) CHECK(c.id >= 0);
    // Bound the sweep against the RESERVED run (5 slots, not the 3 present cells) rather than
    // the cells' own extent — the cells are what's under test, so deriving the bound from them
    // could never catch a layout that under-covers the run they were reserved out of.
    const int runStart = tg.box.x + kDeckGroupPadX;
    const int runEnd = runStart + static_cast<int>(trig[0].cellIds.size()) * kDeckCellW;
    const int rowY = tg.cells.back().cell.y + 5;
    for (int px = runStart; px < runEnd; ++px) {
        const DeckHit rowHit = hitTestDeck(tl, px, rowY);
        CHECK(rowHit.kind == DeckHitKind::Knob && rowHit.id >= 0);
    }

    // The fence padding inside the box misses; outside the deck misses.
    h = hitTestDeck(dl, amp.box.x + 1, amp.box.bottom() - 1);
    CHECK(h.kind == DeckHitKind::None);
    h = hitTestDeck(dl, -50, -50);
    CHECK(h.kind == DeckHitKind::None);
}

// A reserve holds the group's WIDTH and hands its pixels to the cells that are present. The
// three properties together are what stops a narrower face reading as a hole: the group is
// exactly as wide as the full-face one, the cells are uniform and abutting, and what they do
// not cover is smaller than one pixel per cell.
static void testReservedCellWidthGoesToTheCellsPresent() {
    const DeckGroupDesc full{0, 78, {}, {100, 44}, {}, {20, 21, 22, 23, 24}, {}};
    // Three, four, and a lone cell against the same five-slot reserve.
    const std::vector<std::vector<int>> faces = {
        {20, 21, 22, -1, -1}, {20, 21, 22, 23, -1}, {20, -1, -1, -1, -1}};
    for (const std::vector<int>& ids : faces) {
        DeckGroupDesc narrow = full;
        narrow.cellIds = ids;
        CHECK(deckGroupWidth(narrow) == deckGroupWidth(full));

        std::vector<DeckGroupDesc> g{narrow};
        const DeckLayout dl = layoutDeck(g, 0, 0, 824);
        const DeckGroupLayout& lay = dl.groups[0];
        const int present = static_cast<int>(lay.cells.size());
        CHECK(present == 5 - static_cast<int>(std::count(ids.begin(), ids.end(), -1)));

        const int run = 5 * kDeckCellW;
        for (int i = 0; i < present; ++i) {
            const DeckCellLayout& c = lay.cells[static_cast<std::size_t>(i)];
            CHECK(c.cell.width == lay.cells[0].cell.width);       // uniform
            CHECK(c.knob.width == kDeckKnobSize);                 // the dial itself is fixed
            // Centred as exactly as integers allow: a cell whose spare width is odd cannot
            // split it evenly, and the layout's integer division gives the odd pixel to the
            // RIGHT margin. Pinned as a directional identity rather than a tolerance, so a
            // future off-by-one on the other side would still fail here.
            const int leftGap = c.knob.x - c.cell.x;
            const int rightGap = c.cell.right() - c.knob.right();
            CHECK(rightGap - leftGap == (c.cell.width - kDeckKnobSize) % 2);
            if (i > 0) CHECK(c.cell.x == lay.cells[static_cast<std::size_t>(i - 1)].cell.right());
        }
        // Uncovered run is the indivisible residue only, split evenly at the two ends.
        const int covered = lay.cells.back().cell.right() - lay.cells[0].cell.x;
        CHECK(run - covered < present);
        const int leadPad = lay.cells[0].cell.x - (lay.box.x + kDeckGroupPadX);
        CHECK(leadPad == (run - covered) / 2);
    }

    // A reserve does not move the row toggle: it anchors past the whole run, so the FILTER
    // group's law switch cannot drift when a neighbouring face changes shape.
    DeckGroupDesc withToggle{1, 40, {}, {}, {}, {20, 21, 22, 23, 24}, {104, 44}};
    std::vector<DeckGroupDesc> a{withToggle};
    withToggle.cellIds = {20, 21, -1, -1, -1};
    std::vector<DeckGroupDesc> b{withToggle};
    CHECK(layoutDeck(a, 0, 0, 824).groups[0].rowToggle.seg0 ==
          layoutDeck(b, 0, 0, 824).groups[0].rowToggle.seg0);
}

// The three faces above all divide their run evenly, so none of them actually exercises
// "residue in symmetric end margins". An 8-slot reserve with 7 present (480/7 = 68 r4) does:
// residue 4 is the smallest case that can tell a symmetric split (2/2) apart from a
// trailing-only one (0/4) — a residue of 1 can't, since leadPad = residue/2 rounds to 0 either
// way, which is exactly why this seam's earlier test passed without pinning the rule it was
// named for.
static void testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds() {
    const DeckGroupDesc g{0, 78, {}, {100, 44}, {}, {20, 21, 22, 23, 24, 25, 26, -1}, {}};
    std::vector<DeckGroupDesc> gs{g};
    const DeckLayout dl = layoutDeck(gs, 0, 0, 824);
    const DeckGroupLayout& lay = dl.groups[0];
    CHECK(lay.cells.size() == 7);

    const int run = 8 * kDeckCellW;
    const int present = 7;
    const int cellW = run / present;               // 76: the same integer division the layout uses
    const int expectedResidue = run - cellW * present;  // 4
    CHECK(expectedResidue == 4);

    const int covered = lay.cells.back().cell.right() - lay.cells.front().cell.x;
    CHECK(run - covered == expectedResidue);
    const int leadPad = lay.cells.front().cell.x - (lay.box.x + kDeckGroupPadX);
    const int trailPad = (lay.box.right() - kDeckGroupPadX) - lay.cells.back().cell.right();
    // Hard literals, not just the formula: this is the case that actually distinguishes
    // symmetric (2/2) from trailing-only (0/4) — see the comment above.
    CHECK(leadPad == 2);
    CHECK(trailPad == 2);
    CHECK(leadPad == expectedResidue / 2);
    CHECK(trailPad == expectedResidue - leadPad);  // both ends share it, not one absorbing it
}

// The corner radio widens the caption row, takes the far corner, and pushes the caption
// toggle left of itself — the three properties the overlay-select switch relies on.
static void testCaptionRadioGeometryAndHit() {
    const DeckGroupDesc bare{7, 78, {}, {200, 44}, {}, {1, 2}, {}};
    const DeckGroupDesc withRadio{7, 78, {201}, {200, 44}, {}, {1, 2}, {}};
    // Caption row grows by exactly gap + radio; the knob row is unchanged, so a group whose
    // caption row already dominated grows by that much.
    CHECK(deckGroupWidth(withRadio) - deckGroupWidth(bare) ==
          kDeckToggleGap + kDeckRadioSize);

    std::vector<DeckGroupDesc> g{withRadio};
    const DeckLayout dl = layoutDeck(g, 0, 0, 800);
    const DeckGroupLayout& lay = dl.groups[0];
    CHECK(lay.captionRadio.id == 201);
    CHECK(lay.captionRadio.box.width == kDeckRadioSize);
    // Far corner: flush with the group's inner right edge.
    CHECK(lay.captionRadio.box.right() == lay.box.right() - kDeckGroupPadX);
    // The toggle sits entirely left of the radio, and the caption text left of the toggle.
    CHECK(lay.captionToggle.seg1.right() <= lay.captionRadio.box.x);
    CHECK(lay.caption.right() <= lay.captionToggle.seg0.x);

    const DeckHit h = hitTestDeck(dl, lay.captionRadio.box.x + 2, lay.captionRadio.box.y + 2);
    CHECK(h.kind == DeckHitKind::CaptionRadio && h.id == 201);
}

// The inner dial is a concentric sub-region of the knob: a grab there still names the cell,
// with `inner` set, so a cell with no inner value simply ignores the flag.
static void testInnerDialHit() {
    const std::vector<DeckGroupDesc> g = shellLikeDeck();
    const DeckLayout dl = layoutDeck(g, 0, 0, 900);
    const DeckCellLayout& c = dl.groups[0].cells[0];
    CHECK(c.inner.width == kDeckInnerDialSize && c.inner.height == kDeckInnerDialSize);
    // Concentric with the knob square.
    CHECK(c.inner.x + c.inner.width / 2 == c.knob.x + c.knob.width / 2);
    CHECK(c.inner.y + c.inner.height / 2 == c.knob.y + c.knob.height / 2);

    DeckHit h = hitTestDeck(dl, c.inner.x + c.inner.width / 2, c.inner.y + c.inner.height / 2);
    CHECK(h.kind == DeckHitKind::Knob && h.id == c.id && h.inner);
    // A grab on the outer ring is the same cell WITHOUT the inner flag.
    h = hitTestDeck(dl, c.knob.x + 1, c.knob.y + 1);
    CHECK(h.kind == DeckHitKind::Knob && h.id == c.id && !h.inner);
}

// captionToggle2 sits immediately left of captionToggle when both are present (no overlap, and
// the caption text stops before the LEFTMOST one), and takes captionToggle's own slot when
// captionToggle is absent — the shipped FILTER ENV group's exact shape (deck_groups.cpp).
static void testCaptionToggle2() {
    const DeckGroupDesc both{9, 40, {}, {300, 30}, {301, 20}, {1, 2, 3}, {}};
    std::vector<DeckGroupDesc> g{both};
    const DeckLayout dl = layoutDeck(g, 0, 0, 800);
    const DeckGroupLayout& lay = dl.groups[0];
    CHECK(lay.captionToggle.id == 300);
    CHECK(lay.captionToggle2.id == 301);
    CHECK(lay.captionToggle2.seg0.width == 20 && lay.captionToggle2.seg1.width == 20);
    // Left of the first, with exactly one gap between — no overlap by construction.
    CHECK(lay.captionToggle2.seg1.right() == lay.captionToggle.seg0.x - kDeckToggleGap);
    // Caption text stops before the LEFTMOST toggle (toggle2), not just the first-placed one.
    CHECK(lay.caption.right() <= lay.captionToggle2.seg0.x);

    DeckHit h = hitTestDeck(dl, lay.captionToggle2.seg0.right() - 1,
                            lay.captionToggle2.seg0.y + 1);
    CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 301 && h.segment == 0);
    h = hitTestDeck(dl, lay.captionToggle2.seg1.x, lay.captionToggle2.seg1.y + 1);
    CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 301 && h.segment == 1);

    // FILTER ENV's real shape: captionToggle absent, captionToggle2 present with a radio — it
    // takes the first (rightmost) slot rather than leaving a gap where captionToggle would sit.
    const DeckGroupDesc filterEnvLike{10, 66, {200}, {}, {302, 23}, {1, 2, 3, 4, 5}, {}};
    std::vector<DeckGroupDesc> g2{filterEnvLike};
    const DeckLayout dl2 = layoutDeck(g2, 0, 0, 800);
    const DeckGroupLayout& fe = dl2.groups[0];
    CHECK(fe.captionToggle.id == -1);
    CHECK(fe.captionToggle2.id == 302);
    CHECK(fe.captionToggle2.seg1.right() == fe.captionRadio.box.x - kDeckToggleGap);
    h = hitTestDeck(dl2, fe.captionToggle2.seg1.x, fe.captionToggle2.seg1.y + 1);
    CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 302 && h.segment == 1);
}

// The double-click RESET resolve. Unlike hitTestDeck's whole-cell grab, this one answers the
// drawn circles: inner disc -> inner target, outer ring -> outer target, anything off the dial
// (the label band, the cell margin, outside the deck) -> neither. Both boundaries are exclusive.
static void testKnobFaceResolvesInnerRingOuterRingAndMisses() {
    const std::vector<DeckGroupDesc> g = shellLikeDeck();
    const DeckLayout dl = layoutDeck(g, 0, 0, 900);
    const DeckCellLayout& c = dl.groups[0].cells[0];
    const int cx = c.knob.x + c.knob.width / 2;
    const int cy = c.knob.y + c.knob.height / 2;
    const int rOuter = c.knob.width / 2;
    const int rInner = c.inner.width / 2;
    CHECK(rInner > 0 && rInner < rOuter);

    // Dead centre is the inner target; just inside the inner radius still is.
    DeckFaceHit h = hitTestKnobFace(dl, cx, cy);
    CHECK(h.id == c.id && h.inner);
    h = hitTestKnobFace(dl, cx + rInner - 1, cy);
    CHECK(h.id == c.id && h.inner);
    // EXACTLY on the inner radius is the outer ring — the boundary belongs to neither disc.
    h = hitTestKnobFace(dl, cx + rInner, cy);
    CHECK(h.id == c.id && !h.inner);
    // Just inside the rim is still the outer ring...
    h = hitTestKnobFace(dl, cx + rOuter - 1, cy);
    CHECK(h.id == c.id && !h.inner);
    // ...and EXACTLY on the rim is a miss, by the same exclusive rule.
    h = hitTestKnobFace(dl, cx + rOuter, cy);
    CHECK(h.id == -1 && !h.inner);

    // The cell corner is inside the CELL (hitTestDeck resolves it as a grab) but outside the
    // circle — the two resolves deliberately disagree there.
    CHECK(hitTestDeck(dl, c.cell.x + 1, c.cell.y + 1).kind == DeckHitKind::Knob);
    CHECK(hitTestKnobFace(dl, c.cell.x + 1, c.cell.y + 1).id == -1);
    // The label band under the knob: a grab anchor, never a reset target.
    CHECK(hitTestDeck(dl, c.label.x + 2, c.label.y + 2).kind == DeckHitKind::Knob);
    CHECK(hitTestKnobFace(dl, c.label.x + 2, c.label.y + 2).id == -1);
    // Off the deck entirely.
    CHECK(hitTestKnobFace(dl, -50, -50).id == -1);
    // A diagonal at 45 degrees inside the rim: proves the resolve is radial, not the inscribed
    // square a rect test would accept — this point is inside the knob RECT but outside the disc.
    const int diag = static_cast<int>(rOuter * 0.75) + 1;  // dist ~ 1.06 * rOuter
    CHECK(hitTestKnobFace(dl, cx + diag, cy + diag).id == -1);
}

// A non-square knob rect (e.g. a squashed chrome row clamps knob height below its width) must
// resolve against min(width, height)/2 — the same radius computeKnob draws — never against
// width alone, or the hit disc would claim territory above/below where nothing is drawn.
static void testInKnobFaceUsesTheSmallerDimensionOnANonSquareRect() {
    const Rect wide{0, 0, 40, 20};  // width > height: draws a 10px-radius disc, not 20px
    const int cx = wide.x + wide.width / 2;
    const int cy = wide.y + wide.height / 2;
    CHECK(inKnobFace(wide, cx, cy));           // dead centre always hits
    CHECK(inKnobFace(wide, cx, cy + 9));       // just inside the drawn (height-limited) radius
    CHECK(!inKnobFace(wide, cx, cy + 10));     // on the drawn rim: exclusive miss
    CHECK(!inKnobFace(wide, cx, cy + 15));     // inside the RECT but outside the smaller-radius disc
    CHECK(!inKnobFace(wide, cx + 15, cy));     // same check along the wider axis

    // No mirrored tall{20,40} case: min() is symmetric in its two arguments, so a tall rect
    // can't discriminate width/2 from min(w,h)/2 any differently than wide already does.
}

static void testEmptyDeck() {
    const std::vector<DeckGroupDesc> none;
    CHECK(deckRowCount(none) == 0);
    CHECK(deckHeight(none) == 0);
    const DeckLayout dl = layoutDeck(none, 0, 0, 800);
    CHECK(dl.groups.empty() && dl.rowCount == 0 && dl.height == 0);
}

int main() {
    testGroupWidth();
    testRowMembershipComesFromTheGroupNotTheWidth();
    testJustificationSpreadsSlackIntoEqualGutters();
    testTooNarrowFloorsTheGuttersRatherThanWrapping();
    testSpanningColumnStacksFixedSlotsAndCarriesItsReadout();
    testPassiveRadioIsLaidOutButNeverHit();
    testSpanningOnlyDeckKeepsItsHeight();
    testGroupInnerGeometry();
    testHitTest();
    testReservedCellWidthGoesToTheCellsPresent();
    testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds();
    testCaptionRadioGeometryAndHit();
    testInnerDialHit();
    testKnobFaceResolvesInnerRingOuterRingAndMisses();
    testInKnobFaceUsesTheSmallerDimensionOnANonSquareRect();
    testCaptionToggle2();
    testEmptyDeck();
    if (g_fail) {
        std::printf("%d FAILURE(S)\n", g_fail);
        return 1;
    }
    std::printf("knob_deck tests passed\n");
    return 0;
}
