L1: shared LICE drawing kit — theme/palette + component geometry + draw kit; retire GDI text in bank_panel
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
// Standalone tests for reasampler::component_geometry — no REAPER, no LICE, no framework.
|
||||
// Same fast assert loop as the sibling pure tests (prune_button / mode_switch).
|
||||
//
|
||||
// Covers (brief §L1 point 2 + §test cases): button box inset + graceful suppression; slider
|
||||
// track/filled/handle geometry for representative values incl. endpoints, value->px inverse,
|
||||
// too-small/degenerate suppression; list-row rect for representative indices, partial last
|
||||
// row, hover hit-test returns the right row and "no hit" outside/past the last row; and the
|
||||
// shared half-open box hit-test agrees with layout (no double-claimed pixel).
|
||||
|
||||
#include "../src/component_geometry.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- hitTestBox (the shared primitive) ---------------------------------------
|
||||
|
||||
static void testHitTestBoxHalfOpen() {
|
||||
KitBox b{10, 20, 30, 40};
|
||||
CHECK(hitTestBox(10, 20, b)); // top-left inclusive
|
||||
CHECK(hitTestBox(39, 59, b)); // bottom-right inclusive (x+w-1, y+h-1)
|
||||
CHECK(!hitTestBox(40, 20, b)); // right edge excluded
|
||||
CHECK(!hitTestBox(10, 60, b)); // bottom edge excluded
|
||||
CHECK(!hitTestBox(9, 20, b)); // just left
|
||||
CHECK(!hitTestBox(10, 19, b)); // just above
|
||||
KitBox empty{};
|
||||
CHECK(!hitTestBox(0, 0, empty)); // empty claims nothing
|
||||
}
|
||||
|
||||
// --- Button box --------------------------------------------------------------
|
||||
|
||||
static void testButtonBoxInset() {
|
||||
KitBox cell{0, 0, 100, 40};
|
||||
const KitButtonBox b = computeButtonBox(cell, 4);
|
||||
CHECK(!b.box.empty());
|
||||
CHECK((b.box == KitBox{4, 4, 92, 32}));
|
||||
}
|
||||
|
||||
static void testButtonBoxZeroPadding() {
|
||||
KitBox cell{5, 6, 20, 10};
|
||||
const KitButtonBox b = computeButtonBox(cell, 0);
|
||||
CHECK((b.box == cell)); // no inset -> the whole cell
|
||||
}
|
||||
|
||||
static void testButtonBoxNegativePaddingTreatedAsZero() {
|
||||
KitBox cell{5, 6, 20, 10};
|
||||
CHECK((computeButtonBox(cell, -8).box == cell));
|
||||
}
|
||||
|
||||
static void testButtonBoxSuppressedWhenPaddingCollapses() {
|
||||
KitBox cell{0, 0, 10, 10};
|
||||
CHECK(computeButtonBox(cell, 5).box.empty()); // 10 - 2*5 = 0 width -> suppressed
|
||||
CHECK(computeButtonBox(cell, 6).box.empty()); // negative -> suppressed
|
||||
CHECK(computeButtonBox(KitBox{}, 2).box.empty()); // degenerate cell -> suppressed
|
||||
}
|
||||
|
||||
// --- Slider ------------------------------------------------------------------
|
||||
|
||||
// control 200x20, handle 12, track thickness 4. half = 6. track.x = 6, track.width =
|
||||
// 200-12 = 188, track.y = (20-4)/2 = 8. At value 0 the handle centre is track.x=6 ->
|
||||
// handle.x = 0; filled.width = 0. At value 1 the centre is 6+188=194 -> handle.x=188.
|
||||
static void testSliderEndpoints() {
|
||||
KitBox ctrl{0, 0, 200, 20};
|
||||
const SliderGeometry lo = computeSlider(ctrl, 0.0, 12, 4);
|
||||
CHECK(!lo.track.empty());
|
||||
CHECK((lo.track == KitBox{6, 8, 188, 4}));
|
||||
CHECK(lo.handle.x == 0); // flush left
|
||||
CHECK(lo.filled.width == 0); // nothing filled at 0
|
||||
const SliderGeometry hi = computeSlider(ctrl, 1.0, 12, 4);
|
||||
CHECK(hi.handle.x == 188); // flush right (200-12)
|
||||
CHECK(hi.filled.width == 188); // fully filled at 1
|
||||
}
|
||||
|
||||
static void testSliderMidpoint() {
|
||||
KitBox ctrl{0, 0, 200, 20};
|
||||
const SliderGeometry m = computeSlider(ctrl, 0.5, 12, 4);
|
||||
// centre = 6 + round(0.5*188) = 6 + 94 = 100; handle.x = 100 - 6 = 94.
|
||||
CHECK(m.handle.x == 94);
|
||||
CHECK(m.filled.width == 94);
|
||||
}
|
||||
|
||||
static void testSliderClampsValue() {
|
||||
KitBox ctrl{0, 0, 200, 20};
|
||||
CHECK((computeSlider(ctrl, -1.0, 12, 4).handle.x == computeSlider(ctrl, 0.0, 12, 4).handle.x));
|
||||
CHECK((computeSlider(ctrl, 5.0, 12, 4).handle.x == computeSlider(ctrl, 1.0, 12, 4).handle.x));
|
||||
}
|
||||
|
||||
static void testSliderSuppressedWhenTooSmallOrDegenerate() {
|
||||
CHECK(computeSlider(KitBox{0, 0, 8, 20}, 0.5, 12, 4).track.empty()); // width < handle
|
||||
CHECK(computeSlider(KitBox{0, 0, 200, 8}, 0.5, 12, 4).track.empty()); // height < handle
|
||||
CHECK(computeSlider(KitBox{}, 0.5, 12, 4).track.empty()); // degenerate
|
||||
CHECK(computeSlider(KitBox{0, 0, 200, 20}, 0.5, 0, 4).track.empty()); // no handle
|
||||
CHECK(computeSlider(KitBox{0, 0, 200, 20}, 0.5, 12, 0).track.empty()); // no track
|
||||
}
|
||||
|
||||
// value->px is the inverse of the handle placement (a track jump).
|
||||
static void testSliderValueAtInverse() {
|
||||
KitBox ctrl{0, 0, 200, 20};
|
||||
CHECK(sliderValueAt(6, ctrl, 12) == 0.0); // at/left of track start
|
||||
CHECK(sliderValueAt(0, ctrl, 12) == 0.0); // left of the control -> 0
|
||||
CHECK(sliderValueAt(194, ctrl, 12) == 1.0); // at track end -> 1
|
||||
CHECK(sliderValueAt(300, ctrl, 12) == 1.0); // past the end -> clamped
|
||||
const double mid = sliderValueAt(100, ctrl, 12); // (100-6)/188
|
||||
CHECK(mid > 0.49 && mid < 0.51);
|
||||
CHECK(sliderValueAt(50, KitBox{0, 0, 8, 20}, 12) == 0.0); // too small -> 0
|
||||
}
|
||||
|
||||
// --- List row ----------------------------------------------------------------
|
||||
|
||||
static void testListRowStacking() {
|
||||
KitBox list{0, 100, 220, 90}; // 3 full rows of 30 fit
|
||||
CHECK((computeListRow(list, 0, 30).box == KitBox{0, 100, 220, 30}));
|
||||
CHECK((computeListRow(list, 1, 30).box == KitBox{0, 130, 220, 30}));
|
||||
CHECK((computeListRow(list, 2, 30).box == KitBox{0, 160, 220, 30}));
|
||||
CHECK(computeListRow(list, 0, 30).index == 0);
|
||||
CHECK(computeListRow(list, 2, 30).index == 2);
|
||||
}
|
||||
|
||||
// A row that starts inside the list but overhangs the bottom keeps full height (caller
|
||||
// clips the draw); a row starting AT/BELOW the bottom is suppressed.
|
||||
static void testListRowPartialAndClipped() {
|
||||
KitBox list{0, 0, 100, 50}; // rows of 30: row 0 [0,30), row 1 [30,60) overhangs
|
||||
const ListRowBox partial = computeListRow(list, 1, 30);
|
||||
CHECK(!partial.box.empty());
|
||||
CHECK(partial.box.y == 30);
|
||||
CHECK(partial.box.height == 30); // full height; caller clips
|
||||
CHECK(computeListRow(list, 2, 30).box.empty()); // starts at y=60 >= bottom -> none
|
||||
}
|
||||
|
||||
static void testListRowDegenerate() {
|
||||
CHECK(computeListRow(KitBox{}, 0, 30).box.empty());
|
||||
CHECK(computeListRow(KitBox{0, 0, 100, 90}, -1, 30).box.empty());
|
||||
CHECK(computeListRow(KitBox{0, 0, 100, 90}, 0, 0).box.empty());
|
||||
}
|
||||
|
||||
static void testListRowHitTest() {
|
||||
KitBox list{0, 100, 220, 90}; // 3 rows of 30, rowCount = 3
|
||||
CHECK(hitTestListRow(10, 100, list, 30, 3) == 0); // top of row 0
|
||||
CHECK(hitTestListRow(10, 129, list, 30, 3) == 0); // bottom of row 0 (inclusive)
|
||||
CHECK(hitTestListRow(10, 130, list, 30, 3) == 1); // top of row 1
|
||||
CHECK(hitTestListRow(10, 189, list, 30, 3) == 2); // last pixel of row 2
|
||||
// Misses.
|
||||
CHECK(hitTestListRow(10, 99, list, 30, 3) == -1); // above the list
|
||||
CHECK(hitTestListRow(10, 190, list, 30, 3) == -1); // below the list band
|
||||
CHECK(hitTestListRow(-1, 100, list, 30, 3) == -1); // left of the list
|
||||
CHECK(hitTestListRow(220, 100, list, 30, 3) == -1); // right edge excluded
|
||||
}
|
||||
|
||||
// rowCount bounds the hit: a taller list with fewer rows than fit reports the empty tail
|
||||
// as a miss (no phantom row past the data).
|
||||
static void testListRowHitTestBoundedByCount() {
|
||||
KitBox list{0, 0, 100, 200}; // room for 6 rows of 30, but only 2 exist
|
||||
CHECK(hitTestListRow(10, 10, list, 30, 2) == 0);
|
||||
CHECK(hitTestListRow(10, 40, list, 30, 2) == 1);
|
||||
CHECK(hitTestListRow(10, 70, list, 30, 2) == -1); // row 2 is empty tail -> miss
|
||||
CHECK(hitTestListRow(10, 10, list, 30, 0) == -1); // zero rows -> always miss
|
||||
}
|
||||
|
||||
// Draw/hit-test agreement: every pixel inside a computed row hit-tests to that row.
|
||||
static void testListRowLayoutHitAgreement() {
|
||||
KitBox list{3, 7, 97, 120};
|
||||
const int rh = 24, rowCount = 4;
|
||||
for (int idx = 0; idx < rowCount; ++idx) {
|
||||
const ListRowBox r = computeListRow(list, idx, rh);
|
||||
if (r.box.empty()) continue;
|
||||
for (int py = r.box.y; py < r.box.y + r.box.height &&
|
||||
py < list.y + list.height; ++py)
|
||||
CHECK(hitTestListRow(list.x + 1, py, list, rh, rowCount) == idx);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testHitTestBoxHalfOpen();
|
||||
testButtonBoxInset();
|
||||
testButtonBoxZeroPadding();
|
||||
testButtonBoxNegativePaddingTreatedAsZero();
|
||||
testButtonBoxSuppressedWhenPaddingCollapses();
|
||||
testSliderEndpoints();
|
||||
testSliderMidpoint();
|
||||
testSliderClampsValue();
|
||||
testSliderSuppressedWhenTooSmallOrDegenerate();
|
||||
testSliderValueAtInverse();
|
||||
testListRowStacking();
|
||||
testListRowPartialAndClipped();
|
||||
testListRowDegenerate();
|
||||
testListRowHitTest();
|
||||
testListRowHitTestBoundedByCount();
|
||||
testListRowLayoutHitAgreement();
|
||||
|
||||
if (g_fail == 0) std::printf("component_geometry: all tests passed\n");
|
||||
else std::printf("component_geometry: %d CHECK(s) FAILED\n", g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Standalone tests for reasampler::theme — no REAPER, no LICE, no test framework. Same
|
||||
// fast assert loop as the sibling pure tests.
|
||||
//
|
||||
// The load-bearing test: EVERY text-on-surface pair the kit actually draws clears its WCAG
|
||||
// floor (AA 4.5:1 body / 3:1 large + state indicators) — the "punch to the floor, not past
|
||||
// it" rule made testable (brief §1, CONTEXT.md §palette). Plus: the direction constants are
|
||||
// the single point of change (structural — roleColor is the only color source), the
|
||||
// interaction-state transform behaves, the WCAG math is correct against known anchors, and
|
||||
// the Direction C spectral ramp interpolates its endpoints.
|
||||
|
||||
#include "../src/theme.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <initializer_list>
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- WCAG math against known anchors ------------------------------------------
|
||||
|
||||
static void testContrastKnownAnchors() {
|
||||
const KitColor white{255, 255, 255, 255};
|
||||
const KitColor black{0, 0, 0, 255};
|
||||
// Black-on-white is the canonical 21:1.
|
||||
CHECK(std::fabs(contrastRatio(white, black) - 21.0) < 0.05);
|
||||
// Contrast is symmetric (order-independent).
|
||||
CHECK(std::fabs(contrastRatio(white, black) - contrastRatio(black, white)) < 1e-9);
|
||||
// A color against itself is 1:1 (no contrast).
|
||||
CHECK(std::fabs(contrastRatio(white, white) - 1.0) < 1e-9);
|
||||
// Luminance ordering: white brightest, black darkest.
|
||||
CHECK(relativeLuminance(white) > relativeLuminance(black));
|
||||
CHECK(std::fabs(relativeLuminance(black)) < 1e-9);
|
||||
CHECK(std::fabs(relativeLuminance(white) - 1.0) < 1e-9);
|
||||
}
|
||||
|
||||
// --- THE load-bearing test: every drawn text-on-surface pair clears its floor -
|
||||
|
||||
// The surfaces text lands on (near-black elevation stack).
|
||||
static void testTextPrimaryClearsBodyFloorOnSurfaces() {
|
||||
const KitColor tp = roleColor(Role::TextPrimary);
|
||||
// Primary text is body text -> 4.5:1 on every surface it is drawn on.
|
||||
CHECK(contrastRatio(tp, roleColor(Role::BgBase)) >= textFloor(TextClass::Body));
|
||||
CHECK(contrastRatio(tp, roleColor(Role::BgPanel)) >= textFloor(TextClass::Body));
|
||||
CHECK(contrastRatio(tp, roleColor(Role::BgCell)) >= textFloor(TextClass::Body));
|
||||
}
|
||||
|
||||
static void testTextDimClearsItsFloorOnSurfaces() {
|
||||
const KitColor td = roleColor(Role::TextDim);
|
||||
// Dim/secondary text is used for units/counts (large-ish, low-emphasis) -> the
|
||||
// large/state floor 3:1 on the surfaces it appears on. Enforced from the vibrant side:
|
||||
// it must not be dimmed BELOW the floor.
|
||||
CHECK(contrastRatio(td, roleColor(Role::BgBase)) >= textFloor(TextClass::Large));
|
||||
CHECK(contrastRatio(td, roleColor(Role::BgPanel)) >= textFloor(TextClass::Large));
|
||||
CHECK(contrastRatio(td, roleColor(Role::BgCell)) >= textFloor(TextClass::Large));
|
||||
}
|
||||
|
||||
static void testAccentClearsStateFloorOnBackground() {
|
||||
// The accent is a UI-state indicator (selection border / active fill) -> 3:1 minimum
|
||||
// on the base background, "pushed to the floor from the vibrant side".
|
||||
CHECK(contrastRatio(roleColor(Role::Accent), roleColor(Role::BgBase))
|
||||
>= textFloor(TextClass::Large));
|
||||
// The hot (hover) accent is brighter still, so it also clears.
|
||||
CHECK(contrastRatio(roleColor(Role::AccentHot), roleColor(Role::BgBase))
|
||||
>= textFloor(TextClass::Large));
|
||||
}
|
||||
|
||||
static void testWarnClearsStateFloorOnBackground() {
|
||||
// warn (destructive) must be unmistakable -> clears the state floor on the base.
|
||||
CHECK(contrastRatio(roleColor(Role::Warn), roleColor(Role::BgBase))
|
||||
>= textFloor(TextClass::Large));
|
||||
}
|
||||
|
||||
// Label drawn on an ACTIVE (accent-filled) surface: the shell draws it in bg/base. That
|
||||
// pairing must also clear the body floor, or active buttons would be unreadable.
|
||||
static void testLabelOnActiveSurfaceClearsFloor() {
|
||||
const KitColor activeFill = roleColorState(Role::BgCell, InteractionState::Active);
|
||||
const KitColor labelOnActive = roleColor(Role::BgBase); // what drawButton uses
|
||||
CHECK(contrastRatio(labelOnActive, activeFill) >= textFloor(TextClass::Body));
|
||||
}
|
||||
|
||||
// --- Single point of change (structural guarantee) ----------------------------
|
||||
//
|
||||
// roleColor is the ONLY color source; there is no other public accessor that yields a
|
||||
// palette color, so re-picking the direction is editing the one constants block roleColor
|
||||
// reads. We assert the roles are DISTINCT (the block actually differentiates them — a
|
||||
// collapsed/duplicated palette would betray a broken edit) and that the elevation stack
|
||||
// is monotonic (base darkest -> cell lightest), the invariant the direction must preserve.
|
||||
static void testRolesAreDistinctAndElevationMonotonic() {
|
||||
CHECK(!(roleColor(Role::BgBase) == roleColor(Role::BgPanel)));
|
||||
CHECK(!(roleColor(Role::BgPanel) == roleColor(Role::BgCell)));
|
||||
CHECK(!(roleColor(Role::TextPrimary) == roleColor(Role::TextDim)));
|
||||
CHECK(!(roleColor(Role::Accent) == roleColor(Role::Warn)));
|
||||
// Elevation reads as increasing luminance base < panel < cell.
|
||||
CHECK(relativeLuminance(roleColor(Role::BgBase)) <
|
||||
relativeLuminance(roleColor(Role::BgPanel)));
|
||||
CHECK(relativeLuminance(roleColor(Role::BgPanel)) <
|
||||
relativeLuminance(roleColor(Role::BgCell)));
|
||||
// Primary text is brighter than dim text (the type hierarchy).
|
||||
CHECK(relativeLuminance(roleColor(Role::TextPrimary)) >
|
||||
relativeLuminance(roleColor(Role::TextDim)));
|
||||
}
|
||||
|
||||
// --- Interaction-state transform ----------------------------------------------
|
||||
|
||||
static void testStateRestIsIdentity() {
|
||||
for (Role r : {Role::BgBase, Role::BgPanel, Role::BgCell, Role::Accent}) {
|
||||
CHECK(roleColorState(r, InteractionState::Rest) == roleColor(r));
|
||||
}
|
||||
}
|
||||
|
||||
static void testHoverLightensPressedDarkens() {
|
||||
const KitColor rest = roleColor(Role::BgCell);
|
||||
const KitColor hover = roleColorState(Role::BgCell, InteractionState::Hover);
|
||||
const KitColor pressed = roleColorState(Role::BgCell, InteractionState::Pressed);
|
||||
// Hover lightens toward the hot accent; pressed darkens.
|
||||
CHECK(relativeLuminance(hover) > relativeLuminance(rest));
|
||||
CHECK(relativeLuminance(pressed) < relativeLuminance(rest));
|
||||
}
|
||||
|
||||
static void testActiveIsAccent() {
|
||||
CHECK(roleColorState(Role::BgCell, InteractionState::Active) == roleColor(Role::Accent));
|
||||
}
|
||||
|
||||
static void testDisabledDropsAlphaAndDesaturates() {
|
||||
const KitColor rest = roleColor(Role::Accent);
|
||||
const KitColor dis = roleColorState(Role::Accent, InteractionState::Disabled);
|
||||
// Alpha drops to ~40%.
|
||||
CHECK(dis.a < rest.a);
|
||||
CHECK(dis.a >= 90 && dis.a <= 110); // 255 * 0.4 ~= 102
|
||||
}
|
||||
|
||||
// --- Direction C spectral ramp ------------------------------------------------
|
||||
|
||||
static void testSpectralInterpolatesEndpoints() {
|
||||
const KitColor lo = spectralColor(0.0);
|
||||
const KitColor hi = spectralColor(1.0);
|
||||
// Low is cool (blue-dominant), high is hot (red-dominant) — the identity of the ramp.
|
||||
CHECK(lo.b > lo.r);
|
||||
CHECK(hi.r > hi.b);
|
||||
// Midpoint sits between the endpoints on each channel.
|
||||
const KitColor mid = spectralColor(0.5);
|
||||
CHECK(mid.r > lo.r && mid.r < hi.r);
|
||||
// Clamps out of range.
|
||||
CHECK(spectralColor(-1.0) == lo);
|
||||
CHECK(spectralColor(2.0) == hi);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testContrastKnownAnchors();
|
||||
testTextPrimaryClearsBodyFloorOnSurfaces();
|
||||
testTextDimClearsItsFloorOnSurfaces();
|
||||
testAccentClearsStateFloorOnBackground();
|
||||
testWarnClearsStateFloorOnBackground();
|
||||
testLabelOnActiveSurfaceClearsFloor();
|
||||
testRolesAreDistinctAndElevationMonotonic();
|
||||
testStateRestIsIdentity();
|
||||
testHoverLightensPressedDarkens();
|
||||
testActiveIsAccent();
|
||||
testDisabledDropsAlphaAndDesaturates();
|
||||
testSpectralInterpolatesEndpoints();
|
||||
|
||||
if (g_fail == 0) std::printf("theme: all tests passed\n");
|
||||
else std::printf("theme: %d CHECK(s) FAILED\n", g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user