// 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 pays for it in the two end margins, with // the run of present cells centred at their natural width. // * rows — membership comes from the group's own DeckRow, never from a wrap outcome; // space-between justification inside the row block; the right-anchored spanning deck. // * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, the single-button // styles' no-segment answer, the group id every hit carries, fence padding misses, // outside-deck misses. // * knob-FACE hit-test — the reset resolve against the drawn circles: inner disc, outer ring, // both exclusive boundaries, and the points where it deliberately disagrees with the cell. #include "../src/core/instrument/ui/knob_deck.h" #include #include #include 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 shellLikeDeck() { std::vector g; g.push_back({0, 78, {}, {100, 88}, {}, {1, 2, 3, 4, 5}, {}}); g.push_back({1, 38, {}, {101, 96}, {}, {6}, {}}); g.push_back({2, 58, {}, {102, 64}, {}, {7, 8, 9}, {}}); g.push_back({3, 38, {}, {103, 80}, {}, {10}, {104, 88}}); g.push_back({4, 46, {}, {}, {}, {11}, {}}); return g; } static void testGroupWidth() { // Knob row dominates: 5 cells (300) > caption row (78 + 4 + 88 = 170) -> 300 + 2*6. DeckGroupDesc amp{0, 78, {}, {100, 88}, {}, {1, 2, 3, 4, 5}, {}}; CHECK(deckGroupWidth(amp) == 5 * kDeckCellW + 2 * kDeckGroupPadX); // Caption row dominates: 38 + 4 + 96 = 138 > 60 -> 138 + 12. DeckGroupDesc pitch{1, 38, {}, {101, 96}, {}, {6}, {}}; CHECK(deckGroupWidth(pitch) == 38 + kDeckToggleGap + 96 + 2 * kDeckGroupPadX); // Row toggle counts into the knob row: 60 + 4 + 88 = 152 > caption 38+4+80=122. DeckGroupDesc voice{3, 38, {}, {103, 80}, {}, {10}, {104, 88}}; CHECK(deckGroupWidth(voice) == kDeckCellW + kDeckToggleGap + 88 + 2 * kDeckGroupPadX); // A toggle's `width` is the WHOLE control either way, so a single button and a segmented // one of the same declared width cost the group exactly the same. DeckGroupDesc single = voice; single.captionToggle.style = DeckToggleStyle::kEnable; CHECK(deckGroupWidth(single) == deckGroupWidth(voice)); // No toggles: max(caption, cells) + padding. DeckGroupDesc master{4, 46, {}, {}, {}, {11}, {}}; CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX); // 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 tworowDeck() { std::vector g; g.push_back({0, 78, {}, {100, 88}, {}, {1, 2, 3, 4, 5}, {}, DeckRow::Sound, {}}); g.push_back({1, 38, {}, {101, 96}, {}, {6, 7}, {}, DeckRow::Sound, {}}); g.push_back({2, 58, {}, {102, 64}, {}, {8, 9, 10}, {}, DeckRow::Contour, {}}); g.push_back({3, 38, {}, {103, 80}, {}, {11}, {}, DeckRow::Contour, {}}); g.push_back({4, 46, {200, true}, {104, 64}, {}, {12, -1}, {}, DeckRow::Spanning, {300, 62}}); return g; } // 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 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(i + 1)].box.x - d4.groups[static_cast(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 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 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(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, and the cells present cover their CENTRED // run contiguously — no dead rect between them for a grab to fall into. What the reserve // buys is margin at the two ends, which is a deliberate miss and pinned as one below. std::vector trig; trig.push_back({0, 78, {}, {100, 88}, {}, {20, 21, 22, -1, -1}, {}}); const DeckLayout tl = layoutDeck(trig, 0, 0, 824); const DeckGroupLayout& tg = tl.groups[0]; CHECK(tg.cells.size() == 3); for (const DeckCellLayout& c : tg.cells) CHECK(c.id >= 0); const int rowY = tg.cells.back().cell.y + 5; for (int px = tg.cells.front().cell.x; px < tg.cells.back().cell.right(); ++px) { const DeckHit rowHit = hitTestDeck(tl, px, rowY); CHECK(rowHit.kind == DeckHitKind::Knob && rowHit.id >= 0); } // The reserve's own pixels answer no control — but they still name the group, which is // what makes the deck panel's background a target for the overlay focus. const DeckHit margin = hitTestDeck(tl, tg.box.x + kDeckGroupPadX + 1, rowY); CHECK(margin.kind == DeckHitKind::None && margin.group == 0); // The fence padding inside the box misses as a control and names its group; outside the // deck misses entirely, group included. h = hitTestDeck(dl, amp.box.x + 1, amp.box.bottom() - 1); CHECK(h.kind == DeckHitKind::None && h.id == -1 && h.group == 0); h = hitTestDeck(dl, -50, -50); CHECK(h.kind == DeckHitKind::None && h.group == -1); // Every hit kind carries the group it landed in, so the shell never has to re-scan the // layout to find out which deck a click belongs to. CHECK(hitTestDeck(dl, c0.cell.x + 1, c0.cell.y + 1).group == 0); CHECK(hitTestDeck(dl, amp.captionToggle.seg1.x, amp.captionToggle.seg1.y + 1).group == 0); CHECK(hitTestDeck(dl, voice.rowToggle.seg1.x + 1, voice.rowToggle.seg1.y + 1).group == 3); } // A single-button toggle takes the WHOLE declared width in seg0, leaves seg1 empty, and — the // property the commit seam rests on — answers with NO segment, so a caller cannot mistake it // for the left half of a two-segment control. static void testSingleButtonToggleTakesTheWholeSlotAndCarriesNoSegment() { for (DeckToggleStyle style : {DeckToggleStyle::kEnable, DeckToggleStyle::kMode}) { DeckGroupDesc g{0, 40, {}, {200, 52, style}, {}, {1, 2, 3}, {}}; std::vector gs{g}; const DeckLayout dl = layoutDeck(gs, 0, 0, 400); const DeckToggleLayout& t = dl.groups[0].captionToggle; CHECK(t.id == 200); CHECK(t.style == style); CHECK(t.seg0.width == 52); CHECK(t.seg1.empty()); // Right-anchored in the caption row exactly as a segmented toggle is. CHECK(t.seg0.right() == dl.groups[0].box.right() - kDeckGroupPadX); CHECK(t.seg0.height == kDeckToggleH); // Both ends of the button answer the same hit, with segment -1. for (int px : {t.seg0.x, t.seg0.x + 26, t.seg0.right() - 1}) { const DeckHit h = hitTestDeck(dl, px, t.seg0.y + 1); CHECK(h.kind == DeckHitKind::CaptionToggle); CHECK(h.id == 200 && h.segment == -1 && h.group == 0); } // Where the right half of a segmented toggle would have been is now the same button, // not segment 1 — the regression this style exists to make impossible. CHECK(hitTestDeck(dl, t.seg0.right() - 1, t.seg0.y + 1).segment != 1); } } // The caption toggles sit in the caption row and the knob circles in the cell row, so no // button rect can overlap a dial. hitTestKnobFace runs NO toggle-precedence pass, and this is // the property that lets it get away with that — re-checked here because the single-button // styles made every one of those rects wider. static void testNoToggleRectOverlapsAKnobCircle() { DeckGroupDesc g{0, 40, {}, {200, 96, DeckToggleStyle::kEnable}, {201, 96, DeckToggleStyle::kMode}, {1, 2, 3}, {202, 96}}; std::vector gs{g}; const DeckLayout dl = layoutDeck(gs, 0, 0, 600); const DeckGroupLayout& lay = dl.groups[0]; const DeckToggleLayout* toggles[] = {&lay.captionToggle, &lay.captionToggle2, &lay.rowToggle}; for (const DeckToggleLayout* t : toggles) { for (const Rect& seg : {t->seg0, t->seg1}) { if (seg.empty()) continue; for (const DeckCellLayout& c : lay.cells) { // Sweep the segment's own pixels: none of them may land on a drawn dial. for (int px = seg.x; px < seg.right(); ++px) { for (int py = seg.y; py < seg.bottom(); ++py) { CHECK(!inKnobFace(c.knob, px, py)); } } } } } } // A reserve holds the group's WIDTH and gives its pixels to the two END MARGINS, never to the // cells: every cell keeps kDeckCellW whatever face the group is showing, and the run of them is // centred. That is the whole spacing law — a reduced face is the same knobs at the same pitch, // sitting in the middle of a box that did not move. static void testAReserveCentresTheRunAndNeverWidensACell() { const DeckGroupDesc full{0, 78, {}, {100, 88}, {}, {20, 21, 22, 23, 24}, {}}; // Three, four, and a lone cell against the same five-slot reserve. const std::vector> faces = { {20, 21, 22, -1, -1}, {20, 21, 22, 23, -1}, {20, -1, -1, -1, -1}}; for (const std::vector& ids : faces) { DeckGroupDesc narrow = full; narrow.cellIds = ids; CHECK(deckGroupWidth(narrow) == deckGroupWidth(full)); std::vector g{narrow}; const DeckLayout dl = layoutDeck(g, 0, 0, 824); const DeckGroupLayout& lay = dl.groups[0]; const int present = static_cast(lay.cells.size()); CHECK(present == 5 - static_cast(std::count(ids.begin(), ids.end(), -1))); for (int i = 0; i < present; ++i) { const DeckCellLayout& c = lay.cells[static_cast(i)]; CHECK(c.cell.width == kDeckCellW); // natural pitch, never the divided run CHECK(c.knob.width == kDeckKnobSize); // The dial sits centred in its cell — 60 and 40 are both even, so exactly so. CHECK(c.knob.x - c.cell.x == c.cell.right() - c.knob.right()); if (i > 0) CHECK(c.cell.x == lay.cells[static_cast(i - 1)].cell.right()); } // What the run does not cover is the reserve, split evenly at the two ends. The // reserve is a whole number of 60px cells, so the split is exact — never off by one. const int leadPad = lay.cells.front().cell.x - (lay.box.x + kDeckGroupPadX); const int trailPad = (lay.box.right() - kDeckGroupPadX) - lay.cells.back().cell.right(); CHECK(leadPad == trailPad); CHECK(leadPad + trailPad == (5 - present) * kDeckCellW); } // Only the reserve COUNT matters, not where a -1 sits: with the run centred, three faces // that reserve two slots in three different places lay out identically. const std::vector> sameCount = { {20, 21, 22, -1, -1}, {-1, 20, 21, -1, 22}, {-1, -1, 20, 21, 22}}; std::vector firstRun; for (const std::vector& ids : sameCount) { DeckGroupDesc d = full; d.cellIds = ids; std::vector g{d}; const DeckLayout dl = layoutDeck(g, 0, 0, 824); std::vector cells; for (const DeckCellLayout& c : dl.groups[0].cells) cells.push_back(c.cell); CHECK(cells.size() == 3); if (firstRun.empty()) firstRun = cells; else CHECK(cells == firstRun); } // A reserve does not move the row toggle: it anchors past the whole run, so the FILTER // group's law switch cannot drift when a neighbouring face changes shape. DeckGroupDesc withToggle{1, 40, {}, {}, {}, {20, 21, 22, 23, 24}, {104, 88}}; std::vector a{withToggle}; withToggle.cellIds = {20, 21, -1, -1, -1}; std::vector b{withToggle}; CHECK(layoutDeck(a, 0, 0, 824).groups[0].rowToggle.seg0 == layoutDeck(b, 0, 0, 824).groups[0].rowToggle.seg0); } // A face with NO reserve is untouched by the centring — the offset is zero by construction, so // the run starts flush against the group's inner padding exactly as it always did. This is what // makes "the Gate deck face is pixel-identical" a structural claim rather than an observation. static void testAFaceWithNoReserveStartsFlushAgainstThePadding() { for (int slots = 1; slots <= 8; ++slots) { DeckGroupDesc g{0, 78, {}, {100, 88}, {}, {}, {}}; for (int i = 0; i < slots; ++i) g.cellIds.push_back(20 + i); std::vector gs{g}; const DeckLayout dl = layoutDeck(gs, 0, 0, 824); const DeckGroupLayout& lay = dl.groups[0]; CHECK(static_cast(lay.cells.size()) == slots); CHECK(lay.cells.front().cell.x == lay.box.x + kDeckGroupPadX); // The run covers the whole reserve exactly — no lead margin to absorb, none to leave. // (Not "flush right": a caption-row-bound group's box is wider than its knob row.) CHECK(lay.cells.back().cell.right() - lay.cells.front().cell.x == slots * kDeckCellW); for (const DeckCellLayout& c : lay.cells) CHECK(c.cell.width == kDeckCellW); } } // The corner radio widens the caption row, takes the far corner, and pushes the caption // 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 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 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, 60}, {301, 40}, {1, 2, 3}, {}}; std::vector 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, 46}, {1, 2, 3, 4, 5}, {}}; std::vector 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 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(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 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(); testSingleButtonToggleTakesTheWholeSlotAndCarriesNoSegment(); testNoToggleRectOverlapsAKnobCircle(); testAReserveCentresTheRunAndNeverWidensACell(); testAFaceWithNoReserveStartsFlushAgainstThePadding(); 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; }