1package theme
2
3import (
4 "math"
5 "testing"
6
7 renderer "github.com/bnema/vev-vt"
8)
9
10func TestRGBToOKLabKnownValues(t *testing.T) {
11 tests := []struct {
12 name string
13 rgb renderer.RGB
14 want oklab
15 }{
16 {name: "black", rgb: renderer.RGB{}, want: oklab{}},
17 {name: "white", rgb: renderer.RGB{R: 255, G: 255, B: 255}, want: oklab{L: 1}},
18 {name: "red", rgb: renderer.RGB{R: 255}, want: oklab{L: 0.627955, A: 0.224863, B: 0.125846}},
19 }
20
21 for _, tt := range tests {
22 t.Run(tt.name, func(t *testing.T) {
23 got := rgbToOKLab(tt.rgb)
24 if math.Abs(got.L-tt.want.L) > 0.000001 || math.Abs(got.A-tt.want.A) > 0.000001 || math.Abs(got.B-tt.want.B) > 0.000001 {
25 t.Fatalf("rgbToOKLab(%+v) = %+v, want %+v", tt.rgb, got, tt.want)
26 }
27 })
28 }
29}
30
31func TestOKLabRGBRoundTrip(t *testing.T) {
32 colors := []renderer.RGB{
33 {},
34 {R: 255, G: 255, B: 255},
35 {R: 255},
36 {G: 255},
37 {B: 255},
38 {R: 0x7d, G: 0xb5, B: 0xb5},
39 {R: 0x6c, G: 0x9b, B: 0xd9},
40 }
41 for _, color := range colors {
42 t.Run("round_trip", func(t *testing.T) {
43 got := okLabToRGB(rgbToOKLab(color))
44 if channelDelta(got.R, color.R) > 1 || channelDelta(got.G, color.G) > 1 || channelDelta(got.B, color.B) > 1 {
45 t.Fatalf("round trip %v = %v", color, got)
46 }
47 })
48 }
49}
50
51func TestOKLabDistance(t *testing.T) {
52 a := rgbToOKLab(renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5})
53 b := rgbToOKLab(renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9})
54 if got := okLabDistance(a, a); got != 0 {
55 t.Fatalf("self distance = %v, want 0", got)
56 }
57 if got, want := okLabDistance(a, b), okLabDistance(b, a); math.Abs(got-want) > 1e-15 || got <= 0 {
58 t.Fatalf("distance symmetry: %v != %v", got, want)
59 }
60}
61
62func TestOKLabLerpEndpointsAndMonotonicity(t *testing.T) {
63 background := renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}
64 accent := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
65
66 if got := okLabLerp(background, accent, 0); got != background {
67 t.Fatalf("weight 0 = %v, want %v", got, background)
68 }
69 if got := okLabLerp(background, accent, 1); got != accent {
70 t.Fatalf("weight 1 = %v, want %v", got, accent)
71 }
72 if got := okLabLerp(background, accent, -1); got != background {
73 t.Fatalf("clamped low weight = %v, want %v", got, background)
74 }
75 if got := okLabLerp(background, accent, 2); got != accent {
76 t.Fatalf("clamped high weight = %v, want %v", got, accent)
77 }
78
79 previous := background
80 for _, weight := range []float64{0.25, 0.5, 0.75, 1} {
81 got := okLabLerp(background, accent, weight)
82 if got.R < previous.R || got.G < previous.G || got.B < previous.B {
83 t.Fatalf("weight %v = %v is not monotonic after %v", weight, got, previous)
84 }
85 previous = got
86 }
87}
88
89func TestOKLabToRGBClampsOutOfGamutChannels(t *testing.T) {
90 got := okLabToRGB(oklab{L: 0.7, A: 0.5, B: 0.5})
91 if got.R != 255 || got.G != 0 || got.B != 0 {
92 t.Fatalf("out-of-gamut color = %v, want deterministic per-channel clamp to {255 0 0}", got)
93 }
94}
95
96func TestOKLabOKLChRoundTrip(t *testing.T) {
97 colors := []oklab{
98 rgbToOKLab(renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}),
99 rgbToOKLab(renderer.RGB{R: 0xff}),
100 rgbToOKLab(renderer.RGB{B: 0xff}),
101 rgbToOKLab(renderer.RGB{R: 128, G: 128, B: 128}),
102 }
103 for _, want := range colors {
104 got := oklchToOKLab(oklabToOKLCh(want))
105 if math.Abs(got.L-want.L) > 1e-9 || math.Abs(got.A-want.A) > 1e-9 || math.Abs(got.B-want.B) > 1e-9 {
106 t.Fatalf("oklch round trip %+v = %+v, want %+v", want, got, want)
107 }
108 }
109}
110
111func TestOKLabToOKLChHueIsDegreesInRange(t *testing.T) {
112 tests := []struct {
113 name string
114 rgb renderer.RGB
115 want float64
116 }{
117 // Known reference hues, computed with the same conversion this
118 // package uses: red sits near 29 degrees, blue near 264, amber
119 // (#FFBF00) near 84.
120 {name: "red", rgb: renderer.RGB{R: 255}, want: 29.2},
121 {name: "amber #FFBF00", rgb: renderer.RGB{R: 0xff, G: 0xbf, B: 0x00}, want: 84.1},
122 }
123 for _, tt := range tests {
124 t.Run(tt.name, func(t *testing.T) {
125 got := oklabToOKLCh(rgbToOKLab(tt.rgb))
126 if got.H < 0 || got.H >= 360 {
127 t.Fatalf("hue %v out of [0, 360) range", got.H)
128 }
129 if math.Abs(got.H-tt.want) > 0.5 {
130 t.Fatalf("hue = %v, want ~%v", got.H, tt.want)
131 }
132 })
133 }
134}
135
136func TestShiftHuePreservesLightnessAndChromaButReplacesHue(t *testing.T) {
137 tests := []struct {
138 name string
139 source renderer.RGB
140 }{
141 {name: "blue accent", source: renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}},
142 {name: "red accent", source: renderer.RGB{R: 0xcc, G: 0x66, B: 0x66}},
143 {name: "green accent", source: renderer.RGB{R: 0x6c, G: 0xae, B: 0x6c}},
144 }
145 const target = warnHueDegrees
146
147 for _, tt := range tests {
148 t.Run(tt.name, func(t *testing.T) {
149 sourceLCh := oklabToOKLCh(rgbToOKLab(tt.source))
150 shifted := shiftHue(tt.source, target)
151 shiftedLCh := oklabToOKLCh(rgbToOKLab(shifted))
152
153 // sRGB gamut clamping in okLabToRGB can perturb L/C slightly for
154 // out-of-gamut combinations; allow a small tolerance. These three
155 // sources sit safely inside the gamut at the amber target hue
156 // (verified independently), so a tight tolerance still catches a
157 // broken implementation.
158 if math.Abs(shiftedLCh.L-sourceLCh.L) > 0.03 {
159 t.Fatalf("lightness drifted: got %v, want ~%v", shiftedLCh.L, sourceLCh.L)
160 }
161 if math.Abs(shiftedLCh.C-sourceLCh.C) > 0.03 {
162 t.Fatalf("chroma drifted: got %v, want ~%v", shiftedLCh.C, sourceLCh.C)
163 }
164 if math.Abs(shiftedLCh.H-target) > 1 {
165 t.Fatalf("hue = %v, want ~%v", shiftedLCh.H, target)
166 }
167 })
168 }
169}
170
171func channelDelta(a, b uint8) uint8 {
172 if a > b {
173 return a - b
174 }
175 return b - a
176}