accent_resolve_test.go RawHistoryFolder

1package theme
2
3import (
4	"math/rand"
5	"testing"
6
7	renderer "github.com/bnema/vev-vt"
8	"github.com/bnema/vev/internal/domain"
9	"github.com/stretchr/testify/require"
10)
11
12func TestAccentCandidateSlotsAreFixedAndAscending(t *testing.T) {
13	want := [...]uint8{1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14}
14	for i, slot := range want {
15		require.Equalf(t, slot, accentCandidateSlot(i), "candidate index %d", i)
16	}
17}
18
19func TestResolveAccentThresholds(t *testing.T) {
20	tests := []struct {
21		name       string
22		color      oklab
23		background oklab
24		want       bool
25	}{
26		{name: "chroma below", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma - 0.000001}},
27		{name: "chroma at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, want: true},
28		{name: "chroma above", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma + 0.000001}, want: true},
29		{name: "background distance below", color: oklab{L: accentMinBackgroundDistance - 0.000001, A: accentMinChroma}, background: oklab{A: accentMinChroma}},
30		{name: "background distance at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, background: oklab{A: accentMinChroma}, want: true},
31		{name: "background distance above", color: oklab{L: accentMinBackgroundDistance + 0.000001, A: accentMinChroma}, background: oklab{A: accentMinChroma}, want: true},
32	}
33	for _, tt := range tests {
34		t.Run(tt.name, func(t *testing.T) {
35			require.Equal(t, tt.want, accentEligible(tt.color, tt.background))
36		})
37	}
38
39	a := oklab{}
40	require.True(t, accentConnected(a, oklab{L: accentClusterDistance - 0.000001}))
41	require.True(t, accentConnected(a, oklab{L: accentClusterDistance}))
42	require.False(t, accentConnected(a, oklab{L: accentClusterDistance + 0.000001}))
43}
44
45func TestResolveAccentAuto(t *testing.T) {
46	teal := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
47	blue := renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}
48	tests := []struct {
49		name  string
50		theme Theme
51		want  Accent
52	}{
53		{
54			name:  "repeated teal beats repeated blue",
55			theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 14: teal, 4: blue, 12: blue}),
56			want:  Accent{RGB: teal, Slot: 2, Known: true},
57		},
58		{
59			name:  "singleton falls back to slot four",
60			theme: paletteTheme(map[int]renderer.RGB{2: teal, 4: blue}),
61			want:  Accent{RGB: blue, Slot: 4, Known: true},
62		},
63		{
64			name:  "singleton falls back to slot twelve",
65			theme: paletteTheme(map[int]renderer.RGB{2: teal, 12: blue}),
66			want:  Accent{RGB: blue, Slot: 12, Known: true},
67		},
68		{
69			name:  "tied repeated groups fall back to blue",
70			theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue, 12: blue}),
71			want:  Accent{RGB: blue, Slot: 4, Known: true},
72		},
73		{
74			name: "exact theme accent beats a looser regular bright pair",
75			theme: func() Theme {
76				t := paletteTheme(map[int]renderer.RGB{
77					0:  {R: 0xf4, G: 0xf7, B: 0xf5},
78					1:  {R: 0xb6, G: 0x4d, B: 0x58},
79					2:  {R: 0x3d, G: 0x66, B: 0x66},
80					3:  {R: 0x8f, G: 0x62, B: 0x2b},
81					4:  {R: 0x42, G: 0x6f, B: 0xa8},
82					5:  {R: 0x74, G: 0x6f, B: 0x76},
83					6:  {R: 0x5a, G: 0x85, B: 0x85},
84					7:  {R: 0x1d, G: 0x22, B: 0x24},
85					8:  {R: 0x75, G: 0x79, B: 0x82},
86					9:  {R: 0xa9, G: 0x47, B: 0x51},
87					10: {R: 0x2a, G: 0x4a, B: 0x4a},
88					11: {R: 0x7d, G: 0x55, B: 0x25},
89					12: {R: 0x34, G: 0x5f, B: 0x95},
90					13: {R: 0x62, G: 0x5d, B: 0x64},
91					14: {R: 0x3d, G: 0x66, B: 0x66},
92					15: {R: 0x08, G: 0x09, B: 0x0a},
93				})
94				t.Foreground = renderer.RGB{R: 0x1d, G: 0x22, B: 0x24}
95				t.Background = renderer.RGB{R: 0xf4, G: 0xf7, B: 0xf5}
96				t.Light = true
97				return t
98			}(),
99			want: Accent{RGB: renderer.RGB{R: 0x3d, G: 0x66, B: 0x66}, Slot: 2, Known: true},
100		},
101		{
102			name:  "regular bright pair beats equal size runner up",
103			theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue, 13: blue}),
104			want:  Accent{RGB: teal, Slot: 2, Known: true},
105		},
106		{
107			name:  "neutral slots excluded from auto",
108			theme: paletteTheme(map[int]renderer.RGB{0: teal, 7: teal, 8: teal, 15: teal}),
109			want:  Accent{},
110		},
111		{
112			name: "low chroma is rejected",
113			theme: paletteTheme(map[int]renderer.RGB{
114				1: {R: 60, G: 61, B: 60}, 9: {R: 60, G: 61, B: 60},
115			}),
116			want: Accent{},
117		},
118		{
119			name: "near background is rejected",
120			theme: paletteTheme(map[int]renderer.RGB{
121				1: {R: 9, G: 8, B: 12}, 9: {R: 9, G: 8, B: 12},
122			}),
123			want: Accent{},
124		},
125		{
126			name:  "missing foreground yields indexed fallback only",
127			theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}); t.HasFG = false; return t }(),
128			want:  Accent{RGB: teal, Slot: 2, Known: true, IndexedOnly: true},
129		},
130		{
131			name: "known colors remain semantic without truecolor output",
132			theme: func() Theme {
133				t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal})
134				t.TrueColor = false
135				return t
136			}(),
137			want: Accent{RGB: teal, Slot: 2, Known: true},
138		},
139		{
140			name:  "missing background cannot infer",
141			theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}); t.HasBG = false; return t }(),
142			want:  Accent{},
143		},
144		{
145			name: "palette disabled ignores accent policy",
146			theme: func() Theme {
147				t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal})
148				t.UsePalette = false
149				return t
150			}(),
151			want: Accent{},
152		},
153	}
154
155	for _, tt := range tests {
156		t.Run(tt.name, func(t *testing.T) {
157			require.Equal(t, tt.want, ResolveAccent(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}))
158		})
159	}
160}
161
162func TestResolveAccentExplicitSlot(t *testing.T) {
163	teal := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
164	blue := renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}
165	tests := []struct {
166		name   string
167		theme  Theme
168		policy domain.ThemeAccent
169		want   Accent
170	}{
171		{
172			name:   "known explicit neutral slot bypasses auto exclusions",
173			theme:  paletteTheme(map[int]renderer.RGB{0: teal, 2: blue, 10: blue}),
174			policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 0},
175			want:   Accent{RGB: teal, Slot: 0, Known: true},
176		},
177		{
178			name:   "known explicit slot bypasses eligibility",
179			theme:  paletteTheme(map[int]renderer.RGB{3: {R: 9, G: 8, B: 12}, 2: teal, 10: teal}),
180			policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3},
181			want:   Accent{RGB: renderer.RGB{R: 9, G: 8, B: 12}, Slot: 3, Known: true},
182		},
183		{
184			name:   "unknown explicit slot keeps its exact indexed decoration",
185			theme:  paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}),
186			policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5},
187			want:   Accent{Slot: 5, IndexedOnly: true},
188		},
189		{
190			name:   "unknown final palette slot keeps its indexed decoration",
191			theme:  paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}),
192			policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 15},
193			want:   Accent{Slot: 15, IndexedOnly: true},
194		},
195		{
196			name:   "first slot outside palette is rejected",
197			theme:  paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}),
198			policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 16},
199			want:   Accent{},
200		},
201		{
202			name:   "maximum uint8 slot outside palette is rejected",
203			theme:  paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}),
204			policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 255},
205			want:   Accent{},
206		},
207		{
208			name:   "explicit known slot remains semantic without truecolor output",
209			theme:  func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.TrueColor = false; return t }(),
210			policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3},
211			want:   Accent{RGB: teal, Slot: 3, Known: true},
212		},
213		{
214			name:   "palette off ignores explicit slot",
215			theme:  func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.UsePalette = false; return t }(),
216			policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3},
217			want:   Accent{},
218		},
219	}
220	for _, tt := range tests {
221		t.Run(tt.name, func(t *testing.T) {
222			require.Equal(t, tt.want, ResolveAccent(tt.theme, tt.policy))
223		})
224	}
225}
226
227func TestResolveAccentDeterministicAcrossShuffledPaletteConstruction(t *testing.T) {
228	entries := []struct {
229		slot  int
230		color renderer.RGB
231	}{
232		{2, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}}, {10, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}}, {14, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}},
233		{4, renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}}, {12, renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}},
234	}
235	want := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Slot: 2, Known: true}
236	rng := rand.New(rand.NewSource(1)) //nolint:gosec // deterministic test shuffle.
237	for i := 0; i < 50; i++ {
238		rng.Shuffle(len(entries), func(a, b int) { entries[a], entries[b] = entries[b], entries[a] })
239		palette := make(map[int]renderer.RGB, len(entries))
240		for _, entry := range entries {
241			palette[entry.slot] = entry.color
242		}
243		require.Equalf(t, want, ResolveAccent(paletteTheme(palette), domain.ThemeAccent{}), "shuffle %d", i)
244	}
245}
246
247func TestResolveAccentMedoidAndTieBreak(t *testing.T) {
248	group := accentGroup{members: 1<<0 | 1<<1 | 1<<2}
249	colors := [accentCandidateCount]oklab{
250		{L: 0.10, A: 0.10}, {L: 0.11, A: 0.10}, {L: 0.12, A: 0.10},
251	}
252	finalizeAccentGroup(&group, colors)
253	require.Equal(t, uint8(1), group.rep, "central member is the medoid")
254
255	color := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
256	got := ResolveAccent(paletteTheme(map[int]renderer.RGB{3: color, 11: color}), domain.ThemeAccent{})
257	require.Equal(t, Accent{RGB: color, Slot: 3, Known: true}, got)
258}
259
260func TestResolveAccentAllocs(t *testing.T) {
261	theme := paletteTheme(map[int]renderer.RGB{
262		2: {R: 0x7d, G: 0xb5, B: 0xb5}, 10: {R: 0x7d, G: 0xb5, B: 0xb5}, 14: {R: 0x7d, G: 0xb5, B: 0xb5},
263		4: {R: 0x6c, G: 0x9b, B: 0xd9}, 12: {R: 0x6c, G: 0x9b, B: 0xd9},
264	})
265	require.Zero(t, testing.AllocsPerRun(1000, func() {
266		_ = ResolveAccent(theme, domain.ThemeAccent{})
267	}))
268}
269
270func paletteTheme(colors map[int]renderer.RGB) Theme {
271	theme := Theme{
272		Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0},
273		Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a},
274		HasFG:      true,
275		HasBG:      true,
276		TrueColor:  true,
277		Known:      true,
278		UsePalette: true,
279	}
280	for slot, color := range colors {
281		theme.Palette[slot] = color
282		theme.PaletteKnown |= 1 << slot
283	}
284	return theme
285}

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