main ece7682

color_oklab_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close