| 1 | package check |
| 2 | |
| 3 | import ( |
| 4 | "math" |
| 5 | "testing" |
| 6 | ) |
| 7 | |
| 8 | // The gates over the colour arithmetic in colour.go, ported from |
| 9 | // rastrillo's ui/colour_test.go unchanged: the controls for |
| 10 | // ContrastRatio, oklchHex and DeltaEOK, and the scan that proves white |
| 11 | // alone reaches full luminance. The tests of Pair, Wash and Allocate |
| 12 | // stay with the engine, in rastrillo. |
| 13 | |
| 14 | // ─── ContrastRatio ─────────────────────────────────────────────────── |
| 15 | |
| 16 | // TestContrastRatioControls is the control for the arithmetic the whole |
| 17 | // design system rests on. Three cases whose answers are not opinions: |
| 18 | // a colour against itself is exactly 1, black against white is exactly |
| 19 | // 21, and the ratio does not care which colour is named first. |
| 20 | // |
| 21 | // TestContrastMathMatchesDocumentedDangerFillRatios in contrast_test.go |
| 22 | // is the other half — two hand-verified ratios from tokens.css. Together |
| 23 | // they say the formula is both self-consistent and correctly calibrated |
| 24 | // against a number a person computed by hand. |
| 25 | func TestContrastRatioControls(t *testing.T) { |
| 26 | for _, hex := range []string{"#000000", "#ffffff", "#b91c1c", "#7f7f7f", "#123456"} { |
| 27 | got, err := ContrastRatio(hex, hex) |
| 28 | if err != nil { |
| 29 | t.Fatalf("ContrastRatio(%s, %s): %v", hex, hex, err) |
| 30 | } |
| 31 | if got != 1.0 { |
| 32 | t.Errorf("ContrastRatio(%s, %s) = %v, want exactly 1 — a colour compared with itself", hex, hex, got) |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | got, err := ContrastRatio("#000000", "#ffffff") |
| 37 | if err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | if math.Abs(got-21) > 1e-9 { |
| 41 | t.Errorf("ContrastRatio(black, white) = %v, want 21", got) |
| 42 | } |
| 43 | |
| 44 | fwd, _ := ContrastRatio("#b91c1c", "#ffffff") |
| 45 | rev, _ := ContrastRatio("#ffffff", "#b91c1c") |
| 46 | if fwd != rev { |
| 47 | t.Errorf("ContrastRatio is not symmetric: %v vs %v", fwd, rev) |
| 48 | } |
| 49 | |
| 50 | // Short hex is the same colour as its long form, and a value that is |
| 51 | // not a hex literal is an error rather than a silent zero. |
| 52 | short, _ := ContrastRatio("#fff", "#000") |
| 53 | if short != got { |
| 54 | t.Errorf("ContrastRatio(#fff, #000) = %v, want the same as #ffffff/#000000 (%v)", short, got) |
| 55 | } |
| 56 | for _, bad := range []string{"white", "var(--rst-bg)", "rgba(0,0,0,0.5)", "#ff", "", "#12345"} { |
| 57 | if _, err := ContrastRatio(bad, "#ffffff"); err == nil { |
| 58 | t.Errorf("ContrastRatio(%q, …) returned no error; a value this file cannot read must say so", bad) |
| 59 | } |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | // ─── OKLCH → sRGB ──────────────────────────────────────────────────── |
| 64 | |
| 65 | // TestOklchHexControls checks the colour conversion against cases whose |
| 66 | // answers are known without running it: the achromatic axis, which must |
| 67 | // come back grey (all three channels equal) at every lightness, and the |
| 68 | // two ends, which must come back black and white. |
| 69 | // |
| 70 | // It then pins the out-of-gamut decision. Asking for a chroma sRGB |
| 71 | // cannot hold must come back with a REDUCED chroma at the SAME hue — |
| 72 | // desaturated, never hue-shifted — which is the whole argument for |
| 73 | // reducing chroma instead of clamping channels. |
| 74 | func TestOklchHexControls(t *testing.T) { |
| 75 | for _, l := range []float64{0.1, 0.25, 0.5, 0.75, 0.9} { |
| 76 | hex, c := oklchHex(l, 0, 210) |
| 77 | if c != 0 { |
| 78 | t.Errorf("oklchHex(%v, 0, 210) delivered chroma %v, want 0", l, c) |
| 79 | } |
| 80 | r, g, b, err := parseHex(hex) |
| 81 | if err != nil { |
| 82 | t.Fatalf("oklchHex returned an unparseable colour %q: %v", hex, err) |
| 83 | } |
| 84 | if r != g || g != b { |
| 85 | t.Errorf("oklchHex(%v, 0, 210) = %s, want a grey (chroma 0 is the achromatic axis)", l, hex) |
| 86 | } |
| 87 | } |
| 88 | if hex, _ := oklchHex(0, 0, 0); hex != "#000000" { |
| 89 | t.Errorf("oklchHex(0, 0, 0) = %s, want #000000", hex) |
| 90 | } |
| 91 | if hex, _ := oklchHex(1, 0, 0); hex != "#ffffff" { |
| 92 | t.Errorf("oklchHex(1, 0, 0) = %s, want #ffffff", hex) |
| 93 | } |
| 94 | |
| 95 | // 0.4 chroma at mid lightness is outside sRGB at every hue. |
| 96 | for _, hue := range []float64{25, 145, 265, 325} { |
| 97 | hex, c := oklchHex(0.6, 0.4, hue) |
| 98 | if c >= 0.4 { |
| 99 | t.Errorf("oklchHex(0.6, 0.4, %v) claimed chroma %v — 0.4 is outside sRGB and must be reduced", hue, c) |
| 100 | } |
| 101 | if c <= 0 { |
| 102 | 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) |
| 103 | } |
| 104 | // The reduced colour must still be the hue that was asked for. |
| 105 | // A clamp would move it; a chroma reduction cannot. |
| 106 | if got := hueOf(t, hex); angleDiff(got, hue) > 2.0 { |
| 107 | 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) |
| 108 | } |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | // hueOf reads the OKLCh hue back out of a rendered hex, so a test can |
| 113 | // ask whether gamut mapping moved it. Deliberately a separate route |
| 114 | // through the maths from oklchHex's own: it goes sRGB → oklab → angle |
| 115 | // rather than reusing anything on the way out. |
| 116 | func hueOf(t *testing.T, hex string) float64 { |
| 117 | t.Helper() |
| 118 | r, g, b, err := parseHex(hex) |
| 119 | if err != nil { |
| 120 | t.Fatalf("hueOf(%q): %v", hex, err) |
| 121 | } |
| 122 | rl := linearFromSRGB(float64(r) / 255) |
| 123 | gl := linearFromSRGB(float64(g) / 255) |
| 124 | bl := linearFromSRGB(float64(b) / 255) |
| 125 | lc := math.Cbrt(0.4122214708*rl + 0.5363325363*gl + 0.0514459929*bl) |
| 126 | mc := math.Cbrt(0.2119034982*rl + 0.6806995451*gl + 0.1073969566*bl) |
| 127 | sc := math.Cbrt(0.0883024619*rl + 0.2817188376*gl + 0.6299787005*bl) |
| 128 | a := 1.9779984951*lc - 2.4285922050*mc + 0.4505937099*sc |
| 129 | bb := 0.0259040371*lc + 0.7827717662*mc - 0.8086757660*sc |
| 130 | deg := math.Atan2(bb, a) * 180 / math.Pi |
| 131 | if deg < 0 { |
| 132 | deg += 360 |
| 133 | } |
| 134 | return deg |
| 135 | } |
| 136 | |
| 137 | func angleDiff(a, b float64) float64 { |
| 138 | d := math.Abs(math.Mod(a-b+360, 360)) |
| 139 | if d > 180 { |
| 140 | d = 360 - d |
| 141 | } |
| 142 | return d |
| 143 | } |
| 144 | |
| 145 | // ─── Separation ────────────────────────────────────────────────────── |
| 146 | |
| 147 | // TestDeltaEOKControls checks the perceptual metric against distances |
| 148 | // whose answers are known before it runs: a colour against itself is |
| 149 | // exactly 0, the metric is symmetric, and black against white is exactly |
| 150 | // the OKLab lightness range, which is 1. |
| 151 | func TestDeltaEOKControls(t *testing.T) { |
| 152 | for _, hex := range []string{"#000000", "#ffffff", "#00705d", "#b91c1c"} { |
| 153 | got, err := DeltaEOK(hex, hex) |
| 154 | if err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | if got != 0 { |
| 158 | t.Errorf("DeltaEOK(%s, %s) = %v, want exactly 0 — a colour compared with itself", hex, hex, got) |
| 159 | } |
| 160 | } |
| 161 | black, err := DeltaEOK("#000000", "#ffffff") |
| 162 | if err != nil { |
| 163 | t.Fatal(err) |
| 164 | } |
| 165 | if math.Abs(black-1) > 1e-6 { |
| 166 | t.Errorf("DeltaEOK(black, white) = %v, want 1 (OKLab lightness runs 0 to 1 and both are neutral)", black) |
| 167 | } |
| 168 | fwd, _ := DeltaEOK("#00705d", "#006d76") |
| 169 | rev, _ := DeltaEOK("#006d76", "#00705d") |
| 170 | if fwd != rev { |
| 171 | t.Errorf("DeltaEOK is not symmetric: %v vs %v", fwd, rev) |
| 172 | } |
| 173 | // The pair the shipped set is tightest on, pinned so the number in |
| 174 | // the design record and the number the code computes stay the same |
| 175 | // thing. Two teals on day's dark page. |
| 176 | if math.Abs(fwd-0.0449) > 0.0005 { |
| 177 | t.Errorf("DeltaEOK(#00705d, #006d76) = %.4f, want 0.0449 — the shipped set's closest pair", fwd) |
| 178 | } |
| 179 | for _, bad := range []string{"teal", "var(--x)", "#12"} { |
| 180 | if _, err := DeltaEOK(bad, "#ffffff"); err == nil { |
| 181 | t.Errorf("DeltaEOK(%q, …) returned no error", bad) |
| 182 | } |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | func BenchmarkColourContrastRatio(b *testing.B) { |
| 187 | for i := 0; i < b.N; i++ { |
| 188 | ContrastRatio("#b91c1c", "#ffffff") |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | // TestOnlyWhiteReachesFullLuminance is the other side of the same fact, |
| 193 | // checked exhaustively because it can be: all 16,777,216 sRGB colours, in |
| 194 | // about twenty milliseconds. |
| 195 | // |
| 196 | // It is the load-bearing premise underneath §6-v2.2d. The required wash on |
| 197 | // paper white has to sit at relative luminance 1.0, and if any colour but |
| 198 | // white did, the ink-unknown case would have a candidate and the design |
| 199 | // decision behind that section would not arise. |
| 200 | // |
| 201 | // The runner-up is asserted too, so the test cannot pass by finding one |
| 202 | // colour for the wrong reason: a scan that had stopped discriminating |
| 203 | // would show the second-highest sitting at 1.0 as well. |
| 204 | func TestOnlyWhiteReachesFullLuminance(t *testing.T) { |
| 205 | full, runnerUp := 0, 0.0 |
| 206 | var fullHex, runnerUpHex string |
| 207 | for r := 0; r < 256; r++ { |
| 208 | for g := 0; g < 256; g++ { |
| 209 | for b := 0; b < 256; b++ { |
| 210 | l := relLuminance(uint8(r), uint8(g), uint8(b)) |
| 211 | if l >= 1.0 { |
| 212 | full++ |
| 213 | fullHex = hexOf([3]uint8{uint8(r), uint8(g), uint8(b)}) |
| 214 | continue |
| 215 | } |
| 216 | if l > runnerUp { |
| 217 | runnerUp, runnerUpHex = l, hexOf([3]uint8{uint8(r), uint8(g), uint8(b)}) |
| 218 | } |
| 219 | } |
| 220 | } |
| 221 | } |
| 222 | if full != 1 { |
| 223 | t.Errorf("%d sRGB colours reach relative luminance 1.0, want exactly 1 — §6-v2.2d rests on white being the only one", full) |
| 224 | } |
| 225 | if fullHex != "#ffffff" { |
| 226 | t.Errorf("the colour at luminance 1.0 is %s, want #ffffff", fullHex) |
| 227 | } |
| 228 | if runnerUp >= 1.0 { |
| 229 | t.Errorf("the second-highest luminance is %.9f (%s), which is not below 1.0 — the scan is not discriminating", runnerUp, runnerUpHex) |
| 230 | } |
| 231 | t.Logf("exactly one colour at luminance 1.0 (%s); the next is %s at %.9f", fullHex, runnerUpHex, runnerUp) |
| 232 | |
| 233 | // And white fails the other floor against itself, which is what makes |
| 234 | // the empty candidate set an emptiness rather than a squeeze. |
| 235 | sep, err := DeltaEOK("#ffffff", "#ffffff") |
| 236 | if err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | if sep >= MinSeparation { |
| 240 | t.Errorf("the page against itself measures %.4f, clearing the perceptibility floor — the one candidate would then be admissible", sep) |
| 241 | } |
| 242 | } |
| 243 | |