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}