v0.30.0 eb3068f

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	}
134
135	fields := map[string]string{
136		"PaneMode":    defaults.PaneMode,
137		"TabMode":     defaults.TabMode,
138		"SessionMode": defaults.SessionMode,
139		"ResizeMode":  defaults.ResizeMode,
140	}
141
142	for name, value := range fields {
143		if !IsValidHex(value) {
144			t.Errorf("ThemeModeColors.%s = %q is not a valid CSS-safe hex color", name, value)
145		}
146	}
147}
148
149// TestResolvedTheme_StructFieldsExist is a compile-time guard and field-inspection test
150// to verify that the ResolvedTheme struct carries all required fields from the locked contract.
151func TestResolvedTheme_StructFieldsExist(t *testing.T) {
152	rt := ResolvedTheme{
153		LightPalette: ResolvedThemePalette{
154			Background: "#0a0a0b",
155			Surface:    "#1a1a1b",
156		},
157		DarkPalette: ResolvedThemePalette{
158			Background: "#fafafa",
159			Surface:    "#ffffff",
160		},
161		ActivePalette: ResolvedThemePalette{
162			Background: "#0a0a0b",
163		},
164		PrefersDark:       true,
165		ColorSchemeSource: "adwaita",
166		ThemeSource:       ThemeSourceMetadata{Kind: ThemeSourceConfig},
167		Fonts:             DefaultThemeFonts(),
168		UIScale:           1.0,
169		ModeColors: ThemeModeColors{
170			PaneMode:    "#4A90E2",
171			TabMode:     "#FFA500",
172			SessionMode: "#9B59B6",
173			ResizeMode:  "#00D4AA",
174		},
175		Warnings: []ThemeWarning{
176			{Field: "external", Message: "malformed external theme, using last-good"},
177		},
178	}
179
180	// Verify the struct can be constructed and fields are accessible.
181	if !rt.PrefersDark {
182		t.Error("expected PrefersDark to be true")
183	}
184	if rt.ColorSchemeSource != "adwaita" {
185		t.Errorf("expected ColorSchemeSource 'adwaita', got %q", rt.ColorSchemeSource)
186	}
187	if rt.ThemeSource.Kind != ThemeSourceConfig {
188		t.Errorf("expected ThemeSource.Kind 'config', got %q", rt.ThemeSource.Kind)
189	}
190	if rt.UIScale != 1.0 {
191		t.Errorf("expected UIScale 1.0, got %f", rt.UIScale)
192	}
193	if len(rt.Warnings) != 1 {
194		t.Errorf("expected 1 warning, got %d", len(rt.Warnings))
195	}
196	if rt.Fonts.DefaultSize != 16 {
197		t.Errorf("expected DefaultSize 16, got %d", rt.Fonts.DefaultSize)
198	}
199}
200
201// TestThemeSourceKind_ConstantsAreDistinct verifies the ThemeSourceKind constants
202// have unique values so that source metadata comparisons work correctly.
203func TestThemeSourceKind_ConstantsAreDistinct(t *testing.T) {
204	kinds := map[ThemeSourceKind]bool{
205		ThemeSourceDefault:  true,
206		ThemeSourceConfig:   true,
207		ThemeSourceExternal: true,
208	}
209
210	if len(kinds) != 3 {
211		t.Error("ThemeSourceKind constants must be distinct")
212	}
213
214	if ThemeSourceDefault == ThemeSourceExternal {
215		t.Error("ThemeSourceDefault must not equal ThemeSourceExternal")
216	}
217	if ThemeSourceDefault == ThemeSourceConfig {
218		t.Error("ThemeSourceDefault must not equal ThemeSourceConfig")
219	}
220}
221
222// TestThemeWarning_FieldsExist verifies ThemeWarning carries the expected shape.
223func TestThemeWarning_FieldsExist(t *testing.T) {
224	w := ThemeWarning{
225		Field:   "light_palette.background",
226		Message: "not a valid hex color",
227	}
228
229	if w.Field == "" {
230		t.Error("expected ThemeWarning.Field to be non-empty")
231	}
232	if w.Message == "" {
233		t.Error("expected ThemeWarning.Message to be non-empty")
234	}
235}
236
237// TestExternalTheme_IsDomainPure verifies ExternalTheme can be constructed without any
238// infrastructure imports (this is a compile-time contract via package structure).
239func TestExternalTheme_IsDomainPure(t *testing.T) {
240	light := ColorPalette{
241		Background: "#0a0a0b",
242		Surface:    "#1a1a1b",
243	}
244	dark := ColorPalette{
245		Background: "#fafafa",
246		Surface:    "#ffffff",
247	}
248
249	ext := ExternalTheme{
250		Name:         "Noctalia Dark",
251		Provider:     "noctalia",
252		LightPalette: &light,
253		DarkPalette:  &dark,
254	}
255
256	if ext.Name != "Noctalia Dark" {
257		t.Errorf("expected Name 'Noctalia Dark', got %q", ext.Name)
258	}
259	if ext.Provider != "noctalia" {
260		t.Errorf("expected Provider 'noctalia', got %q", ext.Provider)
261	}
262	if ext.LightPalette == nil || ext.DarkPalette == nil {
263		t.Error("expected non-nil palettes")
264	}
265}
266
267// TestDefaultThemeFonts_AllFieldsAreSet ensures no zero-value fields in the defaults.
268func TestDefaultThemeFonts_AllFieldsAreSet(t *testing.T) {
269	fonts := DefaultThemeFonts()
270
271	// Check that all string fields are non-empty
272	if fonts.SansFont == "" {
273		t.Error("SansFont should have a default value")
274	}
275	if fonts.SerifFont == "" {
276		t.Error("SerifFont should have a default value")
277	}
278	if fonts.MonospaceFont == "" {
279		t.Error("MonospaceFont should have a default value")
280	}
281	if fonts.GtkFont == "" {
282		t.Error("GtkFont should have a default value")
283	}
284	if fonts.DefaultSize <= 0 {
285		t.Errorf("DefaultSize should be positive, got %d", fonts.DefaultSize)
286	}
287}
288
289// TestDefaultThemeModeColors_ReturnsSaneDefaults verifies the default mode colors.
290func TestDefaultThemeModeColors_ReturnsSaneDefaults(t *testing.T) {
291	mc := DefaultThemeModeColors()
292
293	if mc.PaneMode != "#4A90E2" {
294		t.Errorf("expected PaneMode #4A90E2, got %q", mc.PaneMode)
295	}
296	if mc.TabMode != "#FFA500" {
297		t.Errorf("expected TabMode #FFA500, got %q", mc.TabMode)
298	}
299	if mc.SessionMode != "#9B59B6" {
300		t.Errorf("expected SessionMode #9B59B6, got %q", mc.SessionMode)
301	}
302	if mc.ResizeMode != "#00D4AA" {
303		t.Errorf("expected ResizeMode #00D4AA, got %q", mc.ResizeMode)
304	}
305
306	// All must be valid hex
307	if !IsValidHex(mc.PaneMode) {
308		t.Error("default PaneMode should be valid hex")
309	}
310	if !IsValidHex(mc.TabMode) {
311		t.Error("default TabMode should be valid hex")
312	}
313	if !IsValidHex(mc.SessionMode) {
314		t.Error("default SessionMode should be valid hex")
315	}
316	if !IsValidHex(mc.ResizeMode) {
317		t.Error("default ResizeMode should be valid hex")
318	}
319}
320
321// TestDefaultDarkPalette_ReturnsValidHex verifies the default dark palette is all valid hex.
322func TestDefaultDarkPalette_ReturnsValidHex(t *testing.T) {
323	p := DefaultDarkPalette()
324
325	fields := map[string]string{
326		"Background":     p.Background,
327		"Surface":        p.Surface,
328		"SurfaceVariant": p.SurfaceVariant,
329		"Text":           p.Text,
330		"Muted":          p.Muted,
331		"Accent":         p.Accent,
332		"Border":         p.Border,
333	}
334	for name, value := range fields {
335		if value == "" {
336			t.Errorf("DefaultDarkPalette.%s should not be empty", name)
337		}
338		if !IsValidHex(value) {
339			t.Errorf("DefaultDarkPalette.%s = %q is not valid hex", name, value)
340		}
341	}
342}
343
344// TestDefaultLightPalette_ReturnsValidHex verifies the default light palette is all valid hex.
345func TestDefaultLightPalette_ReturnsValidHex(t *testing.T) {
346	p := DefaultLightPalette()
347
348	fields := map[string]string{
349		"Background":     p.Background,
350		"Surface":        p.Surface,
351		"SurfaceVariant": p.SurfaceVariant,
352		"Text":           p.Text,
353		"Muted":          p.Muted,
354		"Accent":         p.Accent,
355		"Border":         p.Border,
356	}
357	for name, value := range fields {
358		if value == "" {
359			t.Errorf("DefaultLightPalette.%s should not be empty", name)
360		}
361		if !IsValidHex(value) {
362			t.Errorf("DefaultLightPalette.%s = %q is not valid hex", name, value)
363		}
364	}
365}
366
367// TestValidatePaletteHex_Valid verifies ValidatePaletteHex returns no warnings for valid palettes.
368func TestValidatePaletteHex_Valid(t *testing.T) {
369	p := DefaultDarkPalette()
370	warnings := ValidatePaletteHex(&p, "dark")
371	if len(warnings) != 0 {
372		t.Errorf("expected no warnings for valid palette, got %d: %v", len(warnings), warnings)
373	}
374}
375
376// TestValidatePaletteHex_Invalid verifies ValidatePaletteHex returns warnings for invalid palettes.
377func TestValidatePaletteHex_Invalid(t *testing.T) {
378	p := ColorPalette{
379		Background:     "rgb(0,0,0)", // invalid
380		Surface:        "#ffffff",
381		SurfaceVariant: "not-a-color",
382		Text:           "",        // invalid
383		Muted:          "#GGGGGG", // invalid
384		Accent:         "#123",    // 3-char shorthand, rejected
385		Border:         "#123456",
386	}
387	warnings := ValidatePaletteHex(&p, "test")
388	if len(warnings) == 0 {
389		t.Error("expected warnings for invalid palette, got none")
390	}
391}
392
393// TestValidatePaletteHex_Nil verifies ValidatePaletteHex handles nil.
394func TestValidatePaletteHex_Nil(t *testing.T) {
395	warnings := ValidatePaletteHex(nil, "ext")
396	if len(warnings) == 0 {
397		t.Error("expected warnings for nil palette")
398	}
399}
400
401// TestIsPaletteValid verifies IsPaletteValid returns correct truthiness.
402func TestIsPaletteValid_EdgeCases(t *testing.T) {
403	if IsPaletteValid(nil) {
404		t.Error("nil palette should not be valid")
405	}
406
407	valid := DefaultDarkPalette()
408	if !IsPaletteValid(&valid) {
409		t.Error("default dark palette should be valid")
410	}
411
412	partial := ColorPalette{
413		Background: "#000000",
414		Surface:    "#111111",
415		// missing fields are ""
416	}
417	if IsPaletteValid(&partial) {
418		t.Error("partial palette with empty fields should not be valid")
419	}
420}
421
422// TestMergePalette_FillsEmpties verifies MergePalette fills empty fields from defaults.
423func TestMergePalette_FillsEmpties(t *testing.T) {
424	src := &ColorPalette{
425		Background: "#000000",
426		Surface:    "", // should be filled
427		Text:       "#ffffff",
428	}
429	def := DefaultDarkPalette()
430	result := MergePalette(src, &def)
431
432	if result.Background != "#000000" {
433		t.Errorf("Background should be from src, got %q", result.Background)
434	}
435	if result.Surface != def.Surface {
436		t.Errorf("Surface should be filled from defaults, got %q", result.Surface)
437	}
438	if result.Text != "#ffffff" {
439		t.Errorf("Text should be from src, got %q", result.Text)
440	}
441}
442
443// TestMergePalette_NilSrc verifies MergePalette returns defaults when src is nil.
444func TestMergePalette_NilSrc(t *testing.T) {
445	def := DefaultDarkPalette()
446	result := MergePalette(nil, &def)
447
448	if result.Background != def.Background {
449		t.Errorf("expected default Background, got %q", result.Background)
450	}
451}
452
453// TestMergeFonts_FillsEmpties verifies MergeFonts fills zero fields from defaults.
454func TestMergeFonts_FillsEmpties(t *testing.T) {
455	src := &ThemeFonts{
456		SansFont: "Custom Sans",
457		// SerifFont empty
458		DefaultSize: 0, // zero
459	}
460	def := DefaultThemeFonts()
461	result := MergeFonts(src, def)
462
463	if result.SansFont != "Custom Sans" {
464		t.Errorf("SansFont should be from src, got %q", result.SansFont)
465	}
466	if result.SerifFont != def.SerifFont {
467		t.Errorf("SerifFont should be from defaults, got %q", result.SerifFont)
468	}
469	if result.DefaultSize != def.DefaultSize {
470		t.Errorf("DefaultSize should be from defaults, got %d", result.DefaultSize)
471	}
472}
473
474// TestMergeFonts_NilSrc verifies MergeFonts returns defaults when src is nil.
475func TestMergeFonts_NilSrc(t *testing.T) {
476	def := DefaultThemeFonts()
477	result := MergeFonts(nil, def)
478
479	if result.SansFont != def.SansFont {
480		t.Errorf("expected default SansFont, got %q", result.SansFont)
481	}
482}
483
484// TestMergeModeColors_FillsEmpties verifies mode color merging.
485func TestMergeModeColors_FillsEmpties(t *testing.T) {
486	src := &ThemeModeColors{
487		PaneMode: "#111111",
488		// TabMode empty
489	}
490	def := DefaultThemeModeColors()
491	result := MergeModeColors(src, def)
492
493	if result.PaneMode != "#111111" {
494		t.Errorf("PaneMode should be from src, got %q", result.PaneMode)
495	}
496	if result.TabMode != def.TabMode {
497		t.Errorf("TabMode should be from defaults, got %q", result.TabMode)
498	}
499}
500
501// TestValidateModeColorsHex_ReturnsWarnings verifies invalid mode colors are detected.
502func TestValidateModeColorsHex_ReturnsWarnings(t *testing.T) {
503	colors := &ThemeModeColors{
504		PaneMode:    "#111111",
505		TabMode:     "not-a-color",
506		SessionMode: "#333333",
507		ResizeMode:  "",
508	}
509
510	warnings := ValidateModeColorsHex(colors, "mode_colors")
511	if len(warnings) != 2 {
512		t.Fatalf("expected 2 invalid mode color warnings, got %d: %+v", len(warnings), warnings)
513	}
514	if IsModeColorsValid(colors) {
515		t.Error("expected invalid mode colors to be rejected")
516	}
517}
518
519// TestValidateModeColorsHex_DefaultsAreValid verifies default mode colors satisfy validation.
520func TestValidateModeColorsHex_DefaultsAreValid(t *testing.T) {
521	defaults := DefaultThemeModeColors()
522	if warnings := ValidateModeColorsHex(&defaults, "mode_colors"); len(warnings) != 0 {
523		t.Fatalf("expected default mode colors to be valid, got warnings: %+v", warnings)
524	}
525	if !IsModeColorsValid(&defaults) {
526		t.Error("expected default mode colors to be valid")
527	}
528}

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