package check import ( "math" "testing" ) // The gates over the colour arithmetic in colour.go, ported from // rastrillo's ui/colour_test.go unchanged: the controls for // ContrastRatio, oklchHex and DeltaEOK, and the scan that proves white // alone reaches full luminance. The tests of Pair, Wash and Allocate // stay with the engine, in rastrillo. // ─── ContrastRatio ─────────────────────────────────────────────────── // TestContrastRatioControls is the control for the arithmetic the whole // design system rests on. Three cases whose answers are not opinions: // a colour against itself is exactly 1, black against white is exactly // 21, and the ratio does not care which colour is named first. // // TestContrastMathMatchesDocumentedDangerFillRatios in contrast_test.go // is the other half — two hand-verified ratios from tokens.css. Together // they say the formula is both self-consistent and correctly calibrated // against a number a person computed by hand. func TestContrastRatioControls(t *testing.T) { for _, hex := range []string{"#000000", "#ffffff", "#b91c1c", "#7f7f7f", "#123456"} { got, err := ContrastRatio(hex, hex) if err != nil { t.Fatalf("ContrastRatio(%s, %s): %v", hex, hex, err) } if got != 1.0 { t.Errorf("ContrastRatio(%s, %s) = %v, want exactly 1 — a colour compared with itself", hex, hex, got) } } got, err := ContrastRatio("#000000", "#ffffff") if err != nil { t.Fatal(err) } if math.Abs(got-21) > 1e-9 { t.Errorf("ContrastRatio(black, white) = %v, want 21", got) } fwd, _ := ContrastRatio("#b91c1c", "#ffffff") rev, _ := ContrastRatio("#ffffff", "#b91c1c") if fwd != rev { t.Errorf("ContrastRatio is not symmetric: %v vs %v", fwd, rev) } // Short hex is the same colour as its long form, and a value that is // not a hex literal is an error rather than a silent zero. short, _ := ContrastRatio("#fff", "#000") if short != got { t.Errorf("ContrastRatio(#fff, #000) = %v, want the same as #ffffff/#000000 (%v)", short, got) } for _, bad := range []string{"white", "var(--rst-bg)", "rgba(0,0,0,0.5)", "#ff", "", "#12345"} { if _, err := ContrastRatio(bad, "#ffffff"); err == nil { t.Errorf("ContrastRatio(%q, …) returned no error; a value this file cannot read must say so", bad) } } } // ─── OKLCH → sRGB ──────────────────────────────────────────────────── // TestOklchHexControls checks the colour conversion against cases whose // answers are known without running it: the achromatic axis, which must // come back grey (all three channels equal) at every lightness, and the // two ends, which must come back black and white. // // It then pins the out-of-gamut decision. Asking for a chroma sRGB // cannot hold must come back with a REDUCED chroma at the SAME hue — // desaturated, never hue-shifted — which is the whole argument for // reducing chroma instead of clamping channels. func TestOklchHexControls(t *testing.T) { for _, l := range []float64{0.1, 0.25, 0.5, 0.75, 0.9} { hex, c := oklchHex(l, 0, 210) if c != 0 { t.Errorf("oklchHex(%v, 0, 210) delivered chroma %v, want 0", l, c) } r, g, b, err := parseHex(hex) if err != nil { t.Fatalf("oklchHex returned an unparseable colour %q: %v", hex, err) } if r != g || g != b { t.Errorf("oklchHex(%v, 0, 210) = %s, want a grey (chroma 0 is the achromatic axis)", l, hex) } } if hex, _ := oklchHex(0, 0, 0); hex != "#000000" { t.Errorf("oklchHex(0, 0, 0) = %s, want #000000", hex) } if hex, _ := oklchHex(1, 0, 0); hex != "#ffffff" { t.Errorf("oklchHex(1, 0, 0) = %s, want #ffffff", hex) } // 0.4 chroma at mid lightness is outside sRGB at every hue. for _, hue := range []float64{25, 145, 265, 325} { hex, c := oklchHex(0.6, 0.4, hue) if c >= 0.4 { t.Errorf("oklchHex(0.6, 0.4, %v) claimed chroma %v — 0.4 is outside sRGB and must be reduced", hue, c) } if c <= 0 { t.Errorf("oklchHex(0.6, 0.4, %v) reduced chroma to %v — it should find the largest chroma that fits, not give up", hue, c) } // The reduced colour must still be the hue that was asked for. // A clamp would move it; a chroma reduction cannot. if got := hueOf(t, hex); angleDiff(got, hue) > 2.0 { t.Errorf("oklchHex(0.6, 0.4, %v) = %s, whose hue is %.1f — gamut mapping moved the hue, which is what clamping does and reducing chroma must not", hue, hex, got) } } } // hueOf reads the OKLCh hue back out of a rendered hex, so a test can // ask whether gamut mapping moved it. Deliberately a separate route // through the maths from oklchHex's own: it goes sRGB → oklab → angle // rather than reusing anything on the way out. func hueOf(t *testing.T, hex string) float64 { t.Helper() r, g, b, err := parseHex(hex) if err != nil { t.Fatalf("hueOf(%q): %v", hex, err) } rl := linearFromSRGB(float64(r) / 255) gl := linearFromSRGB(float64(g) / 255) bl := linearFromSRGB(float64(b) / 255) lc := math.Cbrt(0.4122214708*rl + 0.5363325363*gl + 0.0514459929*bl) mc := math.Cbrt(0.2119034982*rl + 0.6806995451*gl + 0.1073969566*bl) sc := math.Cbrt(0.0883024619*rl + 0.2817188376*gl + 0.6299787005*bl) a := 1.9779984951*lc - 2.4285922050*mc + 0.4505937099*sc bb := 0.0259040371*lc + 0.7827717662*mc - 0.8086757660*sc deg := math.Atan2(bb, a) * 180 / math.Pi if deg < 0 { deg += 360 } return deg } func angleDiff(a, b float64) float64 { d := math.Abs(math.Mod(a-b+360, 360)) if d > 180 { d = 360 - d } return d } // ─── Separation ────────────────────────────────────────────────────── // TestDeltaEOKControls checks the perceptual metric against distances // whose answers are known before it runs: a colour against itself is // exactly 0, the metric is symmetric, and black against white is exactly // the OKLab lightness range, which is 1. func TestDeltaEOKControls(t *testing.T) { for _, hex := range []string{"#000000", "#ffffff", "#00705d", "#b91c1c"} { got, err := DeltaEOK(hex, hex) if err != nil { t.Fatal(err) } if got != 0 { t.Errorf("DeltaEOK(%s, %s) = %v, want exactly 0 — a colour compared with itself", hex, hex, got) } } black, err := DeltaEOK("#000000", "#ffffff") if err != nil { t.Fatal(err) } if math.Abs(black-1) > 1e-6 { t.Errorf("DeltaEOK(black, white) = %v, want 1 (OKLab lightness runs 0 to 1 and both are neutral)", black) } fwd, _ := DeltaEOK("#00705d", "#006d76") rev, _ := DeltaEOK("#006d76", "#00705d") if fwd != rev { t.Errorf("DeltaEOK is not symmetric: %v vs %v", fwd, rev) } // The pair the shipped set is tightest on, pinned so the number in // the design record and the number the code computes stay the same // thing. Two teals on day's dark page. if math.Abs(fwd-0.0449) > 0.0005 { t.Errorf("DeltaEOK(#00705d, #006d76) = %.4f, want 0.0449 — the shipped set's closest pair", fwd) } for _, bad := range []string{"teal", "var(--x)", "#12"} { if _, err := DeltaEOK(bad, "#ffffff"); err == nil { t.Errorf("DeltaEOK(%q, …) returned no error", bad) } } } func BenchmarkColourContrastRatio(b *testing.B) { for i := 0; i < b.N; i++ { ContrastRatio("#b91c1c", "#ffffff") } } // TestOnlyWhiteReachesFullLuminance is the other side of the same fact, // checked exhaustively because it can be: all 16,777,216 sRGB colours, in // about twenty milliseconds. // // It is the load-bearing premise underneath §6-v2.2d. The required wash on // paper white has to sit at relative luminance 1.0, and if any colour but // white did, the ink-unknown case would have a candidate and the design // decision behind that section would not arise. // // The runner-up is asserted too, so the test cannot pass by finding one // colour for the wrong reason: a scan that had stopped discriminating // would show the second-highest sitting at 1.0 as well. func TestOnlyWhiteReachesFullLuminance(t *testing.T) { full, runnerUp := 0, 0.0 var fullHex, runnerUpHex string for r := 0; r < 256; r++ { for g := 0; g < 256; g++ { for b := 0; b < 256; b++ { l := relLuminance(uint8(r), uint8(g), uint8(b)) if l >= 1.0 { full++ fullHex = hexOf([3]uint8{uint8(r), uint8(g), uint8(b)}) continue } if l > runnerUp { runnerUp, runnerUpHex = l, hexOf([3]uint8{uint8(r), uint8(g), uint8(b)}) } } } } if full != 1 { t.Errorf("%d sRGB colours reach relative luminance 1.0, want exactly 1 — §6-v2.2d rests on white being the only one", full) } if fullHex != "#ffffff" { t.Errorf("the colour at luminance 1.0 is %s, want #ffffff", fullHex) } if runnerUp >= 1.0 { t.Errorf("the second-highest luminance is %.9f (%s), which is not below 1.0 — the scan is not discriminating", runnerUp, runnerUpHex) } t.Logf("exactly one colour at luminance 1.0 (%s); the next is %s at %.9f", fullHex, runnerUpHex, runnerUp) // And white fails the other floor against itself, which is what makes // the empty candidate set an emptiness rather than a squeeze. sep, err := DeltaEOK("#ffffff", "#ffffff") if err != nil { t.Fatal(err) } if sep >= MinSeparation { t.Errorf("the page against itself measures %.4f, clearing the perceptibility floor — the one candidate would then be admissible", sep) } }