resolve_theme_test.go RawHistoryFolder

1package usecase
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"strings"
8	"testing"
9
10	"github.com/bnema/dumber/internal/application/port"
11	"github.com/bnema/dumber/internal/domain/entity"
12)
13
14// assertNoWarnings fails the test if there are any warnings.
15func assertNoWarnings(t *testing.T, warnings []entity.ThemeWarning) {
16	t.Helper()
17	if len(warnings) > 0 {
18		var msgs []string
19		for _, w := range warnings {
20			msgs = append(msgs, fmt.Sprintf("%s: %s", w.Field, w.Message))
21		}
22		t.Errorf("unexpected warnings: %s", strings.Join(msgs, "; "))
23	}
24}
25
26// assertValidPaletteHex checks every color field in a palette is valid CSS-safe hex.
27func assertValidPaletteHex(t *testing.T, label string, p entity.ResolvedThemePalette) {
28	t.Helper()
29	fields := map[string]string{
30		"Background":     p.Background,
31		"Surface":        p.Surface,
32		"SurfaceVariant": p.SurfaceVariant,
33		"Text":           p.Text,
34		"Muted":          p.Muted,
35		"Accent":         p.Accent,
36		"Border":         p.Border,
37	}
38	for name, value := range fields {
39		if !entity.IsValidHex(value) {
40			t.Errorf("%s.%s = %q is not a valid CSS-safe hex color", label, name, value)
41		}
42	}
43}
44
45// newMockExternalSource creates a test double for the external theme source port.
46type newMockExternalSource struct {
47	theme    *entity.ExternalTheme
48	err      error
49	enabled  bool
50	identity string
51}
52
53func (m *newMockExternalSource) Get(_ context.Context) (*entity.ExternalTheme, error) {
54	return m.theme, m.err
55}
56
57func (m *newMockExternalSource) IsEnabled() bool {
58	return m.enabled
59}
60
61func (m *newMockExternalSource) ExternalThemeIdentity() string {
62	return m.identity
63}
64
65// configLightPalette returns a recognizable light palette for config.
66func configLightPalette() *entity.ColorPalette {
67	return &entity.ColorPalette{
68		Background:     "#fafafa",
69		Surface:        "#ffffff",
70		SurfaceVariant: "#f0f0f0",
71		Text:           "#1a1a1a",
72		Muted:          "#666666",
73		Accent:         "#22c55e",
74		Border:         "#dddddd",
75	}
76}
77
78// configDarkPalette returns a recognizable dark palette for config.
79func configDarkPalette() *entity.ColorPalette {
80	return &entity.ColorPalette{
81		Background:     "#0a0a0b",
82		Surface:        "#1a1a1b",
83		SurfaceVariant: "#2d2d2d",
84		Text:           "#ffffff",
85		Muted:          "#909090",
86		Accent:         "#4ade80",
87		Border:         "#333333",
88	}
89}
90
91// externalLightPalette returns a different light palette for external testing.
92func externalLightPalette() *entity.ColorPalette {
93	return &entity.ColorPalette{
94		Background:     "#fff8e7",
95		Surface:        "#fffef5",
96		SurfaceVariant: "#f5ecd7",
97		Text:           "#2d2a1e",
98		Muted:          "#8c876e",
99		Accent:         "#d4a017",
100		Border:         "#e0d8b0",
101	}
102}
103
104// externalDarkPalette returns a different dark palette for external testing.
105func externalDarkPalette() *entity.ColorPalette {
106	return &entity.ColorPalette{
107		Background:     "#1a1a2e",
108		Surface:        "#16213e",
109		SurfaceVariant: "#0f3460",
110		Text:           "#e0e0e0",
111		Muted:          "#a0a0a0",
112		Accent:         "#e94560",
113		Border:         "#533483",
114	}
115}
116
117// validExternalTheme returns a fully valid ExternalTheme for testing.
118func validExternalTheme() *entity.ExternalTheme {
119	return &entity.ExternalTheme{
120		Name:         "Noctalia Test",
121		Provider:     "noctalia",
122		LightPalette: externalLightPalette(),
123		DarkPalette:  externalDarkPalette(),
124	}
125}
126
127// ============================================================
128// Precedence: External disabled → config/default palettes
129// ============================================================
130
131// TestResolveTheme_ExternalDisabled_UsesConfigPalettes verifies that when the external
132// source is disabled, config palettes (or defaults) are used.
133func TestResolveTheme_ExternalDisabled_UsesConfigPalettes(t *testing.T) {
134	uc := NewResolveThemeUseCase(nil)
135
136	light := configLightPalette()
137	dark := configDarkPalette()
138
139	input := ResolveThemeInput{
140		ColorScheme:       "default",
141		PrefersDark:       true,
142		ColorSchemeSource: "adwaita",
143		LightPalette:      light,
144		DarkPalette:       dark,
145		UIScale:           1.0,
146	}
147
148	output, err := uc.Execute(context.Background(), input)
149	if err != nil {
150		t.Fatalf("unexpected error: %v", err)
151	}
152
153	assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette)
154	assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette)
155	assertValidPaletteHex(t, "ActivePalette", output.Theme.ActivePalette)
156
157	// With PrefersDark=true and no external, light palette should be config
158	if output.Theme.LightPalette.Background != light.Background {
159		t.Errorf("expected LightPalette.Background=%q, got %q", light.Background, output.Theme.LightPalette.Background)
160	}
161	// Active palette should be dark (PrefersDark=true)
162	if output.Theme.ActivePalette.Background != dark.Background {
163		t.Errorf("expected ActivePalette.Background=%q (dark), got %q", dark.Background, output.Theme.ActivePalette.Background)
164	}
165}
166
167// TestResolveTheme_NoConfig_UsesDefaults verifies that when neither config nor external
168// provide palettes, sensible defaults are returned.
169func TestResolveTheme_NoConfig_UsesDefaults(t *testing.T) {
170	uc := NewResolveThemeUseCase(nil)
171
172	input := ResolveThemeInput{
173		ColorScheme:       "default",
174		PrefersDark:       true,
175		ColorSchemeSource: "fallback",
176		UIScale:           1.0,
177		// LightPalette and DarkPalette are nil (no config)
178	}
179
180	output, err := uc.Execute(context.Background(), input)
181	if err != nil {
182		t.Fatalf("unexpected error: %v", err)
183	}
184
185	assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette)
186	assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette)
187	assertValidPaletteHex(t, "ActivePalette", output.Theme.ActivePalette)
188
189	// Defaults must be non-trivial (Background, Text non-empty)
190	if output.Theme.DarkPalette.Background == "" {
191		t.Error("expected non-empty DarkPalette.Background from defaults")
192	}
193	if output.Theme.DarkPalette.Text == "" {
194		t.Error("expected non-empty DarkPalette.Text from defaults")
195	}
196	if output.Theme.LightPalette.Background == "" {
197		t.Error("expected non-empty LightPalette.Background from defaults")
198	}
199}
200
201// ============================================================
202// Precedence: Valid external → external palettes override palette colors only
203// ============================================================
204
205// TestResolveTheme_ValidExternal_OverridesPaletteColors verifies that when a valid
206// external theme is available, its palette colors override config palette colors,
207// but other fields (fonts, mode colors, scale) come from config or defaults.
208func TestResolveTheme_ValidExternal_OverridesPaletteColors(t *testing.T) {
209	ext := validExternalTheme()
210	mock := &newMockExternalSource{
211		theme:   ext,
212		enabled: true,
213	}
214	uc := NewResolveThemeUseCase(mock)
215
216	input := ResolveThemeInput{
217		ColorScheme:       "default",
218		PrefersDark:       true,
219		ColorSchemeSource: "adwaita",
220		LightPalette:      configLightPalette(),
221		DarkPalette:       configDarkPalette(),
222		Fonts: &entity.ThemeFonts{
223			SansFont: "config-sans",
224		},
225		UIScale: 1.5,
226	}
227
228	output, err := uc.Execute(context.Background(), input)
229	if err != nil {
230		t.Fatalf("unexpected error: %v", err)
231	}
232
233	assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette)
234	assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette)
235	assertValidPaletteHex(t, "ActivePalette", output.Theme.ActivePalette)
236
237	// External palette should override config palette
238	if output.Theme.DarkPalette.Background != ext.DarkPalette.Background {
239		t.Errorf("expected DarkPalette.Background from external (%q), got %q",
240			ext.DarkPalette.Background, output.Theme.DarkPalette.Background)
241	}
242	if output.Theme.DarkPalette.Accent != ext.DarkPalette.Accent {
243		t.Errorf("expected DarkPalette.Accent from external (%q), got %q",
244			ext.DarkPalette.Accent, output.Theme.DarkPalette.Accent)
245	}
246
247	// ThemeSource should indicate external
248	if output.Theme.ThemeSource.Kind != entity.ThemeSourceExternal {
249		t.Errorf("expected ThemeSource.Kind=external, got %q", output.Theme.ThemeSource.Kind)
250	}
251	if output.Theme.ThemeSource.Provider != "noctalia" {
252		t.Errorf("expected ThemeSource.Provider=noctalia, got %q", output.Theme.ThemeSource.Provider)
253	}
254	if output.Theme.ThemeSource.LastGood {
255		t.Error("expected ThemeSource.LastGood=false for fresh valid external theme")
256	}
257
258	// ColorSchemeSource should still reflect the detector
259	if output.Theme.ColorSchemeSource != "adwaita" {
260		t.Errorf("expected ColorSchemeSource='adwaita', got %q", output.Theme.ColorSchemeSource)
261	}
262}
263
264// TestResolveTheme_ValidExternal_DoesNotOverrideNonPaletteFields verifies that
265// external theme only overrides palette colors, not fonts, scale, or mode colors.
266func TestResolveTheme_ValidExternal_DoesNotOverrideNonPaletteFields(t *testing.T) {
267	ext := validExternalTheme()
268	mock := &newMockExternalSource{
269		theme:   ext,
270		enabled: true,
271	}
272	uc := NewResolveThemeUseCase(mock)
273
274	input := ResolveThemeInput{
275		ColorScheme:       "default",
276		PrefersDark:       false,
277		ColorSchemeSource: "env",
278		Fonts: &entity.ThemeFonts{
279			SansFont:      "MyConfigSans",
280			SerifFont:     "MyConfigSerif",
281			MonospaceFont: "MyConfigMono",
282			GtkFont:       "MyConfigGtk",
283			DefaultSize:   14,
284		},
285		UIScale: 2.0,
286		ModeColors: &entity.ThemeModeColors{
287			PaneMode:    "#111111",
288			TabMode:     "#222222",
289			SessionMode: "#333333",
290			ResizeMode:  "#444444",
291		},
292	}
293
294	output, err := uc.Execute(context.Background(), input)
295	if err != nil {
296		t.Fatalf("unexpected error: %v", err)
297	}
298
299	// Fonts should come from config, not external
300	if output.Theme.Fonts.SansFont != "MyConfigSans" {
301		t.Errorf("expected SansFont from config (%q), got %q",
302			"MyConfigSans", output.Theme.Fonts.SansFont)
303	}
304	if output.Theme.Fonts.DefaultSize != 14 {
305		t.Errorf("expected DefaultFontSize from config (14), got %d",
306			output.Theme.Fonts.DefaultSize)
307	}
308
309	// UI scale should come from config
310	if output.Theme.UIScale != 2.0 {
311		t.Errorf("expected UIScale=2.0 from config, got %f", output.Theme.UIScale)
312	}
313
314	// Mode colors should come from config
315	if output.Theme.ModeColors.PaneMode != "#111111" {
316		t.Errorf("expected PaneMode from config (#111111), got %q",
317			output.Theme.ModeColors.PaneMode)
318	}
319}
320
321// TestResolveTheme_PartialExternal_MergesOverConfig verifies that empty external
322// fields are treated as "inherit" rather than malformed.
323func TestResolveTheme_PartialExternal_MergesOverConfig(t *testing.T) {
324	partialLight := &entity.ColorPalette{
325		Accent: "#123456",
326	}
327	partialDark := &entity.ColorPalette{
328		Background: "#010203",
329		Border:     "#abcdef",
330	}
331	mock := &newMockExternalSource{
332		theme: &entity.ExternalTheme{
333			Name:         "Partial Noctalia",
334			Provider:     "noctalia",
335			LightPalette: partialLight,
336			DarkPalette:  partialDark,
337		},
338		enabled: true,
339	}
340	uc := NewResolveThemeUseCase(mock)
341
342	input := ResolveThemeInput{
343		PrefersDark:       true,
344		ColorSchemeSource: "adwaita",
345		LightPalette:      configLightPalette(),
346		DarkPalette:       configDarkPalette(),
347		UIScale:           1.0,
348	}
349
350	output, err := uc.Execute(context.Background(), input)
351	if err != nil {
352		t.Fatalf("unexpected error: %v", err)
353	}
354
355	assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette)
356	assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette)
357	assertNoWarnings(t, output.Theme.Warnings)
358
359	if output.Theme.LightPalette.Accent != "#123456" {
360		t.Errorf("expected partial external light accent override, got %q", output.Theme.LightPalette.Accent)
361	}
362	if output.Theme.LightPalette.Background != configLightPalette().Background {
363		t.Errorf("expected empty external light background to inherit config value %q, got %q",
364			configLightPalette().Background, output.Theme.LightPalette.Background)
365	}
366	if output.Theme.DarkPalette.Background != "#010203" {
367		t.Errorf("expected partial external dark background override, got %q", output.Theme.DarkPalette.Background)
368	}
369	if output.Theme.DarkPalette.Text != configDarkPalette().Text {
370		t.Errorf("expected empty external dark text to inherit config value %q, got %q",
371			configDarkPalette().Text, output.Theme.DarkPalette.Text)
372	}
373	if output.Theme.ThemeSource.Kind != entity.ThemeSourceExternal || output.Theme.ThemeSource.Provider != "noctalia" {
374		t.Errorf("expected noctalia external source metadata, got %+v", output.Theme.ThemeSource)
375	}
376}
377
378// TestResolveTheme_PartialExternal_MergesOverDefaults verifies partial external
379// palettes also merge over built-in defaults when config palettes are absent.
380func TestResolveTheme_PartialExternal_MergesOverDefaults(t *testing.T) {
381	mock := &newMockExternalSource{
382		theme: &entity.ExternalTheme{
383			Name: "Partial Defaults",
384			LightPalette: &entity.ColorPalette{
385				Accent: "#112233",
386			},
387			DarkPalette: &entity.ColorPalette{
388				Accent: "#445566",
389			},
390		},
391		enabled: true,
392	}
393	uc := NewResolveThemeUseCase(mock)
394
395	output, err := uc.Execute(context.Background(), ResolveThemeInput{PrefersDark: false})
396	if err != nil {
397		t.Fatalf("unexpected error: %v", err)
398	}
399
400	assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette)
401	assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette)
402	if output.Theme.LightPalette.Accent != "#112233" {
403		t.Errorf("expected external light accent, got %q", output.Theme.LightPalette.Accent)
404	}
405	if output.Theme.LightPalette.Background != entity.DefaultLightPalette().Background {
406		t.Errorf("expected light background to inherit default %q, got %q",
407			entity.DefaultLightPalette().Background, output.Theme.LightPalette.Background)
408	}
409	if output.Theme.DarkPalette.Accent != "#445566" {
410		t.Errorf("expected external dark accent, got %q", output.Theme.DarkPalette.Accent)
411	}
412}
413
414func TestResolveTheme_PartialExternal_RepairsInvalidConfigFallbackFields(t *testing.T) {
415	partialLight := &entity.ColorPalette{Accent: "#112233"}
416	partialDark := &entity.ColorPalette{Background: "#445566"}
417	mock := &newMockExternalSource{
418		theme: &entity.ExternalTheme{
419			Name:         "Partial With Invalid Config",
420			Provider:     "noctalia",
421			LightPalette: partialLight,
422			DarkPalette:  partialDark,
423		},
424		enabled: true,
425	}
426	uc := NewResolveThemeUseCase(mock)
427	invalidLight := *configLightPalette()
428	invalidLight.Background = "not-a-hex"
429	invalidDark := *configDarkPalette()
430	invalidDark.Text = "also-invalid"
431
432	output, err := uc.Execute(context.Background(), ResolveThemeInput{
433		PrefersDark:  true,
434		LightPalette: &invalidLight,
435		DarkPalette:  &invalidDark,
436		UIScale:      1.0,
437	})
438	if err != nil {
439		t.Fatalf("unexpected error: %v", err)
440	}
441
442	assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette)
443	assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette)
444	if output.Theme.LightPalette.Accent != "#112233" {
445		t.Fatalf("expected valid external light accent to survive invalid config fallback, got %q", output.Theme.LightPalette.Accent)
446	}
447	if output.Theme.LightPalette.Background != entity.DefaultLightPalette().Background {
448		t.Fatalf("expected invalid config light background to be repaired from defaults, got %q", output.Theme.LightPalette.Background)
449	}
450	if output.Theme.DarkPalette.Background != "#445566" {
451		t.Fatalf("expected valid external dark background to survive invalid config fallback, got %q", output.Theme.DarkPalette.Background)
452	}
453	if output.Theme.DarkPalette.Text != entity.DefaultDarkPalette().Text {
454		t.Fatalf("expected invalid config dark text to be repaired from defaults, got %q", output.Theme.DarkPalette.Text)
455	}
456	if output.Theme.ThemeSource.Kind != entity.ThemeSourceExternal || output.Theme.ThemeSource.LastGood {
457		t.Fatalf("expected fresh external source metadata, got %+v", output.Theme.ThemeSource)
458	}
459}
460
461// ============================================================
462// PrefersDark selects ActivePalette
463// ============================================================
464
465// TestResolveTheme_PrefersDark_SelectsDarkPalette verifies that when PrefersDark=true,
466// ActivePalette is the dark palette.
467func TestResolveTheme_PrefersDark_SelectsDarkPalette(t *testing.T) {
468	uc := NewResolveThemeUseCase(nil)
469
470	input := ResolveThemeInput{
471		PrefersDark:       true,
472		ColorSchemeSource: "config",
473		LightPalette:      configLightPalette(),
474		DarkPalette:       configDarkPalette(),
475		UIScale:           1.0,
476	}
477
478	output, err := uc.Execute(context.Background(), input)
479	if err != nil {
480		t.Fatalf("unexpected error: %v", err)
481	}
482
483	if output.Theme.ActivePalette.Background != configDarkPalette().Background {
484		t.Errorf("expected ActivePalette to be dark palette, got Background=%q",
485			output.Theme.ActivePalette.Background)
486	}
487	if !output.Theme.PrefersDark {
488		t.Error("expected PrefersDark=true in output")
489	}
490}
491
492// TestResolveTheme_PrefersLight_SelectsLightPalette verifies that when PrefersDark=false,
493// ActivePalette is the light palette.
494func TestResolveTheme_PrefersLight_SelectsLightPalette(t *testing.T) {
495	uc := NewResolveThemeUseCase(nil)
496
497	input := ResolveThemeInput{
498		PrefersDark:       false,
499		ColorSchemeSource: "config",
500		LightPalette:      configLightPalette(),
501		DarkPalette:       configDarkPalette(),
502		UIScale:           1.0,
503	}
504
505	output, err := uc.Execute(context.Background(), input)
506	if err != nil {
507		t.Fatalf("unexpected error: %v", err)
508	}
509
510	if output.Theme.ActivePalette.Background != configLightPalette().Background {
511		t.Errorf("expected ActivePalette to be light palette, got Background=%q",
512			output.Theme.ActivePalette.Background)
513	}
514	if output.Theme.PrefersDark {
515		t.Error("expected PrefersDark=false in output")
516	}
517}
518
519// ============================================================
520// Malformed external at startup → config/default palettes + warning
521// ============================================================
522
523// TestResolveTheme_MalformedExternalAtStartup_FallsBackToConfig tests that when the
524// external theme has malformed palette colors on first resolution, the usecase falls
525// back to config/defaults and includes a warning.
526func TestResolveTheme_MalformedExternalAtStartup_FallsBackToConfig(t *testing.T) {
527	malformed := &entity.ExternalTheme{
528		Name: "Malformed Theme",
529		LightPalette: &entity.ColorPalette{
530			Background: "rgb(0,0,0)", // not hex
531			Text:       "#ffffff",
532		},
533		DarkPalette: &entity.ColorPalette{
534			Background: "#hijklm", // invalid hex
535			Text:       "#ffffff",
536		},
537	}
538	mock := &newMockExternalSource{
539		theme:   malformed,
540		enabled: true,
541	}
542	uc := NewResolveThemeUseCase(mock)
543
544	input := ResolveThemeInput{
545		PrefersDark:       true,
546		ColorSchemeSource: "adwaita",
547		LightPalette:      configLightPalette(),
548		DarkPalette:       configDarkPalette(),
549		UIScale:           1.0,
550	}
551
552	output, err := uc.Execute(context.Background(), input)
553	if err != nil {
554		t.Fatalf("unexpected error: %v", err)
555	}
556
557	assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette)
558	assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette)
559	assertValidPaletteHex(t, "ActivePalette", output.Theme.ActivePalette)
560
561	// Should fall back to config palette (not the malformed external)
562	if output.Theme.DarkPalette.Background != configDarkPalette().Background {
563		t.Errorf("expected fallback to config dark palette (%q), got %q",
564			configDarkPalette().Background, output.Theme.DarkPalette.Background)
565	}
566
567	// Should have at least one warning about the malformed external
568	if len(output.Theme.Warnings) == 0 {
569		t.Error("expected at least one warning for malformed external theme")
570	}
571}
572
573// TestResolveTheme_MissingExternalAtStartup_FallsBackWithWarning verifies that enabled
574// external sources returning nil data are treated as missing, not silently ignored.
575func TestResolveTheme_MissingExternalAtStartup_FallsBackWithWarning(t *testing.T) {
576	mock := &newMockExternalSource{
577		theme:   nil,
578		enabled: true,
579	}
580	uc := NewResolveThemeUseCase(mock)
581
582	input := ResolveThemeInput{
583		PrefersDark:       true,
584		ColorSchemeSource: "adwaita",
585		LightPalette:      configLightPalette(),
586		DarkPalette:       configDarkPalette(),
587		UIScale:           1.0,
588	}
589
590	output, err := uc.Execute(context.Background(), input)
591	if err != nil {
592		t.Fatalf("unexpected error: %v", err)
593	}
594
595	if output.Theme.DarkPalette.Background != configDarkPalette().Background {
596		t.Errorf("expected missing external startup fallback to config dark palette %q, got %q",
597			configDarkPalette().Background, output.Theme.DarkPalette.Background)
598	}
599	if len(output.Theme.Warnings) == 0 {
600		t.Fatal("expected warning for missing external theme")
601	}
602	if output.Theme.ThemeSource.Kind == entity.ThemeSourceExternal {
603		t.Errorf("expected non-external source for startup missing external, got %+v", output.Theme.ThemeSource)
604	}
605}
606
607func TestResolveTheme_ReadErrorAtStartup_FallsBackWithWarning(t *testing.T) {
608	mock := &newMockExternalSource{
609		err:      errors.New("read failed"),
610		enabled:  true,
611		identity: "noctalia|colors-json|/tmp/theme-a.json",
612	}
613	uc := NewResolveThemeUseCase(mock)
614
615	input := ResolveThemeInput{
616		PrefersDark:       true,
617		ColorSchemeSource: "adwaita",
618		LightPalette:      configLightPalette(),
619		DarkPalette:       configDarkPalette(),
620		UIScale:           1.0,
621	}
622
623	output, err := uc.Execute(context.Background(), input)
624	if err != nil {
625		t.Fatalf("unexpected error: %v", err)
626	}
627	if output.Theme.DarkPalette.Background != configDarkPalette().Background {
628		t.Errorf("expected read-error startup fallback to config dark palette %q, got %q",
629			configDarkPalette().Background, output.Theme.DarkPalette.Background)
630	}
631	if output.Theme.ThemeSource.Kind == entity.ThemeSourceExternal {
632		t.Errorf("expected non-external source for startup read error, got %+v", output.Theme.ThemeSource)
633	}
634	if len(output.Theme.Warnings) == 0 {
635		t.Fatal("expected warning for external read error")
636	}
637}
638
639func TestResolveTheme_ReadErrorAfterValid_KeepsLastGoodForSameIdentity(t *testing.T) {
640	ext := validExternalTheme()
641	mock := &newMockExternalSource{
642		theme:    ext,
643		enabled:  true,
644		identity: "noctalia|colors-json|/tmp/theme-a.json",
645	}
646	uc := NewResolveThemeUseCase(mock)
647
648	input := ResolveThemeInput{
649		PrefersDark:       true,
650		ColorSchemeSource: "adwaita",
651		LightPalette:      configLightPalette(),
652		DarkPalette:       configDarkPalette(),
653		UIScale:           1.0,
654	}
655
656	first, err := uc.Execute(context.Background(), input)
657	if err != nil {
658		t.Fatalf("first resolution error: %v", err)
659	}
660	mock.theme = nil
661	mock.err = errors.New("temporary read failure")
662
663	second, err := uc.Execute(context.Background(), input)
664	if err != nil {
665		t.Fatalf("second resolution error: %v", err)
666	}
667	if second.Theme.DarkPalette.Background != first.Theme.DarkPalette.Background {
668		t.Errorf("expected same-identity read error to keep last-good dark background %q, got %q",
669			first.Theme.DarkPalette.Background, second.Theme.DarkPalette.Background)
670	}
671	if !second.Theme.ThemeSource.LastGood {
672		t.Fatal("expected LastGood=true for same-identity read error after valid external")
673	}
674}
675
676func TestResolveTheme_ExternalIdentityChangeClearsLastGoodBeforeReadError(t *testing.T) {
677	ext := validExternalTheme()
678	mock := &newMockExternalSource{
679		theme:    ext,
680		enabled:  true,
681		identity: "noctalia|colors-json|/tmp/theme-a.json",
682	}
683	uc := NewResolveThemeUseCase(mock)
684
685	input := ResolveThemeInput{
686		PrefersDark:       true,
687		ColorSchemeSource: "adwaita",
688		LightPalette:      configLightPalette(),
689		DarkPalette:       configDarkPalette(),
690		UIScale:           1.0,
691	}
692
693	first, err := uc.Execute(context.Background(), input)
694	if err != nil {
695		t.Fatalf("first resolution error: %v", err)
696	}
697	if first.Theme.DarkPalette.Background == configDarkPalette().Background {
698		t.Fatal("first resolution should use external palette, not config")
699	}
700
701	mock.theme = nil
702	mock.err = errors.New("new path missing")
703	mock.identity = "noctalia|colors-json|/tmp/theme-b.json"
704
705	second, err := uc.Execute(context.Background(), input)
706	if err != nil {
707		t.Fatalf("second resolution error: %v", err)
708	}
709	if second.Theme.DarkPalette.Background != configDarkPalette().Background {
710		t.Errorf("expected changed-identity read error to fall back to config dark background %q, got %q",
711			configDarkPalette().Background, second.Theme.DarkPalette.Background)
712	}
713	if second.Theme.ThemeSource.LastGood {
714		t.Fatal("expected LastGood=false after external identity changed")
715	}
716	if second.Theme.ThemeSource.Kind == entity.ThemeSourceExternal {
717		t.Errorf("expected non-external source after changed-identity read error, got %+v", second.Theme.ThemeSource)
718	}
719}
720
721// TestResolveTheme_MissingExternalAfterValid_KeepsLastGood verifies missing nil data
722// after a valid external theme keeps last-good with warning metadata.
723func TestResolveTheme_MissingExternalAfterValid_KeepsLastGood(t *testing.T) {
724	ext := validExternalTheme()
725	mock := &newMockExternalSource{
726		theme:   ext,
727		enabled: true,
728	}
729	uc := NewResolveThemeUseCase(mock)
730
731	input := ResolveThemeInput{
732		PrefersDark:       true,
733		ColorSchemeSource: "adwaita",
734		LightPalette:      configLightPalette(),
735		DarkPalette:       configDarkPalette(),
736		UIScale:           1.0,
737	}
738
739	first, err := uc.Execute(context.Background(), input)
740	if err != nil {
741		t.Fatalf("first resolution error: %v", err)
742	}
743	mock.theme = nil
744
745	second, err := uc.Execute(context.Background(), input)
746	if err != nil {
747		t.Fatalf("second resolution error: %v", err)
748	}
749
750	if second.Theme.DarkPalette.Background != first.Theme.DarkPalette.Background {
751		t.Errorf("expected last-good dark background %q, got %q",
752			first.Theme.DarkPalette.Background, second.Theme.DarkPalette.Background)
753	}
754	if !second.Theme.ThemeSource.LastGood {
755		t.Error("expected LastGood=true when missing external follows valid external")
756	}
757	if second.Theme.ThemeSource.Provider != "noctalia" {
758		t.Errorf("expected provider noctalia, got %q", second.Theme.ThemeSource.Provider)
759	}
760	if len(second.Theme.Warnings) == 0 {
761		t.Fatal("expected warning for missing external after valid")
762	}
763}
764
765// TestResolveTheme_MalformedExternalMissingPalette_FallsBack verifies fallback
766// when the external theme has nil palettes.
767func TestResolveTheme_MalformedExternalMissingPalette_FallsBack(t *testing.T) {
768	malformed := &entity.ExternalTheme{
769		Name:         "Missing Palettes",
770		LightPalette: nil,
771		DarkPalette:  nil,
772	}
773	mock := &newMockExternalSource{
774		theme:   malformed,
775		enabled: true,
776	}
777	uc := NewResolveThemeUseCase(mock)
778
779	input := ResolveThemeInput{
780		PrefersDark:       true,
781		ColorSchemeSource: "adwaita",
782		LightPalette:      configLightPalette(),
783		DarkPalette:       configDarkPalette(),
784		UIScale:           1.0,
785	}
786
787	output, err := uc.Execute(context.Background(), input)
788	if err != nil {
789		t.Fatalf("unexpected error: %v", err)
790	}
791
792	assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette)
793	assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette)
794
795	// Should have warnings about missing palettes
796	if len(output.Theme.Warnings) == 0 {
797		t.Error("expected warnings for external theme with nil palettes")
798	}
799}
800
801// ============================================================
802// Malformed after valid external → keep last-good external + warning
803// ============================================================
804
805// TestResolveTheme_MalformedAfterValid_KeepsLastGood tests the "last-good" contract:
806// when an external theme becomes malformed after previously being valid, the usecase
807// keeps the last-good external palette colors and emits a warning.
808func TestResolveTheme_MalformedAfterValid_KeepsLastGood(t *testing.T) {
809	// Step 1: First resolution with valid external
810	ext := validExternalTheme()
811	mock := &newMockExternalSource{
812		theme:   ext,
813		enabled: true,
814	}
815	uc := NewResolveThemeUseCase(mock)
816
817	input := ResolveThemeInput{
818		PrefersDark:       true,
819		ColorSchemeSource: "adwaita",
820		LightPalette:      configLightPalette(),
821		DarkPalette:       configDarkPalette(),
822		UIScale:           1.0,
823	}
824
825	output1, err := uc.Execute(context.Background(), input)
826	if err != nil {
827		t.Fatalf("first resolution error: %v", err)
828	}
829
830	firstDarkBg := output1.Theme.DarkPalette.Background
831	// Verify first resolution uses external palette
832	if firstDarkBg != ext.DarkPalette.Background {
833		t.Fatalf("expected first resolution to use external dark palette (%q), got %q",
834			ext.DarkPalette.Background, firstDarkBg)
835	}
836
837	// Step 2: External becomes malformed
838	malformed := &entity.ExternalTheme{
839		Name: "Now Malformed",
840		LightPalette: &entity.ColorPalette{
841			Background: "bad-color",
842			Text:       "#ffffff",
843		},
844		DarkPalette: nil, // missing palette
845	}
846	mock.theme = malformed
847
848	output2, err := uc.Execute(context.Background(), input)
849	if err != nil {
850		t.Fatalf("second resolution error: %v", err)
851	}
852
853	assertValidPaletteHex(t, "DarkPalette", output2.Theme.DarkPalette)
854	assertValidPaletteHex(t, "ActivePalette", output2.Theme.ActivePalette)
855
856	// Should keep last-good colors (from first resolution)
857	if output2.Theme.DarkPalette.Background != firstDarkBg {
858		t.Errorf("expected to keep last-good dark palette background (%q), got %q",
859			firstDarkBg, output2.Theme.DarkPalette.Background)
860	}
861
862	// Should have warnings about the malformed update
863	if len(output2.Theme.Warnings) == 0 {
864		t.Error("expected warnings when external becomes malformed after being valid")
865	}
866
867	// ThemeSource should still indicate external (last-good)
868	if output2.Theme.ThemeSource.Kind != entity.ThemeSourceExternal {
869		t.Errorf("expected ThemeSource.Kind='external' for last-good, got %q", output2.Theme.ThemeSource.Kind)
870	}
871	if output2.Theme.ThemeSource.Provider != "noctalia" {
872		t.Errorf("expected ThemeSource.Provider='noctalia' for last-good, got %q", output2.Theme.ThemeSource.Provider)
873	}
874	if !output2.Theme.ThemeSource.LastGood {
875		t.Error("expected ThemeSource.LastGood=true for last-good external palette")
876	}
877}
878
879// ============================================================
880// Disabling external after valid → clear last-good and use config/defaults
881// ============================================================
882
883// TestResolveTheme_DisablingExternalAfterValid_ClearsLastGood verifies that when the
884// external source is disabled after having provided a valid theme, last-good is cleared
885// and config/defaults are used instead.
886func TestResolveTheme_DisablingExternalAfterValid_ClearsLastGood(t *testing.T) {
887	// Step 1: First resolution with valid external
888	ext := validExternalTheme()
889	mock := &newMockExternalSource{
890		theme:   ext,
891		enabled: true,
892	}
893	uc := NewResolveThemeUseCase(mock)
894
895	input := ResolveThemeInput{
896		PrefersDark:       true,
897		ColorSchemeSource: "adwaita",
898		LightPalette:      configLightPalette(),
899		DarkPalette:       configDarkPalette(),
900		UIScale:           1.0,
901	}
902
903	output1, err := uc.Execute(context.Background(), input)
904	if err != nil {
905		t.Fatalf("first resolution error: %v", err)
906	}
907
908	// Verify first resolution uses external palette
909	if output1.Theme.DarkPalette.Background == configDarkPalette().Background {
910		t.Fatal("first resolution should use external palette, not config")
911	}
912
913	// Step 2: External becomes disabled
914	mock.theme = nil
915	mock.enabled = false
916
917	output2, err := uc.Execute(context.Background(), input)
918	if err != nil {
919		t.Fatalf("second resolution error: %v", err)
920	}
921
922	assertValidPaletteHex(t, "DarkPalette", output2.Theme.DarkPalette)
923
924	// Should fall back to config palette (NOT keep last-good)
925	if output2.Theme.DarkPalette.Background != configDarkPalette().Background {
926		t.Errorf("expected fallback to config dark palette (%q) after external disabled, got %q",
927			configDarkPalette().Background, output2.Theme.DarkPalette.Background)
928	}
929
930	// ThemeSource should indicate config or default
931	if output2.Theme.ThemeSource.Kind == entity.ThemeSourceExternal {
932		t.Error("expected ThemeSource.Kind NOT to be 'external' after external is disabled")
933	}
934	if output2.Theme.ThemeSource.LastGood {
935		t.Error("expected ThemeSource.LastGood=false after external is disabled")
936	}
937}
938
939// TestResolveTheme_DisablingExternalTwice_Noop verifies that disabling external
940// when it was already disabled is a no-op (no warnings, uses config).
941func TestResolveTheme_DisablingExternalTwice_Noop(t *testing.T) {
942	// External already disabled from start
943	mock := &newMockExternalSource{
944		enabled: false,
945	}
946	uc := NewResolveThemeUseCase(mock)
947
948	input := ResolveThemeInput{
949		PrefersDark:       true,
950		ColorSchemeSource: "adwaita",
951		LightPalette:      configLightPalette(),
952		DarkPalette:       configDarkPalette(),
953		UIScale:           1.0,
954	}
955
956	output1, err := uc.Execute(context.Background(), input)
957	if err != nil {
958		t.Fatalf("first resolution error: %v", err)
959	}
960
961	// Should use config
962	if output1.Theme.DarkPalette.Background != configDarkPalette().Background {
963		t.Errorf("expected config dark palette, got %q", output1.Theme.DarkPalette.Background)
964	}
965
966	// Second resolution with same disabled state — should behave identically
967	output2, err := uc.Execute(context.Background(), input)
968	if err != nil {
969		t.Fatalf("second resolution error: %v", err)
970	}
971
972	if output2.Theme.DarkPalette.Background != configDarkPalette().Background {
973		t.Errorf("expected config dark palette on second call, got %q", output2.Theme.DarkPalette.Background)
974	}
975
976	assertNoWarnings(t, output2.Theme.Warnings)
977}
978
979// ============================================================
980// Output completeness: all ResolvedTheme fields
981// ============================================================
982
983// TestResolveTheme_OutputContainsAllFields verifies that every field of
984// ResolvedTheme is populated in the output (no zero-value surprises).
985func TestResolveTheme_OutputContainsAllFields(t *testing.T) {
986	uc := NewResolveThemeUseCase(nil)
987
988	fonts := entity.ThemeFonts{
989		SansFont:      "config-sans",
990		SerifFont:     "config-serif",
991		MonospaceFont: "config-mono",
992		GtkFont:       "config-gtk",
993		DefaultSize:   12,
994	}
995	modeColors := entity.ThemeModeColors{
996		PaneMode:    "#111111",
997		TabMode:     "#222222",
998		SessionMode: "#333333",
999		ResizeMode:  "#444444",
1000	}
1001
1002	input := ResolveThemeInput{
1003		ColorScheme:       "prefer-dark",
1004		PrefersDark:       true,
1005		ColorSchemeSource: "config",
1006		LightPalette:      configLightPalette(),
1007		DarkPalette:       configDarkPalette(),
1008		Fonts:             &fonts,
1009		UIScale:           1.25,
1010		ModeColors:        &modeColors,
1011	}
1012
1013	output, err := uc.Execute(context.Background(), input)
1014	if err != nil {
1015		t.Fatalf("unexpected error: %v", err)
1016	}
1017
1018	rt := output.Theme
1019
1020	// LightPalette must be valid
1021	assertValidPaletteHex(t, "LightPalette", rt.LightPalette)
1022	// DarkPalette must be valid
1023	assertValidPaletteHex(t, "DarkPalette", rt.DarkPalette)
1024	// ActivePalette must be valid
1025	assertValidPaletteHex(t, "ActivePalette", rt.ActivePalette)
1026	// PrefersDark
1027	if !rt.PrefersDark {
1028		t.Error("expected PrefersDark=true")
1029	}
1030	// ColorSchemeSource
1031	if rt.ColorSchemeSource != "config" {
1032		t.Errorf("expected ColorSchemeSource='config', got %q", rt.ColorSchemeSource)
1033	}
1034	// ThemeSource
1035	if rt.ThemeSource.Kind == "" {
1036		t.Error("expected non-empty ThemeSource.Kind")
1037	}
1038	// Fonts
1039	if rt.Fonts.SansFont == "" {
1040		t.Error("expected non-empty Fonts.SansFont")
1041	}
1042	if rt.Fonts.SerifFont == "" {
1043		t.Error("expected non-empty Fonts.SerifFont")
1044	}
1045	if rt.Fonts.DefaultSize <= 0 {
1046		t.Errorf("expected positive Fonts.DefaultSize, got %d", rt.Fonts.DefaultSize)
1047	}
1048	// UIScale
1049	if rt.UIScale <= 0 {
1050		t.Errorf("expected positive UIScale, got %f", rt.UIScale)
1051	}
1052	// ModeColors
1053	if rt.ModeColors.PaneMode == "" {
1054		t.Error("expected non-empty ModeColors.PaneMode")
1055	}
1056	if rt.ModeColors.TabMode == "" {
1057		t.Error("expected non-empty ModeColors.TabMode")
1058	}
1059	if rt.ModeColors.SessionMode == "" {
1060		t.Error("expected non-empty ModeColors.SessionMode")
1061	}
1062	if rt.ModeColors.ResizeMode == "" {
1063		t.Error("expected non-empty ModeColors.ResizeMode")
1064	}
1065}
1066
1067// ============================================================
1068// All palette colors must be valid CSS-safe hex
1069// ============================================================
1070
1071// TestResolveTheme_AllOutputPalettesAreCSSSafeHex is the contract test: every color
1072// field in every palette of the output must be a valid #RRGGBB hex color.
1073func TestResolveTheme_AllOutputPalettesAreCSSSafeHex(t *testing.T) {
1074	uc := NewResolveThemeUseCase(nil)
1075
1076	input := ResolveThemeInput{
1077		PrefersDark:       false,
1078		ColorSchemeSource: "fallback",
1079		UIScale:           1.0,
1080		// No config palettes — must get valid defaults
1081	}
1082
1083	output, err := uc.Execute(context.Background(), input)
1084	if err != nil {
1085		t.Fatalf("unexpected error: %v", err)
1086	}
1087
1088	assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette)
1089	assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette)
1090	assertValidPaletteHex(t, "ActivePalette", output.Theme.ActivePalette)
1091}
1092
1093// ============================================================
1094// Font defaults are used when no config or external fonts provided
1095// ============================================================
1096
1097// TestResolveTheme_NoConfigFonts_UsesDefaults verifies that when fonts are not
1098// provided in config or external, sensible defaults are used.
1099func TestResolveTheme_NoConfigFonts_UsesDefaults(t *testing.T) {
1100	uc := NewResolveThemeUseCase(nil)
1101
1102	input := ResolveThemeInput{
1103		PrefersDark:       true,
1104		ColorSchemeSource: "fallback",
1105		UIScale:           1.0,
1106		// Fonts is nil
1107	}
1108
1109	output, err := uc.Execute(context.Background(), input)
1110	if err != nil {
1111		t.Fatalf("unexpected error: %v", err)
1112	}
1113
1114	defaults := entity.DefaultThemeFonts()
1115
1116	if output.Theme.Fonts.SansFont != defaults.SansFont {
1117		t.Errorf("expected default SansFont (%q), got %q",
1118			defaults.SansFont, output.Theme.Fonts.SansFont)
1119	}
1120	if output.Theme.Fonts.SerifFont != defaults.SerifFont {
1121		t.Errorf("expected default SerifFont (%q), got %q",
1122			defaults.SerifFont, output.Theme.Fonts.SerifFont)
1123	}
1124	if output.Theme.Fonts.DefaultSize != defaults.DefaultSize {
1125		t.Errorf("expected default DefaultSize (%d), got %d",
1126			defaults.DefaultSize, output.Theme.Fonts.DefaultSize)
1127	}
1128}
1129
1130// ============================================================
1131// Mode color defaults are used when not provided
1132// ============================================================
1133
1134// TestResolveTheme_NoConfigModeColors_UsesDefaults verifies that when mode colors
1135// are not provided, sensible defaults are used.
1136func TestResolveTheme_NoConfigModeColors_UsesDefaults(t *testing.T) {
1137	uc := NewResolveThemeUseCase(nil)
1138
1139	input := ResolveThemeInput{
1140		PrefersDark:       true,
1141		ColorSchemeSource: "fallback",
1142		UIScale:           1.0,
1143		// ModeColors is nil
1144	}
1145
1146	output, err := uc.Execute(context.Background(), input)
1147	if err != nil {
1148		t.Fatalf("unexpected error: %v", err)
1149	}
1150
1151	// Mode colors must be valid CSS-safe hex
1152	fields := map[string]string{
1153		"PaneMode":    output.Theme.ModeColors.PaneMode,
1154		"TabMode":     output.Theme.ModeColors.TabMode,
1155		"SessionMode": output.Theme.ModeColors.SessionMode,
1156		"ResizeMode":  output.Theme.ModeColors.ResizeMode,
1157	}
1158	for name, value := range fields {
1159		if value == "" {
1160			t.Errorf("expected non-empty ModeColors.%s", name)
1161		}
1162		if !entity.IsValidHex(value) {
1163			t.Errorf("ModeColors.%s = %q is not valid CSS-safe hex", name, value)
1164		}
1165	}
1166}
1167
1168// TestResolveTheme_InvalidModeColors_FallsBackToDefaults verifies invalid config mode
1169// colors cannot pass through ResolvedTheme.
1170func TestResolveTheme_InvalidModeColors_FallsBackToDefaults(t *testing.T) {
1171	uc := NewResolveThemeUseCase(nil)
1172	invalid := &entity.ThemeModeColors{
1173		PaneMode:    "not-a-color",
1174		TabMode:     "#222222",
1175		SessionMode: "#333333",
1176		ResizeMode:  "#444444",
1177	}
1178
1179	output, err := uc.Execute(context.Background(), ResolveThemeInput{
1180		PrefersDark:       true,
1181		ColorSchemeSource: "config",
1182		ModeColors:        invalid,
1183	})
1184	if err != nil {
1185		t.Fatalf("unexpected error: %v", err)
1186	}
1187
1188	defaults := entity.DefaultThemeModeColors()
1189	if output.Theme.ModeColors != defaults {
1190		t.Errorf("expected invalid mode colors to fall back to defaults %+v, got %+v", defaults, output.Theme.ModeColors)
1191	}
1192	if len(output.Theme.Warnings) == 0 {
1193		t.Fatal("expected warning for invalid mode colors")
1194	}
1195}
1196
1197// ============================================================
1198// UIScale defaults
1199// ============================================================
1200
1201// TestResolveTheme_UIScaleDefaultsToOne verifies that when UIScale is zero or negative,
1202// it defaults to 1.0.
1203func TestResolveTheme_UIScaleDefaultsToOne(t *testing.T) {
1204	uc := NewResolveThemeUseCase(nil)
1205
1206	tests := []struct {
1207		name     string
1208		uiScale  float64
1209		expected float64
1210	}{
1211		{"zero", 0.0, 1.0},
1212		{"negative", -0.5, 1.0},
1213		{"positive", 1.25, 1.25},
1214	}
1215
1216	for _, tt := range tests {
1217		t.Run(tt.name, func(t *testing.T) {
1218			input := ResolveThemeInput{
1219				PrefersDark:       true,
1220				ColorSchemeSource: "fallback",
1221				UIScale:           tt.uiScale,
1222			}
1223
1224			output, err := uc.Execute(context.Background(), input)
1225			if err != nil {
1226				t.Fatalf("unexpected error: %v", err)
1227			}
1228
1229			if output.Theme.UIScale != tt.expected {
1230				t.Errorf("expected UIScale=%f, got %f", tt.expected, output.Theme.UIScale)
1231			}
1232		})
1233	}
1234}
1235
1236// ============================================================
1237// Color scheme source is properly captured
1238// ============================================================
1239
1240// TestResolveTheme_ColorSchemeSourceIsCaptured verifies that the color scheme
1241// source string is passed through to the output.
1242func TestResolveTheme_ColorSchemeSourceIsCaptured(t *testing.T) {
1243	uc := NewResolveThemeUseCase(nil)
1244
1245	sources := []string{"config", "adwaita", "gsettings", "env", "fallback"}
1246
1247	for _, source := range sources {
1248		input := ResolveThemeInput{
1249			PrefersDark:       true,
1250			ColorSchemeSource: source,
1251			UIScale:           1.0,
1252		}
1253
1254		output, err := uc.Execute(context.Background(), input)
1255		if err != nil {
1256			t.Fatalf("unexpected error for source %q: %v", source, err)
1257		}
1258
1259		if output.Theme.ColorSchemeSource != source {
1260			t.Errorf("expected ColorSchemeSource=%q, got %q", source, output.Theme.ColorSchemeSource)
1261		}
1262	}
1263}
1264
1265// ============================================================
1266// Refresh API
1267// ============================================================
1268
1269// TestResolveTheme_RefreshDelegatesToExecute verifies the refresh/update API returns
1270// the same single result shape as Execute without adding CSS generation or I/O.
1271func TestResolveTheme_RefreshDelegatesToExecute(t *testing.T) {
1272	uc := NewResolveThemeUseCase(nil)
1273	input := ResolveThemeInput{
1274		PrefersDark:       true,
1275		ColorSchemeSource: "adwaita",
1276		LightPalette:      configLightPalette(),
1277		DarkPalette:       configDarkPalette(),
1278		UIScale:           1.25,
1279	}
1280
1281	output, err := uc.Refresh(context.Background(), input)
1282	if err != nil {
1283		t.Fatalf("unexpected error: %v", err)
1284	}
1285
1286	if !output.Theme.PrefersDark {
1287		t.Error("expected refreshed theme to preserve PrefersDark")
1288	}
1289	if output.Theme.ActivePalette.Background != configDarkPalette().Background {
1290		t.Errorf("expected refreshed active palette background %q, got %q",
1291			configDarkPalette().Background, output.Theme.ActivePalette.Background)
1292	}
1293	if output.Theme.UIScale != 1.25 {
1294		t.Errorf("expected refreshed UI scale 1.25, got %f", output.Theme.UIScale)
1295	}
1296}
1297
1298// ============================================================
1299// Input mapping from domain config shapes
1300// ============================================================
1301
1302// TestResolveThemeInputFromConfig_MapsAppearanceStylingAndPreference verifies
1303// app/bootstrap callers can build usecase input without duplicating fallback logic.
1304func TestResolveThemeInputFromConfig_MapsAppearanceStylingAndPreference(t *testing.T) {
1305	appearance := &entity.AppearanceConfig{
1306		SansFont:        "Inter",
1307		SerifFont:       "Georgia",
1308		MonospaceFont:   "JetBrains Mono",
1309		GtkFont:         "Adwaita Sans",
1310		DefaultFontSize: 18,
1311		ColorScheme:     "prefer-dark",
1312		LightPalette:    *configLightPalette(),
1313		DarkPalette:     *configDarkPalette(),
1314	}
1315	styling := &entity.WorkspaceStylingConfig{
1316		PaneModeColor:    "#111111",
1317		TabModeColor:     "#222222",
1318		SessionModeColor: "#333333",
1319		ResizeModeColor:  "#444444",
1320		VimModeColor:     "#abcdef",
1321	}
1322	preference := port.ColorSchemePreference{PrefersDark: true, Source: "adwaita"}
1323
1324	input := ResolveThemeInputFromConfig(appearance, 1.25, styling, preference)
1325
1326	if input.ColorScheme != "prefer-dark" {
1327		t.Errorf("expected color scheme prefer-dark, got %q", input.ColorScheme)
1328	}
1329	if !input.PrefersDark || input.ColorSchemeSource != "adwaita" {
1330		t.Errorf("expected preference from detector, got PrefersDark=%v Source=%q", input.PrefersDark, input.ColorSchemeSource)
1331	}
1332	if input.LightPalette == nil || input.LightPalette.Background != configLightPalette().Background {
1333		t.Fatalf("expected light palette pointer from appearance, got %+v", input.LightPalette)
1334	}
1335	if input.DarkPalette == nil || input.DarkPalette.Background != configDarkPalette().Background {
1336		t.Fatalf("expected dark palette pointer from appearance, got %+v", input.DarkPalette)
1337	}
1338	if input.Fonts == nil || input.Fonts.SansFont != "Inter" || input.Fonts.DefaultSize != 18 {
1339		t.Fatalf("expected fonts from appearance, got %+v", input.Fonts)
1340	}
1341	if input.UIScale != 1.25 {
1342		t.Errorf("expected UI scale 1.25, got %f", input.UIScale)
1343	}
1344	if input.ModeColors == nil || input.ModeColors.PaneMode != "#111111" || input.ModeColors.ResizeMode != "#444444" || input.ModeColors.VimMode != "#abcdef" {
1345		t.Fatalf("expected mode colors from styling, got %+v", input.ModeColors)
1346	}
1347}
1348
1349// TestResolveThemeInputFromConfig_AllowsNilConfig verifies callers can resolve defaults
1350// when appearance/styling config is unavailable.
1351func TestResolveThemeInputFromConfig_AllowsNilConfig(t *testing.T) {
1352	preference := port.ColorSchemePreference{PrefersDark: false, Source: "fallback"}
1353
1354	input := ResolveThemeInputFromConfig(nil, 0, nil, preference)
1355
1356	if input.ColorScheme != "" {
1357		t.Errorf("expected empty color scheme for nil appearance, got %q", input.ColorScheme)
1358	}
1359	if input.PrefersDark || input.ColorSchemeSource != "fallback" {
1360		t.Errorf("expected fallback light preference, got PrefersDark=%v Source=%q", input.PrefersDark, input.ColorSchemeSource)
1361	}
1362	if input.LightPalette != nil || input.DarkPalette != nil || input.Fonts != nil || input.ModeColors != nil {
1363		t.Fatalf("expected nil optional inputs, got light=%v dark=%v fonts=%v modes=%v",
1364			input.LightPalette, input.DarkPalette, input.Fonts, input.ModeColors)
1365	}
1366}
1367
1368func TestResolveThemeInputFromConfigVimColorFallback(t *testing.T) {
1369	input := ResolveThemeInputFromConfig(nil, 1, &entity.WorkspaceStylingConfig{}, port.ColorSchemePreference{})
1370	colors := entity.MergeModeColors(input.ModeColors, entity.DefaultThemeModeColors())
1371	if colors.VimMode != "#F7768E" {
1372		t.Fatalf("expected Vim fallback, got %q", colors.VimMode)
1373	}
1374}

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