1package noctalia
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "runtime"
8 "testing"
9
10 "github.com/bnema/dumber/internal/domain/entity"
11)
12
13func TestParseColorsJSONValidActivePalette(t *testing.T) {
14 theme, err := ParseColorsJSON(noctaliaColorsJSON())
15 if err != nil {
16 t.Fatalf("ParseColorsJSON() error = %v", err)
17 }
18 if theme.Provider != "noctalia" {
19 t.Fatalf("Provider = %q, want noctalia", theme.Provider)
20 }
21 if theme.Name == "" {
22 t.Fatal("Name is empty, want source metadata")
23 }
24 want := entity.ColorPalette{
25 Background: "#000000",
26 Surface: "#0c0c0c",
27 SurfaceVariant: "#282828",
28 Text: "#ffffff",
29 Muted: "#a0a0a0",
30 Accent: "#ffc799",
31 Border: "#505050",
32 }
33 if theme.LightPalette == nil || *theme.LightPalette != want {
34 t.Fatalf("LightPalette = %+v, want %+v", theme.LightPalette, want)
35 }
36 if theme.DarkPalette == nil || *theme.DarkPalette != want {
37 t.Fatalf("DarkPalette = %+v, want active Noctalia palette %+v", theme.DarkPalette, want)
38 }
39}
40
41func TestParseColorsJSONDocsExample(t *testing.T) {
42 path := docsExamplePath(t, "noctalia-colors.json")
43 data, err := os.ReadFile(path)
44 if err != nil {
45 t.Fatalf("ReadFile(%q) error = %v", path, err)
46 }
47
48 theme, err := ParseColorsJSON(data)
49 if err != nil {
50 t.Fatalf("ParseColorsJSON(docs example) error = %v", err)
51 }
52 if theme == nil || theme.DarkPalette == nil || theme.Provider != "noctalia" || theme.DarkPalette.Accent == "" {
53 t.Fatalf("parsed docs example = %+v", theme)
54 }
55}
56
57func TestParseColorsJSONFallsBackWhenNuanceFieldsAreAbsent(t *testing.T) {
58 theme, err := ParseColorsJSON([]byte(`{
59 "mPrimary":"#ffc799",
60 "mSurface":"#0c0c0c",
61 "mOnSurface":"#ffffff",
62 "mSurfaceVariant":"#1c1c1c",
63 "mOnSurfaceVariant":"#a0a0a0",
64 "mOutline":"#505050"
65 }`))
66 if err != nil {
67 t.Fatalf("ParseColorsJSON() error = %v", err)
68 }
69 if theme.DarkPalette.Background != "#0c0c0c" {
70 t.Fatalf("Background = %q, want mSurface fallback", theme.DarkPalette.Background)
71 }
72 if theme.DarkPalette.SurfaceVariant != "#1c1c1c" {
73 t.Fatalf("SurfaceVariant = %q, want mSurfaceVariant fallback", theme.DarkPalette.SurfaceVariant)
74 }
75}
76
77func TestParseColorsJSONRequiresMappedFields(t *testing.T) {
78 cases := map[string]string{
79 "missing surface": `{"mPrimary":"#111111","mOnSurface":"#222222","mSurfaceVariant":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`,
80 "missing surface variant": `{"mPrimary":"#111111","mSurface":"#222222","mOnSurface":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`,
81 "missing text": `{"mPrimary":"#111111","mSurface":"#222222","mSurfaceVariant":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`,
82 "missing muted": `{"mPrimary":"#111111","mSurface":"#222222","mOnSurface":"#333333","mSurfaceVariant":"#444444","mOutline":"#555555"}`,
83 "missing accent": `{"mSurface":"#111111","mOnSurface":"#222222","mSurfaceVariant":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`,
84 "missing border": `{"mPrimary":"#111111","mSurface":"#222222","mOnSurface":"#333333","mSurfaceVariant":"#444444","mOnSurfaceVariant":"#555555"}`,
85 }
86 for name, input := range cases {
87 t.Run(name, func(t *testing.T) {
88 if _, err := ParseColorsJSON([]byte(input)); err == nil {
89 t.Fatal("ParseColorsJSON() error = nil, want missing field error")
90 }
91 })
92 }
93}
94
95func TestParseColorsJSONInvalidHexRejection(t *testing.T) {
96 if _, err := ParseColorsJSON([]byte(`{"mPrimary":"ffc799","mSurface":"#0c0c0c","mOnSurface":"#ffffff","mSurfaceVariant":"#1c1c1c","mOnSurfaceVariant":"#a0a0a0","mOutline":"#505050"}`)); err == nil {
97 t.Fatal("ParseColorsJSON() error = nil, want invalid hex error")
98 }
99}
100
101func TestParseColorsJSONInvalidOptionalNuanceHexRejection(t *testing.T) {
102 cases := map[string]string{
103 "invalid shadow": `{"mPrimary":"#ffc799","mSurface":"#0c0c0c","mOnSurface":"#ffffff","mSurfaceVariant":"#1c1c1c","mOnSurfaceVariant":"#a0a0a0","mOutline":"#505050","mShadow":"000000"}`,
104 "invalid hover": `{"mPrimary":"#ffc799","mSurface":"#0c0c0c","mOnSurface":"#ffffff","mSurfaceVariant":"#1c1c1c","mOnSurfaceVariant":"#a0a0a0","mOutline":"#505050","mHover":"282828"}`,
105 }
106 for name, input := range cases {
107 t.Run(name, func(t *testing.T) {
108 if _, err := ParseColorsJSON([]byte(input)); err == nil {
109 t.Fatal("ParseColorsJSON() error = nil, want optional hex validation error")
110 }
111 })
112 }
113}
114
115func TestParseColorsJSONRejectsTrailingTokens(t *testing.T) {
116 if _, err := ParseColorsJSON(append(noctaliaColorsJSON(), []byte(` {}`)...)); err == nil {
117 t.Fatal("ParseColorsJSON() error = nil, want trailing data error")
118 }
119}
120
121func TestParseDumberJSONValidFullPalette(t *testing.T) {
122 json := []byte(`{
123 "name":"Night Theme",
124 "source":"noctalia-template",
125 "mode":"dark",
126 "light":{"background":"#FFFFFF","surface":"#F0F0F0","surface_variant":"#E0E0E0","text":"#111111","muted":"#777777","accent":"#3366CC","border":"#CCCCCC"},
127 "dark":{"background":"#000000","surface":"#101010","surface_variant":"#202020","text":"#EEEEEE","muted":"#999999","accent":"#AABBCC","border":"#333333"}
128 }`)
129
130 theme, err := ParseDumberJSON(json)
131 if err != nil {
132 t.Fatalf("ParseDumberJSON() error = %v", err)
133 }
134 if theme.Provider != "noctalia" {
135 t.Fatalf("Provider = %q, want noctalia", theme.Provider)
136 }
137 if theme.Name != "Night Theme" {
138 t.Fatalf("Name = %q, want metadata name", theme.Name)
139 }
140 if theme.LightPalette.Background != "#FFFFFF" || theme.DarkPalette.Accent != "#AABBCC" {
141 t.Fatalf("parsed palettes = %+v / %+v", theme.LightPalette, theme.DarkPalette)
142 }
143}
144
145func TestParseDumberJSONDocsExample(t *testing.T) {
146 path := docsExamplePath(t, "noctalia-dumber-theme.json")
147 data, err := os.ReadFile(path)
148 if err != nil {
149 t.Fatalf("ReadFile(%q) error = %v", path, err)
150 }
151
152 theme, err := ParseDumberJSON(data)
153 if err != nil {
154 t.Fatalf("ParseDumberJSON(docs example) error = %v", err)
155 }
156 if theme == nil || theme.LightPalette == nil || theme.DarkPalette == nil || theme.Provider != "noctalia" || theme.LightPalette.Background == "" || theme.DarkPalette.Background == "" {
157 t.Fatalf("parsed docs example = %+v", theme)
158 }
159}
160
161func TestParseDumberJSONPartialPalette(t *testing.T) {
162 theme, err := ParseDumberJSON([]byte(`{
163 "light":{"background":"#FFFFFF","accent":"#112233"},
164 "dark":{"text":"#EEEEEE"}
165 }`))
166 if err != nil {
167 t.Fatalf("ParseDumberJSON() error = %v", err)
168 }
169 if theme.LightPalette.Surface != "" || theme.DarkPalette.Background != "" {
170 t.Fatalf("missing fields should remain empty: %+v / %+v", theme.LightPalette, theme.DarkPalette)
171 }
172}
173
174func TestParseDumberJSONRequiresLightAndDarkObjects(t *testing.T) {
175 cases := map[string]string{
176 "missing light": `{"dark":{}}`,
177 "missing dark": `{"light":{}}`,
178 }
179 for name, input := range cases {
180 t.Run(name, func(t *testing.T) {
181 if _, err := ParseDumberJSON([]byte(input)); err == nil {
182 t.Fatal("ParseDumberJSON() error = nil, want error")
183 }
184 })
185 }
186}
187
188func TestParseDumberJSONMetadataNameFallsBackToSource(t *testing.T) {
189 theme, err := ParseDumberJSON([]byte(`{"source":"noctalia-template","light":{},"dark":{}}`))
190 if err != nil {
191 t.Fatalf("ParseDumberJSON() error = %v", err)
192 }
193 if theme.Name != "noctalia-template" {
194 t.Fatalf("Name = %q, want source fallback", theme.Name)
195 }
196}
197
198func TestParseDumberJSONValidHexAcceptance(t *testing.T) {
199 _, err := ParseDumberJSON([]byte(`{"light":{"accent":"#aBc123"},"dark":{"accent":"#ABC123"}}`))
200 if err != nil {
201 t.Fatalf("ParseDumberJSON() error = %v", err)
202 }
203}
204
205func TestParseDumberJSONInvalidHexRejection(t *testing.T) {
206 if _, err := ParseDumberJSON([]byte(`{"light":{"accent":"ABC123"},"dark":{}}`)); err == nil {
207 t.Fatal("ParseDumberJSON() error = nil, want invalid hex error")
208 }
209}
210
211func TestParseDumberJSONMalformedRejection(t *testing.T) {
212 if _, err := ParseDumberJSON([]byte(`{"light":`)); err == nil {
213 t.Fatal("ParseDumberJSON() error = nil, want malformed JSON error")
214 }
215}
216
217func TestParseDumberJSONRejectsTrailingTokens(t *testing.T) {
218 if _, err := ParseDumberJSON([]byte(`{"light":{},"dark":{}} {}`)); err == nil {
219 t.Fatal("ParseDumberJSON() error = nil, want trailing data error")
220 }
221}
222
223func TestExpandPath(t *testing.T) {
224 t.Setenv("NOCTALIA_THEME_TEST", "theme.json")
225 home := t.TempDir()
226 t.Setenv("HOME", home)
227
228 got, err := ExpandPath("~/$NOCTALIA_THEME_TEST")
229 if err != nil {
230 t.Fatalf("ExpandPath() error = %v", err)
231 }
232 want := filepath.Join(home, "theme.json")
233 if got != want {
234 t.Fatalf("ExpandPath() = %q, want %q", got, want)
235 }
236}
237
238func TestFileSourceIdentityUsesExpandedPath(t *testing.T) {
239 home := t.TempDir()
240 t.Setenv("HOME", home)
241
242 path := filepath.Join(home, ".config", "noctalia", "colors.json")
243 source := NewFileSourceFromConfig(entity.ExternalThemeConfig{
244 Enabled: true,
245 Provider: "noctalia",
246 Format: "colors-json",
247 Path: path,
248 })
249 absoluteIdentity := source.ExternalThemeIdentity()
250
251 source.Configure(entity.ExternalThemeConfig{
252 Enabled: true,
253 Provider: "noctalia",
254 Format: "colors-json",
255 Path: "~/.config/noctalia/colors.json",
256 })
257 if source.ExternalThemeIdentity() != absoluteIdentity {
258 t.Fatalf("ExternalThemeIdentity() = %q, want %q", source.ExternalThemeIdentity(), absoluteIdentity)
259 }
260}
261
262func TestFileSourceDisabledBehavior(t *testing.T) {
263 source := NewFileSource(false, "/does/not/exist")
264 if source.IsEnabled() {
265 t.Fatal("IsEnabled() = true, want false")
266 }
267 theme, err := source.Get(context.Background())
268 if err != nil {
269 t.Fatalf("Get() error = %v", err)
270 }
271 if theme != nil {
272 t.Fatalf("Get() theme = %+v, want nil", theme)
273 }
274}
275
276func TestFileSourceReadsColorsJSONByDefault(t *testing.T) {
277 path := filepath.Join(t.TempDir(), "colors.json")
278 if err := os.WriteFile(path, noctaliaColorsJSON(), 0o600); err != nil {
279 t.Fatalf("WriteFile() error = %v", err)
280 }
281
282 theme, err := NewFileSource(true, path).Get(context.Background())
283 if err != nil {
284 t.Fatalf("Get() error = %v", err)
285 }
286 if theme.LightPalette.Accent != "#ffc799" || theme.DarkPalette.Background != "#000000" || theme.DarkPalette.SurfaceVariant != "#282828" {
287 t.Fatalf("Get() palettes = %+v / %+v", theme.LightPalette, theme.DarkPalette)
288 }
289}
290
291func TestFileSourceReadsDumberJSONWhenConfigured(t *testing.T) {
292 path := filepath.Join(t.TempDir(), "theme.json")
293 if err := os.WriteFile(path, []byte(`{"light":{"background":"#FFFFFF"},"dark":{"background":"#000000"}}`), 0o600); err != nil {
294 t.Fatalf("WriteFile() error = %v", err)
295 }
296
297 source := NewFileSourceFromConfig(entity.ExternalThemeConfig{
298 Enabled: true,
299 Provider: "noctalia",
300 Format: "dumber-json",
301 Path: path,
302 })
303 theme, err := source.Get(context.Background())
304 if err != nil {
305 t.Fatalf("Get() error = %v", err)
306 }
307 if theme.LightPalette.Background != "#FFFFFF" || theme.DarkPalette.Background != "#000000" {
308 t.Fatalf("Get() palettes = %+v / %+v", theme.LightPalette, theme.DarkPalette)
309 }
310}
311
312func TestFileSourceConfigureUpdatesSettings(t *testing.T) {
313 colorsPath := filepath.Join(t.TempDir(), "colors.json")
314 if err := os.WriteFile(colorsPath, noctaliaColorsJSON(), 0o600); err != nil {
315 t.Fatalf("WriteFile(colors) error = %v", err)
316 }
317 dumberPath := filepath.Join(t.TempDir(), "theme.json")
318 if err := os.WriteFile(dumberPath, []byte(`{"light":{"background":"#FFFFFF"},"dark":{"background":"#000000"}}`), 0o600); err != nil {
319 t.Fatalf("WriteFile(dumber) error = %v", err)
320 }
321
322 source := NewFileSourceFromConfig(entity.ExternalThemeConfig{})
323 if source.IsEnabled() {
324 t.Fatal("IsEnabled() = true, want false")
325 }
326
327 source.Configure(entity.ExternalThemeConfig{
328 Enabled: true,
329 Provider: " NOCTALIA ",
330 Format: " COLORS-JSON ",
331 Path: colorsPath,
332 })
333 if !source.IsEnabled() {
334 t.Fatal("IsEnabled() = false for colors-json, want true")
335 }
336 if _, err := source.Get(context.Background()); err != nil {
337 t.Fatalf("Get() colors-json after Configure error = %v", err)
338 }
339
340 source.Configure(entity.ExternalThemeConfig{
341 Enabled: true,
342 Provider: "noctalia",
343 Format: "dumber-json",
344 Path: dumberPath,
345 })
346 if !source.IsEnabled() {
347 t.Fatal("IsEnabled() = false for dumber-json, want true")
348 }
349 if _, err := source.Get(context.Background()); err != nil {
350 t.Fatalf("Get() dumber-json after Configure error = %v", err)
351 }
352
353 source.Configure(entity.ExternalThemeConfig{Enabled: true, Provider: "other", Format: "colors-json", Path: colorsPath})
354 if source.IsEnabled() {
355 t.Fatal("IsEnabled() = true for unsupported provider, want false")
356 }
357 source.Configure(entity.ExternalThemeConfig{Enabled: true, Provider: "noctalia", Format: "toml", Path: colorsPath})
358 if source.IsEnabled() {
359 t.Fatal("IsEnabled() = true for unsupported format, want false")
360 }
361}
362
363func docsExamplePath(t *testing.T, name string) string {
364 t.Helper()
365 _, file, _, ok := runtime.Caller(0)
366 if !ok {
367 t.Fatal("runtime.Caller(0) failed")
368 }
369 return filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "docs", "examples", name)
370}
371
372func noctaliaColorsJSON() []byte {
373 return []byte(`{
374 "mError": "#ff8080",
375 "mHover": "#282828",
376 "mOnError": "#000000",
377 "mOnHover": "#ffffff",
378 "mOnPrimary": "#000000",
379 "mOnSecondary": "#000000",
380 "mOnSurface": "#ffffff",
381 "mOnSurfaceVariant": "#a0a0a0",
382 "mOnTertiary": "#000000",
383 "mOutline": "#505050",
384 "mPrimary": "#ffc799",
385 "mSecondary": "#99ffe4",
386 "mShadow": "#000000",
387 "mSurface": "#0c0c0c",
388 "mSurfaceVariant": "#1c1c1c",
389 "mTertiary": "#fbadff"
390 }`)
391}