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