rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1package check
2
3import (
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.
25func 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.
74func 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.
116func 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
137func 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.
151func 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
186func 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.
204func 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