// 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/core/ui/theme.h" #include #include #include #include using namespace reasampler; using namespace reasampler::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) // --- 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 (DS-2 revised REAPER-grey 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); // DS-2 revised (grey re-read): text/dim is used for keybinding sub-labels / units on // the mid-grey surfaces, and mid-grey-on-mid-grey is the classic AA failure. It is // LOCKED to the lightest grey that still reads dim while clearing AA 4.5:1 BODY on the // GREYEST surface it draws body text on (bg/cell). This is the load-bearing check: the // spec-start #a0a0a0 is UNDER floor on grey by design — the value was lifted until this // passes. Body floor on all three surfaces (bg/cell is the tight one). CHECK(contrastRatio(td, roleColor(Role::BgBase)) >= textFloor(TextClass::Body)); CHECK(contrastRatio(td, roleColor(Role::BgPanel)) >= textFloor(TextClass::Body)); CHECK(contrastRatio(td, roleColor(Role::BgCell)) >= textFloor(TextClass::Body)); } static void testAccentsClearIndicatorFloorOnCell() { // DS-2 revised (grey re-read): each of the three pastel accents is used as a state // indicator / active fill on bg/cell (selection border, active segment, active tab). // The greyer surface shrank the cushion from ~15:1 (near-black) to ~6-7:1 (grey), so // this is re-verified on bg/cell (NOT the old near-black) at the 3:1 large/indicator // floor. If any pastel dropped below, the fix is to nudge that hue deeper (hue stays // lime/teal/purple) — the value the test locks proves it did not need it here. for (Role a : {Role::AccentPrimary, Role::AccentSecondary, Role::AccentTertiary, Role::AccentHot}) { CHECK(contrastRatio(roleColor(a), roleColor(Role::BgCell)) >= textFloor(TextClass::Large)); } // The primary accent also leads the mode-switch/tab active fill drawn over bg/base. CHECK(contrastRatio(roleColor(Role::AccentPrimary), roleColor(Role::BgBase)) >= textFloor(TextClass::Large)); // bank_panel draws categorical accents as text on bg/panel (region-title band): // AccentSecondary/Tertiary as region-title text; AccentPrimary as the "Active:" readout. // Verify all three clear the 3:1 large/indicator floor on bg/panel (the ACTUAL draw surface). for (Role a : {Role::AccentPrimary, Role::AccentSecondary, Role::AccentTertiary}) { CHECK(contrastRatio(roleColor(a), roleColor(Role::BgPanel)) >= textFloor(TextClass::Large)); } } // Text drawn ON a pastel accent fill (retained tight pair, re-verified for the pastels): // active buttons/segments/tabs fill with the primary accent and draw their label in // bg/base (near-black-ish grey). That near-black-on-pastel pair must clear AA 4.5:1 body, // or active controls would be unreadable. Verified for EACH pastel that can be a text fill. static void testTextOnPastelFillClearsBodyFloor() { const KitColor label = roleColor(Role::BgBase); // what drawButton/drawListRow use for (Role a : {Role::AccentPrimary, Role::AccentSecondary, Role::AccentTertiary}) { CHECK(contrastRatio(label, roleColor(a)) >= textFloor(TextClass::Body)); } } // Secondary and tertiary are the CATEGORICAL pair (region titles, spectral bands). They // must read as DISTINCT categories, not two greys. Two pastels can be near-equal in // LUMINANCE yet clearly distinct in HUE (teal vs purple differ mainly by channel balance, // not brightness) — so distinguishability is a hue-separation check, not a luminance- // contrast one. Assert a meaningful per-channel difference: teal is green/blue-leaning, // purple is red/blue-leaning, so their channel BALANCE diverges substantially. static void testSecondaryTertiaryAreDistinguishable() { const KitColor sec = roleColor(Role::AccentSecondary); // teal: G,B high, R low const KitColor ter = roleColor(Role::AccentTertiary); // purple: R,B high, G lower CHECK(!(sec == ter)); CHECK(!(roleColor(Role::AccentPrimary) == sec)); // Hue divergence: teal's green dominates its red; purple's red dominates its green. // A muddying that collapsed them toward a common grey would break this ordering. CHECK(sec.g > sec.r); // teal leans green over red CHECK(ter.r > ter.g); // purple leans red over green // And the total channel-balance gap is large (sum of absolute channel deltas), so the // two are far apart in color space even though close in luminance. const int delta = std::abs(int(sec.r) - int(ter.r)) + std::abs(int(sec.g) - int(ter.g)) + std::abs(int(sec.b) - int(ter.b)); CHECK(delta >= 60); } // The instrument's envelope overlay is traced OVER the waveform, which draws in the primary // accent — an accent-on-accent pair no floor covers, since neither is a surface. It moved from // the secondary to the tertiary for exactly this reason, so the pair is pinned two ways: the // tertiary must separate from the primary MORE than the secondary did (the measurable half of // the move), and the separation is a hue one, since two pastels sit close in luminance by // construction. Whether the result reads clearly is a perceptual call, not this test's. static void testOverlayAccentSeparatesFromTheWaveformAccent() { const KitColor wave = roleColor(Role::AccentPrimary); const KitColor overlay = roleColor(Role::AccentTertiary); const KitColor prior = roleColor(Role::AccentSecondary); CHECK(contrastRatio(overlay, wave) > contrastRatio(prior, wave)); // Pin the MEASURED value, not just the relational improvement: today's pair sits at // ~1.37:1 — BELOW the 3:1 indicator floor every other assertion in this file uses. That // is a known, held deviation (Daniel's palette call, tracked separately) rather than a // silently accepted one; a regression toward the old ~1.12:1 secondary pairing must fail. CHECK(contrastRatio(overlay, wave) >= 1.35); // Hue divergence against the waveform: the waveform's green dominates its red, the // overlay's red dominates its green — opposite balances, not two shades of one. CHECK(wave.g > wave.r); CHECK(overlay.r > overlay.g); const int delta = std::abs(int(wave.r) - int(overlay.r)) + std::abs(int(wave.g) - int(overlay.g)) + std::abs(int(wave.b) - int(overlay.b)); CHECK(delta >= 60); } // editor_paint_waveform.cpp draws the loop span/markers and the envelope overlay trace into // the SAME overlay rect. Loop markers are secondary (this track's reassignment, off tertiary, // which the envelope overlay now owns exclusively there); a future palette edit that collapses // the two back to one role must fail here rather than silently recreating the finding. static void testLoopMarkerAndEnvelopeOverlayRolesAreDistinct() { const KitColor loopMarker = roleColor(Role::AccentSecondary); const KitColor envelopeOverlay = roleColor(Role::AccentTertiary); CHECK(!(loopMarker == envelopeOverlay)); } 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)); } // Text drawn on a HOVER-state surface (Phase L, L3): the VST editor + embed restyle lights // hover on inactive segments/tabs/cards by lightening bg/cell toward accent/hot (~10%). // Lightening the surface REDUCES contrast against light text, so re-verify the pairs on the // hover surface, not just on rest bg/cell. (A distinct drawn pair the L3 shells introduce, // not covered by the rest-surface tests above.) // - text/PRIMARY (card name, segment/tab labels) is BODY -> must clear AA 4.5:1 even hovered. // - text/DIM is used ONLY as a compact UI LABEL on a hoverable surface (the card's root/key // BADGE — the one dim-on-hover pair the restyle draws), so it clears at the 3:1 large/ // UI-label floor, per §2.1's "confine dim to large/UI-label use where the 3:1 floor // applies." A lightened surface pulls a mid-grey dim toward the body floor; the badge is // deliberately a label, not body, so 3:1 is the correct floor and this locks it there. static void testTextOnHoverSurfaceClearsFloor() { const KitColor hover = roleColorState(Role::BgCell, InteractionState::Hover); CHECK(contrastRatio(roleColor(Role::TextPrimary), hover) >= textFloor(TextClass::Body)); CHECK(contrastRatio(roleColor(Role::TextDim), hover) >= textFloor(TextClass::Large)); } // --- 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::AccentPrimary) == 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::AccentPrimary}) { 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 testActiveIsPrimaryAccent() { // DS-2 revised: the Active state is ALWAYS the primary accent ("what's live" leads). CHECK(roleColorState(Role::BgCell, InteractionState::Active) == roleColor(Role::AccentPrimary)); } static void testDisabledDropsAlphaAndDesaturates() { const KitColor rest = roleColor(Role::AccentPrimary); const KitColor dis = roleColorState(Role::AccentPrimary, 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 testSpectralIsPastelRampAnchoredOnAccents() { // DS-2 revised Direction C: the spectral ramp is a PASTEL sweep anchored on the three // accents — lime (low) -> teal (mid) -> purple (high) — NOT the old neon cool->hot. const KitColor lo = spectralColor(0.0); const KitColor mid = spectralColor(0.5); const KitColor hi = spectralColor(1.0); // The three stops ARE the three accent constants (single source — the strip belongs to // the accent system). This is the load-bearing identity of the pastel ramp. CHECK(lo == roleColor(Role::AccentPrimary)); // low = pastel lime CHECK(mid == roleColor(Role::AccentSecondary)); // mid = pastel teal CHECK(hi == roleColor(Role::AccentTertiary)); // high = pastel purple // Low is lime (green-dominant); high is purple (red+blue over green) — distinct hues. CHECK(lo.g > lo.r && lo.g > lo.b); CHECK(hi.b > hi.g && hi.r > hi.g); // A quarter-point interpolates within the lime->teal half (not off the family). const KitColor q = spectralColor(0.25); CHECK(q.b > lo.b && q.b < mid.b); // blue rises lime -> teal // Clamps out of range to the endpoints. CHECK(spectralColor(-1.0) == lo); CHECK(spectralColor(2.0) == hi); } int main() { testContrastKnownAnchors(); testTextPrimaryClearsBodyFloorOnSurfaces(); testTextDimClearsItsFloorOnSurfaces(); testAccentsClearIndicatorFloorOnCell(); testTextOnPastelFillClearsBodyFloor(); testTextOnHoverSurfaceClearsFloor(); testSecondaryTertiaryAreDistinguishable(); testOverlayAccentSeparatesFromTheWaveformAccent(); testLoopMarkerAndEnvelopeOverlayRolesAreDistinct(); testWarnClearsStateFloorOnBackground(); testLabelOnActiveSurfaceClearsFloor(); testRolesAreDistinctAndElevationMonotonic(); testStateRestIsIdentity(); testHoverLightensPressedDarkens(); testActiveIsPrimaryAccent(); testDisabledDropsAlphaAndDesaturates(); testSpectralIsPastelRampAnchoredOnAccents(); 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; }