theme_test.go RawHistoryFolder

1package entity
2
3import (
4	"testing"
5)
6
7// TestValidateHexColor_ValidHex tests that standard hex colors are recognized.
8func TestValidateHexColor_ValidHex(t *testing.T) {
9	valid := []string{
10		"#000000",
11		"#ffffff",
12		"#0a0a0b",
13		"#4ade80",
14		"#4A90E2",
15		"#FFA500",
16		"#9B59B6",
17		"#00D4AA",
18	}
19
20	for _, hex := range valid {
21		if !IsValidHex(hex) {
22			t.Errorf("expected %q to be a valid hex color", hex)
23		}
24	}
25}
26
27// TestValidateHexColor_InvalidHex tests that non-hex or malformed strings are rejected.
28func TestValidateHexColor_InvalidHex(t *testing.T) {
29	invalid := []string{
30		"",
31		"rgb(0,0,0)",
32		"#FFF",      // 3-char shorthand
33		"#FFFFFFFF", // 8-char with alpha
34		"#GGGGGG",   // non-hex chars
35		"red",
36		"#12",         // too short
37		"#1234567",    // odd length
38		"  #ffffff  ", // whitespace
39		"#ffffff ",    // trailing space
40	}
41
42	for _, hex := range invalid {
43		if IsValidHex(hex) {
44			t.Errorf("expected %q to be invalid hex", hex)
45		}
46	}
47}
48
49// TestDefaultThemeFonts_ReturnsSaneDefaults verifies the default font configuration has non-empty,
50// sensible values.
51func TestDefaultThemeFonts_ReturnsSaneDefaults(t *testing.T) {
52	fonts := DefaultThemeFonts()
53
54	if fonts.SansFont != "Fira Sans" {
55		t.Errorf("expected SansFont Fira Sans, got %q", fonts.SansFont)
56	}
57	if fonts.SerifFont != "Fira Sans" {
58		t.Errorf("expected SerifFont Fira Sans, got %q", fonts.SerifFont)
59	}
60	if fonts.MonospaceFont != "Fira Code" {
61		t.Errorf("expected MonospaceFont Fira Code, got %q", fonts.MonospaceFont)
62	}
63	if fonts.GtkFont != "Adwaita Sans" {
64		t.Errorf("expected GtkFont Adwaita Sans, got %q", fonts.GtkFont)
65	}
66	if fonts.DefaultSize != 16 {
67		t.Errorf("expected DefaultSize 16, got %d", fonts.DefaultSize)
68	}
69}
70
71// TestResolvedThemePalette_MustBeCSSSafeHex ensures that every color field
72// in a ResolvedThemePalette passes hex validation.
73// This is the locked contract: all palette colors in ResolvedTheme are CSS-safe hex.
74func TestResolvedThemePalette_MustBeCSSSafeHex(t *testing.T) {
75	// A well-formed palette that SHOULD be produced by resolution.
76	validPalette := ResolvedThemePalette{
77		Background:     "#0a0a0b",
78		Surface:        "#1a1a1b",
79		SurfaceVariant: "#2d2d2d",
80		Text:           "#ffffff",
81		Muted:          "#909090",
82		Accent:         "#4ade80",
83		Border:         "#333333",
84	}
85
86	fields := map[string]string{
87		"Background":     validPalette.Background,
88		"Surface":        validPalette.Surface,
89		"SurfaceVariant": validPalette.SurfaceVariant,
90		"Text":           validPalette.Text,
91		"Muted":          validPalette.Muted,
92		"Accent":         validPalette.Accent,
93		"Border":         validPalette.Border,
94	}
95
96	for name, value := range fields {
97		if !IsValidHex(value) {
98			t.Errorf("ResolvedThemePalette.%s = %q is not a valid CSS-safe hex color", name, value)
99		}
100	}
101}
102
103// TestResolvedThemePalette_RejectsInvalidColors verifies that any non-hex color
104// in a palette is detectable by a validation helper.
105func TestResolvedThemePalette_RejectsInvalidColors(t *testing.T) {
106	invalidFields := []struct {
107		name  string
108		value string
109	}{
110		{"Background", "rgb(0,0,0)"},
111		{"Background", "#F"},
112		{"Surface", "white"},
113		{"Text", "#GGGGGG"},
114		{"Accent", "hsl(0,0%,0%)"},
115		{"Border", ""},
116	}
117
118	for _, tc := range invalidFields {
119		if IsValidHex(tc.value) {
120			t.Errorf("field %q value %q should be rejected as invalid hex", tc.name, tc.value)
121		}
122	}
123}
124
125// TestThemeModeColors_DefaultsAreCSSSafe ensures the default mode colors are valid hex.
126func TestThemeModeColors_DefaultsAreCSSSafe(t *testing.T) {
127	// Per the existing ui/theme palette: defaults are #4A90E2, #FFA500, #9B59B6, #00D4AA
128	defaults := ThemeModeColors{
129		PaneMode:    "#4A90E2",
130		TabMode:     "#FFA500",
131		SessionMode: "#9B59B6",
132		ResizeMode:  "#00D4AA",
133		VimMode:     "#F7768E",
134	}
135
136	fields := map[string]string{
137		"PaneMode":    defaults.PaneMode,
138		"TabMode":     defaults.TabMode,
139		"SessionMode": defaults.SessionMode,
140		"ResizeMode":  defaults.ResizeMode,
141		"VimMode":     defaults.VimMode,
142	}
143
144	for name, value := range fields {
145		if !IsValidHex(value) {
146			t.Errorf("ThemeModeColors.%s = %q is not a valid CSS-safe hex color", name, value)
147		}
148	}
149}
150
151// TestResolvedTheme_StructFieldsExist is a compile-time guard and field-inspection test
152// to verify that the ResolvedTheme struct carries all required fields from the locked contract.
153func TestResolvedTheme_StructFieldsExist(t *testing.T) {
154	rt := ResolvedTheme{
155		LightPalette: ResolvedThemePalette{
156			Background: "#0a0a0b",
157			Surface:    "#1a1a1b",
158		},
159		DarkPalette: ResolvedThemePalette{
160			Background: "#fafafa",
161			Surface:    "#ffffff",
162		},
163		ActivePalette: ResolvedThemePalette{
164			Background: "#0a0a0b",
165		},
166		PrefersDark:       true,
167		ColorSchemeSource: "adwaita",
168		ThemeSource:       ThemeSourceMetadata{Kind: ThemeSourceConfig},
169		Fonts:             DefaultThemeFonts(),
170		UIScale:           1.0,
171		ModeColors: ThemeModeColors{
172			PaneMode:    "#4A90E2",
173			TabMode:     "#FFA500",
174			SessionMode: "#9B59B6",
175			ResizeMode:  "#00D4AA",
176			VimMode:     "#F7768E",
177		},
178		Warnings: []ThemeWarning{
179			{Field: "external", Message: "malformed external theme, using last-good"},
180		},
181	}
182
183	// Verify the struct can be constructed and fields are accessible.
184	if !rt.PrefersDark {
185		t.Error("expected PrefersDark to be true")
186	}
187	if rt.ColorSchemeSource != "adwaita" {
188		t.Errorf("expected ColorSchemeSource 'adwaita', got %q", rt.ColorSchemeSource)
189	}
190	if rt.ThemeSource.Kind != ThemeSourceConfig {
191		t.Errorf("expected ThemeSource.Kind 'config', got %q", rt.ThemeSource.Kind)
192	}
193	if rt.UIScale != 1.0 {
194		t.Errorf("expected UIScale 1.0, got %f", rt.UIScale)
195	}
196	if len(rt.Warnings) != 1 {
197		t.Errorf("expected 1 warning, got %d", len(rt.Warnings))
198	}
199	if rt.Fonts.DefaultSize != 16 {
200		t.Errorf("expected DefaultSize 16, got %d", rt.Fonts.DefaultSize)
201	}
202}
203
204// TestThemeSourceKind_ConstantsAreDistinct verifies the ThemeSourceKind constants
205// have unique values so that source metadata comparisons work correctly.
206func TestThemeSourceKind_ConstantsAreDistinct(t *testing.T) {
207	kinds := map[ThemeSourceKind]bool{
208		ThemeSourceDefault:  true,
209		ThemeSourceConfig:   true,
210		ThemeSourceExternal: true,
211	}
212
213	if len(kinds) != 3 {
214		t.Error("ThemeSourceKind constants must be distinct")
215	}
216
217	if ThemeSourceDefault == ThemeSourceExternal {
218		t.Error("ThemeSourceDefault must not equal ThemeSourceExternal")
219	}
220	if ThemeSourceDefault == ThemeSourceConfig {
221		t.Error("ThemeSourceDefault must not equal ThemeSourceConfig")
222	}
223}
224
225// TestThemeWarning_FieldsExist verifies ThemeWarning carries the expected shape.
226func TestThemeWarning_FieldsExist(t *testing.T) {
227	w := ThemeWarning{
228		Field:   "light_palette.background",
229		Message: "not a valid hex color",
230	}
231
232	if w.Field == "" {
233		t.Error("expected ThemeWarning.Field to be non-empty")
234	}
235	if w.Message == "" {
236		t.Error("expected ThemeWarning.Message to be non-empty")
237	}
238}
239
240// TestExternalTheme_IsDomainPure verifies ExternalTheme can be constructed without any
241// infrastructure imports (this is a compile-time contract via package structure).
242func TestExternalTheme_IsDomainPure(t *testing.T) {
243	light := ColorPalette{
244		Background: "#0a0a0b",
245		Surface:    "#1a1a1b",
246	}
247	dark := ColorPalette{
248		Background: "#fafafa",
249		Surface:    "#ffffff",
250	}
251
252	ext := ExternalTheme{
253		Name:         "Noctalia Dark",
254		Provider:     "noctalia",
255		LightPalette: &light,
256		DarkPalette:  &dark,
257	}
258
259	if ext.Name != "Noctalia Dark" {
260		t.Errorf("expected Name 'Noctalia Dark', got %q", ext.Name)
261	}
262	if ext.Provider != "noctalia" {
263		t.Errorf("expected Provider 'noctalia', got %q", ext.Provider)
264	}
265	if ext.LightPalette == nil || ext.DarkPalette == nil {
266		t.Error("expected non-nil palettes")
267	}
268}
269
270// TestDefaultThemeFonts_AllFieldsAreSet ensures no zero-value fields in the defaults.
271func TestDefaultThemeFonts_AllFieldsAreSet(t *testing.T) {
272	fonts := DefaultThemeFonts()
273
274	// Check that all string fields are non-empty
275	if fonts.SansFont == "" {
276		t.Error("SansFont should have a default value")
277	}
278	if fonts.SerifFont == "" {
279		t.Error("SerifFont should have a default value")
280	}
281	if fonts.MonospaceFont == "" {
282		t.Error("MonospaceFont should have a default value")
283	}
284	if fonts.GtkFont == "" {
285		t.Error("GtkFont should have a default value")
286	}
287	if fonts.DefaultSize <= 0 {
288		t.Errorf("DefaultSize should be positive, got %d", fonts.DefaultSize)
289	}
290}
291
292// TestDefaultThemeModeColors_ReturnsSaneDefaults verifies the default mode colors.
293func TestDefaultThemeModeColors_ReturnsSaneDefaults(t *testing.T) {
294	mc := DefaultThemeModeColors()
295
296	if mc.PaneMode != "#4A90E2" {
297		t.Errorf("expected PaneMode #4A90E2, got %q", mc.PaneMode)
298	}
299	if mc.TabMode != "#FFA500" {
300		t.Errorf("expected TabMode #FFA500, got %q", mc.TabMode)
301	}
302	if mc.SessionMode != "#9B59B6" {
303		t.Errorf("expected SessionMode #9B59B6, got %q", mc.SessionMode)
304	}
305	if mc.ResizeMode != "#00D4AA" {
306		t.Errorf("expected ResizeMode #00D4AA, got %q", mc.ResizeMode)
307	}
308
309	// All must be valid hex
310	if !IsValidHex(mc.PaneMode) {
311		t.Error("default PaneMode should be valid hex")
312	}
313	if !IsValidHex(mc.TabMode) {
314		t.Error("default TabMode should be valid hex")
315	}
316	if !IsValidHex(mc.SessionMode) {
317		t.Error("default SessionMode should be valid hex")
318	}
319	if !IsValidHex(mc.ResizeMode) {
320		t.Error("default ResizeMode should be valid hex")
321	}
322}
323
324// TestDefaultDarkPalette_ReturnsValidHex verifies the default dark palette is all valid hex.
325func TestDefaultDarkPalette_ReturnsValidHex(t *testing.T) {
326	p := DefaultDarkPalette()
327
328	fields := map[string]string{
329		"Background":     p.Background,
330		"Surface":        p.Surface,
331		"SurfaceVariant": p.SurfaceVariant,
332		"Text":           p.Text,
333		"Muted":          p.Muted,
334		"Accent":         p.Accent,
335		"Border":         p.Border,
336	}
337	for name, value := range fields {
338		if value == "" {
339			t.Errorf("DefaultDarkPalette.%s should not be empty", name)
340		}
341		if !IsValidHex(value) {
342			t.Errorf("DefaultDarkPalette.%s = %q is not valid hex", name, value)
343		}
344	}
345}
346
347// TestDefaultLightPalette_ReturnsValidHex verifies the default light palette is all valid hex.
348func TestDefaultLightPalette_ReturnsValidHex(t *testing.T) {
349	p := DefaultLightPalette()
350
351	fields := map[string]string{
352		"Background":     p.Background,
353		"Surface":        p.Surface,
354		"SurfaceVariant": p.SurfaceVariant,
355		"Text":           p.Text,
356		"Muted":          p.Muted,
357		"Accent":         p.Accent,
358		"Border":         p.Border,
359	}
360	for name, value := range fields {
361		if value == "" {
362			t.Errorf("DefaultLightPalette.%s should not be empty", name)
363		}
364		if !IsValidHex(value) {
365			t.Errorf("DefaultLightPalette.%s = %q is not valid hex", name, value)
366		}
367	}
368}
369
370// TestValidatePaletteHex_Valid verifies ValidatePaletteHex returns no warnings for valid palettes.
371func TestValidatePaletteHex_Valid(t *testing.T) {
372	p := DefaultDarkPalette()
373	warnings := ValidatePaletteHex(&p, "dark")
374	if len(warnings) != 0 {
375		t.Errorf("expected no warnings for valid palette, got %d: %v", len(warnings), warnings)
376	}
377}
378
379// TestValidatePaletteHex_Invalid verifies ValidatePaletteHex returns warnings for invalid palettes.
380func TestValidatePaletteHex_Invalid(t *testing.T) {
381	p := ColorPalette{
382		Background:     "rgb(0,0,0)", // invalid
383		Surface:        "#ffffff",
384		SurfaceVariant: "not-a-color",
385		Text:           "",        // invalid
386		Muted:          "#GGGGGG", // invalid
387		Accent:         "#123",    // 3-char shorthand, rejected
388		Border:         "#123456",
389	}
390	warnings := ValidatePaletteHex(&p, "test")
391	if len(warnings) == 0 {
392		t.Error("expected warnings for invalid palette, got none")
393	}
394}
395
396// TestValidatePaletteHex_Nil verifies ValidatePaletteHex handles nil.
397func TestValidatePaletteHex_Nil(t *testing.T) {
398	warnings := ValidatePaletteHex(nil, "ext")
399	if len(warnings) == 0 {
400		t.Error("expected warnings for nil palette")
401	}
402}
403
404// TestIsPaletteValid verifies IsPaletteValid returns correct truthiness.
405func TestIsPaletteValid_EdgeCases(t *testing.T) {
406	if IsPaletteValid(nil) {
407		t.Error("nil palette should not be valid")
408	}
409
410	valid := DefaultDarkPalette()
411	if !IsPaletteValid(&valid) {
412		t.Error("default dark palette should be valid")
413	}
414
415	partial := ColorPalette{
416		Background: "#000000",
417		Surface:    "#111111",
418		// missing fields are ""
419	}
420	if IsPaletteValid(&partial) {
421		t.Error("partial palette with empty fields should not be valid")
422	}
423}
424
425// TestMergePalette_FillsEmpties verifies MergePalette fills empty fields from defaults.
426func TestMergePalette_FillsEmpties(t *testing.T) {
427	src := &ColorPalette{
428		Background: "#000000",
429		Surface:    "", // should be filled
430		Text:       "#ffffff",
431	}
432	def := DefaultDarkPalette()
433	result := MergePalette(src, &def)
434
435	if result.Background != "#000000" {
436		t.Errorf("Background should be from src, got %q", result.Background)
437	}
438	if result.Surface != def.Surface {
439		t.Errorf("Surface should be filled from defaults, got %q", result.Surface)
440	}
441	if result.Text != "#ffffff" {
442		t.Errorf("Text should be from src, got %q", result.Text)
443	}
444}
445
446// TestMergePalette_NilSrc verifies MergePalette returns defaults when src is nil.
447func TestMergePalette_NilSrc(t *testing.T) {
448	def := DefaultDarkPalette()
449	result := MergePalette(nil, &def)
450
451	if result.Background != def.Background {
452		t.Errorf("expected default Background, got %q", result.Background)
453	}
454}
455
456// TestMergeFonts_FillsEmpties verifies MergeFonts fills zero fields from defaults.
457func TestMergeFonts_FillsEmpties(t *testing.T) {
458	src := &ThemeFonts{
459		SansFont: "Custom Sans",
460		// SerifFont empty
461		DefaultSize: 0, // zero
462	}
463	def := DefaultThemeFonts()
464	result := MergeFonts(src, def)
465
466	if result.SansFont != "Custom Sans" {
467		t.Errorf("SansFont should be from src, got %q", result.SansFont)
468	}
469	if result.SerifFont != def.SerifFont {
470		t.Errorf("SerifFont should be from defaults, got %q", result.SerifFont)
471	}
472	if result.DefaultSize != def.DefaultSize {
473		t.Errorf("DefaultSize should be from defaults, got %d", result.DefaultSize)
474	}
475}
476
477// TestMergeFonts_NilSrc verifies MergeFonts returns defaults when src is nil.
478func TestMergeFonts_NilSrc(t *testing.T) {
479	def := DefaultThemeFonts()
480	result := MergeFonts(nil, def)
481
482	if result.SansFont != def.SansFont {
483		t.Errorf("expected default SansFont, got %q", result.SansFont)
484	}
485}
486
487// TestMergeModeColors_FillsEmpties verifies mode color merging.
488func TestMergeModeColors_FillsEmpties(t *testing.T) {
489	src := &ThemeModeColors{
490		PaneMode: "#111111",
491		// TabMode empty
492	}
493	def := DefaultThemeModeColors()
494	result := MergeModeColors(src, def)
495
496	if result.PaneMode != "#111111" {
497		t.Errorf("PaneMode should be from src, got %q", result.PaneMode)
498	}
499	if result.VimMode != def.VimMode {
500		t.Errorf("VimMode should use default, got %q", result.VimMode)
501	}
502	if result.TabMode != def.TabMode {
503		t.Errorf("TabMode should be from defaults, got %q", result.TabMode)
504	}
505}
506
507// TestValidateModeColorsHex_ReturnsWarnings verifies invalid mode colors are detected.
508func TestValidateModeColorsHex_ReturnsWarnings(t *testing.T) {
509	colors := &ThemeModeColors{
510		PaneMode:    "#111111",
511		TabMode:     "not-a-color",
512		SessionMode: "#333333",
513		ResizeMode:  "",
514		VimMode:     "#F7768E",
515	}
516
517	warnings := ValidateModeColorsHex(colors, "mode_colors")
518	if len(warnings) != 2 {
519		t.Fatalf("expected 2 invalid mode color warnings, got %d: %+v", len(warnings), warnings)
520	}
521	if IsModeColorsValid(colors) {
522		t.Error("expected invalid mode colors to be rejected")
523	}
524}
525
526// TestValidateModeColorsHex_DefaultsAreValid verifies default mode colors satisfy validation.
527func TestValidateModeColorsHex_DefaultsAreValid(t *testing.T) {
528	defaults := DefaultThemeModeColors()
529	if warnings := ValidateModeColorsHex(&defaults, "mode_colors"); len(warnings) != 0 {
530		t.Fatalf("expected default mode colors to be valid, got warnings: %+v", warnings)
531	}
532	if !IsModeColorsValid(&defaults) {
533		t.Error("expected default mode colors to be valid")
534	}
535}

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 8

Tags 42

↑↓ moveenter openesc close