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}