返回 DeepSeek-Reasonix
theme_pack_test.go
根目录 / desktop / theme_pack_test.go
1 package main
2
3 import (
4 "archive/zip"
5 "bytes"
6 "encoding/json"
7 "image"
8 "image/color"
9 "image/png"
10 "net/http"
11 "net/http/httptest"
12 "os"
13 "path/filepath"
14 "strings"
15 "testing"
16 )
17
18 func TestParseThemePackManifestValid(t *testing.T) {
19 raw := []byte(`{
20 "schemaVersion": 1,
21 "id": "my-theme",
22 "name": "My Theme",
23 "baseStyle": "graphite",
24 "tokens": {
25 "light": {"bg": "#f4f3ef", "fg": "#111827", "accent": "#2f5fa8"},
26 "dark": {"bg": "#0c0d10", "fg": "#f1f1ef", "accent": "#ff6a3d"}
27 },
28 "recipes": {"density": "comfortable", "corners": "soft"},
29 "background": {
30 "image": "background.webp",
31 "focusX": 0.72,
32 "focusY": 0.45,
33 "safeArea": "left",
34 "homeOpacity": 1,
35 "taskOpacity": 0.28,
36 "overlayStrength": 0.62
37 }
38 }`)
39 m, err := parseThemePackManifest(raw)
40 if err != nil {
41 t.Fatalf("parse: %v", err)
42 }
43 if m.ID != "my-theme" || m.BaseStyle != "graphite" {
44 t.Fatalf("unexpected manifest: %+v", m)
45 }
46 if m.Background == nil || m.Background.SafeArea != "left" {
47 t.Fatalf("background: %+v", m.Background)
48 }
49 if m.Tokens.Light["bg"] != "#f4f3ef" {
50 t.Fatalf("token normalize = %q", m.Tokens.Light["bg"])
51 }
52 }
53
54 func TestParseThemePackManifestRejects(t *testing.T) {
55 cases := []struct {
56 name string
57 raw string
58 }{
59 {"bad schema", `{"schemaVersion":3,"id":"a","name":"A","baseStyle":"graphite"}`},
60 {"v1 task background", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","taskBackground":{"image":"task.png"}}`},
61 {"bad id", `{"schemaVersion":1,"id":"Bad_ID","name":"A","baseStyle":"graphite"}`},
62 {"unknown token", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","tokens":{"light":{"hack":"#ffffff"}}}`},
63 {"css url color", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","tokens":{"light":{"bg":"url(x)"}}}`},
64 {"gradient", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","tokens":{"dark":{"accent":"linear-gradient(red,blue)"}}}`},
65 {"unknown field", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","scripts":"alert(1)"}`},
66 {"bad density", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","recipes":{"density":"huge"}}`},
67 {"bad corners", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","recipes":{"corners":"pill"}}`},
68 {"svg image", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","background":{"image":"x.svg"}}`},
69 {"path traversal image", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","background":{"image":"../etc/passwd.png"}}`},
70 }
71 for _, tc := range cases {
72 t.Run(tc.name, func(t *testing.T) {
73 if _, err := parseThemePackManifest([]byte(tc.raw)); err == nil {
74 t.Fatal("expected error")
75 }
76 })
77 }
78 }
79
80 func TestThemePackV2IndependentSceneImagesRoundTrip(t *testing.T) {
81 home := t.TempDir()
82 t.Setenv("REASONIX_HOME", home)
83 assets := t.TempDir()
84 homeImage := writeTestPNG(t, filepath.Join(assets, "home.png"), 32, 24)
85 taskImage := writeTestPNG(t, filepath.Join(assets, "task.png"), 40, 30)
86 m := &ThemePackManifest{
87 SchemaVersion: 2,
88 ID: "two-scenes",
89 Name: "Two Scenes",
90 BaseStyle: "nocturne",
91 Recipes: defaultThemePackRecipes(),
92 Background: &ThemePackBackground{
93 Image: "background.png", FocusX: 0.25, FocusY: 0.4, SafeArea: "left",
94 HomeOpacity: 0.9, TaskOpacity: 0.2, OverlayStrength: 0.5,
95 },
96 TaskBackground: &ThemePackSceneBackground{
97 Image: "background-task.png", FocusX: 0.75, FocusY: 0.6, SafeArea: "right",
98 Opacity: 0.35, OverlayStrength: 0.7,
99 },
100 }
101 staging, err := writeThemeStaging(m, homeImage, nil, themeStagingImage{path: taskImage})
102 if err != nil {
103 t.Fatal(err)
104 }
105 defer os.RemoveAll(staging)
106 if err := publishThemeDir(m.ID, staging, false); err != nil {
107 t.Fatal(err)
108 }
109
110 exportPath := filepath.Join(t.TempDir(), "two-scenes.reasonix-theme")
111 if err := exportThemePackZIP(m.ID, exportPath); err != nil {
112 t.Fatal(err)
113 }
114 if err := deleteUserTheme(m.ID); err != nil {
115 t.Fatal(err)
116 }
117 imported, importedStage, err := importThemePackZIP(exportPath)
118 if err != nil {
119 t.Fatal(err)
120 }
121 defer os.RemoveAll(importedStage)
122 if imported.SchemaVersion != 2 || imported.TaskBackground == nil {
123 t.Fatalf("missing V2 task background: %+v", imported)
124 }
125 if imported.TaskBackground.SafeArea != "right" || imported.TaskBackground.Opacity != 0.35 {
126 t.Fatalf("unexpected task background: %+v", imported.TaskBackground)
127 }
128 if _, err := os.Stat(filepath.Join(importedStage, imported.TaskBackground.Image)); err != nil {
129 t.Fatalf("task scene image missing: %v", err)
130 }
131 }
132
133 func TestThemeTokenAndRecipeCSSVars(t *testing.T) {
134 vars := themePackCSSVars(map[string]string{"fg": "#ffffff", "bg": "#000000", "accent": "#ff0000"})
135 if vars["--fg"] != "#ffffff" || vars["--text"] != "#ffffff" {
136 t.Fatalf("fg aliases: %+v", vars)
137 }
138 r := recipeCSSVars(ThemePackRecipes{Density: "compact", Corners: "square"})
139 if r["--r"] != "2px" || r["--theme-row-h"] != "28px" {
140 t.Fatalf("recipe vars: %+v", r)
141 }
142 }
143
144 func TestOpacityClamped(t *testing.T) {
145 bg, err := normalizeThemeBackground(&ThemePackBackground{
146 Image: "bg.png", FocusX: 2, FocusY: -1, SafeArea: "left",
147 HomeOpacity: 1.5, TaskOpacity: 0.9, OverlayStrength: 2,
148 })
149 if err != nil {
150 t.Fatal(err)
151 }
152 if bg.TaskOpacity > 1 {
153 t.Fatalf("task opacity not clamped: %v", bg.TaskOpacity)
154 }
155 if bg.PaneOpacity == nil || *bg.PaneOpacity != 0.72 {
156 t.Fatalf("pane opacity default: %v", bg.PaneOpacity)
157 }
158 if bg.FocusX != 1 || bg.FocusY != 0 {
159 t.Fatalf("focus clamp: %v %v", bg.FocusX, bg.FocusY)
160 }
161 if bg.HomeOpacity != 1 || bg.OverlayStrength != 1 {
162 t.Fatalf("opacity clamp: home=%v overlay=%v", bg.HomeOpacity, bg.OverlayStrength)
163 }
164 // Task scene background defaults
165 sbg, err := normalizeThemeSceneBackground(&ThemePackSceneBackground{
166 Image: "task.png", Opacity: 1.5, OverlayStrength: 3,
167 })
168 if err != nil {
169 t.Fatal(err)
170 }
171 if sbg.Opacity > 1 {
172 t.Fatalf("scene opacity not clamped: %v", sbg.Opacity)
173 }
174 if sbg.PaneOpacity == nil || *sbg.PaneOpacity != 0.80 {
175 t.Fatalf("scene pane opacity default: %v", sbg.PaneOpacity)
176 }
177
178 // Explicit zero is a valid value and must not be confused with an omitted
179 // field, which preserves the legacy defaults above.
180 bg, err = normalizeThemeBackground(&ThemePackBackground{
181 Image: "bg.png", SafeArea: "center", PaneOpacity: themePackFloat64(0),
182 })
183 if err != nil {
184 t.Fatal(err)
185 }
186 if bg.PaneOpacity == nil || *bg.PaneOpacity != 0 {
187 t.Fatalf("explicit zero pane opacity changed: %v", bg.PaneOpacity)
188 }
189 sbg, err = normalizeThemeSceneBackground(&ThemePackSceneBackground{
190 Image: "task.png", SafeArea: "center", PaneOpacity: themePackFloat64(0),
191 })
192 if err != nil {
193 t.Fatal(err)
194 }
195 if sbg.PaneOpacity == nil || *sbg.PaneOpacity != 0 {
196 t.Fatalf("explicit zero scene pane opacity changed: %v", sbg.PaneOpacity)
197 }
198 }
199
200 func TestPaneOpacityExplicitZeroJSONRoundTrip(t *testing.T) {
201 raw := []byte(`{
202 "schemaVersion": 2,
203 "id": "zero-pane",
204 "name": "Zero Pane",
205 "baseStyle": "graphite",
206 "background": {"image": "home.webp", "safeArea": "center", "paneOpacity": 0},
207 "taskBackground": {"image": "task.webp", "safeArea": "center", "paneOpacity": 0}
208 }`)
209 m, err := parseThemePackManifest(raw)
210 if err != nil {
211 t.Fatal(err)
212 }
213 if m.Background == nil || m.Background.PaneOpacity == nil || *m.Background.PaneOpacity != 0 {
214 t.Fatalf("home pane opacity did not preserve zero: %+v", m.Background)
215 }
216 if m.TaskBackground == nil || m.TaskBackground.PaneOpacity == nil || *m.TaskBackground.PaneOpacity != 0 {
217 t.Fatalf("task pane opacity did not preserve zero: %+v", m.TaskBackground)
218 }
219 encoded, err := json.Marshal(m)
220 if err != nil {
221 t.Fatal(err)
222 }
223 if got := bytes.Count(encoded, []byte(`"paneOpacity":0`)); got != 2 {
224 t.Fatalf("explicit zero missing after marshal: count=%d json=%s", got, encoded)
225 }
226 }
227
228 func TestImportExportRoundTrip(t *testing.T) {
229 home := t.TempDir()
230 t.Setenv("REASONIX_HOME", home)
231
232 img := writeTestPNG(t, filepath.Join(t.TempDir(), "background.png"), 32, 24)
233 m := &ThemePackManifest{
234 SchemaVersion: 1,
235 ID: "round-trip",
236 Name: "Round Trip",
237 BaseStyle: "aurora",
238 Tokens: ThemePackTokens{
239 Dark: map[string]string{"accent": "#11aabb"},
240 },
241 Recipes: defaultThemePackRecipes(),
242 Background: &ThemePackBackground{
243 Image: "background.png", FocusX: 0.3, FocusY: 0.7, SafeArea: "right",
244 HomeOpacity: 1, TaskOpacity: 0.2, OverlayStrength: 0.5,
245 },
246 }
247 staging, err := writeThemeStaging(m, img, nil)
248 if err != nil {
249 t.Fatal(err)
250 }
251 defer os.RemoveAll(staging)
252 if err := publishThemeDir(m.ID, staging, false); err != nil {
253 t.Fatal(err)
254 }
255
256 exportPath := filepath.Join(t.TempDir(), "out.reasonix-theme")
257 if err := exportThemePackZIP(m.ID, exportPath); err != nil {
258 t.Fatal(err)
259 }
260 // Delete and re-import.
261 if err := deleteUserTheme(m.ID); err != nil {
262 t.Fatal(err)
263 }
264 im, stage2, err := importThemePackZIP(exportPath)
265 if err != nil {
266 t.Fatal(err)
267 }
268 defer os.RemoveAll(stage2)
269 if im.ID != "round-trip" || im.Background == nil || im.Background.SafeArea != "right" {
270 t.Fatalf("import manifest: %+v", im)
271 }
272 if err := publishThemeDir(im.ID, stage2, false); err != nil {
273 t.Fatal(err)
274 }
275 if !userThemeExists("round-trip") {
276 t.Fatal("expected theme after re-import")
277 }
278 }
279
280 func TestImportRejectsZipSlipAndSymlinkAndDuplicate(t *testing.T) {
281 // Nested path
282 var buf bytes.Buffer
283 zw := zip.NewWriter(&buf)
284 w, _ := zw.Create("../../evil.json")
285 _, _ = w.Write([]byte(`{}`))
286 _ = zw.Close()
287 if _, _, err := extractThemeZipBytes(buf.Bytes()); err == nil {
288 t.Fatal("expected zip-slip rejection")
289 }
290
291 // Duplicate entries
292 buf.Reset()
293 zw = zip.NewWriter(&buf)
294 w, _ = zw.Create("theme.json")
295 _, _ = w.Write([]byte(`{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite"}`))
296 w2, _ := zw.Create("Theme.json")
297 _, _ = w2.Write([]byte(`{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite"}`))
298 _ = zw.Close()
299 if _, _, err := extractThemeZipBytes(buf.Bytes()); err == nil {
300 t.Fatal("expected duplicate rejection")
301 }
302 }
303
304 func TestImportRejectsOversizedAndDisallowedFiles(t *testing.T) {
305 var buf bytes.Buffer
306 zw := zip.NewWriter(&buf)
307 w, _ := zw.Create("theme.json")
308 _, _ = w.Write([]byte(`{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite"}`))
309 w2, _ := zw.Create("hack.js")
310 _, _ = w2.Write([]byte(`alert(1)`))
311 _ = zw.Close()
312 if _, _, err := extractThemeZipBytes(buf.Bytes()); err == nil {
313 t.Fatal("expected disallowed file rejection")
314 }
315 }
316
317 func TestDeleteActiveThemeFallsBack(t *testing.T) {
318 home := t.TempDir()
319 t.Setenv("REASONIX_HOME", home)
320 app := NewApp()
321
322 m := &ThemePackManifest{
323 SchemaVersion: 1, ID: "deleteme", Name: "Delete Me", BaseStyle: "slate",
324 Recipes: defaultThemePackRecipes(),
325 }
326 staging, err := writeThemeStaging(m, "", nil)
327 if err != nil {
328 t.Fatal(err)
329 }
330 defer os.RemoveAll(staging)
331 if err := publishThemeDir(m.ID, staging, false); err != nil {
332 t.Fatal(err)
333 }
334 if err := app.ActivateThemePack("deleteme"); err != nil {
335 t.Fatal(err)
336 }
337 active, err := app.GetActiveThemePack()
338 if err != nil || active.ActiveThemeID != "deleteme" {
339 t.Fatalf("active = %+v err=%v", active, err)
340 }
341 if err := app.DeleteThemePack("deleteme"); err != nil {
342 t.Fatal(err)
343 }
344 active, err = app.GetActiveThemePack()
345 if err != nil {
346 t.Fatal(err)
347 }
348 if active.ActiveThemeID != "" || active.Pack != nil {
349 t.Fatalf("expected fallback after delete, got %+v", active)
350 }
351 }
352
353 func TestBuiltinCannotDeleteOrOverwrite(t *testing.T) {
354 home := t.TempDir()
355 t.Setenv("REASONIX_HOME", home)
356 app := NewApp()
357 if err := app.DeleteThemePack("graphite"); err == nil {
358 t.Fatal("expected delete builtin error")
359 }
360 m := &ThemePackManifest{
361 SchemaVersion: 1, ID: "graphite", Name: "Hijack", BaseStyle: "graphite",
362 Recipes: defaultThemePackRecipes(),
363 }
364 staging, err := writeThemeStaging(m, "", nil)
365 if err != nil {
366 t.Fatalf("stage builtin theme: %v", err)
367 }
368 if staging != "" {
369 defer os.RemoveAll(staging)
370 if err := publishThemeDir("graphite", staging, true); err == nil {
371 t.Fatal("expected publish builtin rejection")
372 }
373 }
374 _, err = app.SaveThemePack(ThemeSaveInput{
375 ID: "graphite", Name: "Hijack", BaseStyle: "graphite",
376 })
377 if err == nil {
378 t.Fatal("expected save builtin rejection")
379 }
380 }
381
382 func TestThemeAssetRouteValidatesAndRejectsEscape(t *testing.T) {
383 home := t.TempDir()
384 t.Setenv("REASONIX_HOME", home)
385 app := NewApp()
386
387 img := writeTestPNG(t, filepath.Join(t.TempDir(), "background.png"), 16, 16)
388 m := &ThemePackManifest{
389 SchemaVersion: 1, ID: "asset-theme", Name: "Asset", BaseStyle: "carbon",
390 Recipes: defaultThemePackRecipes(),
391 Background: &ThemePackBackground{
392 Image: "background.png", FocusX: 0.5, FocusY: 0.5, SafeArea: "center",
393 HomeOpacity: 1, TaskOpacity: 0.28, OverlayStrength: 0.5,
394 },
395 }
396 staging, err := writeThemeStaging(m, img, nil)
397 if err != nil {
398 t.Fatal(err)
399 }
400 defer os.RemoveAll(staging)
401 if err := publishThemeDir(m.ID, staging, false); err != nil {
402 t.Fatal(err)
403 }
404
405 url := themeBackgroundURL("asset-theme", "background.png")
406 if url == "" || !strings.HasPrefix(url, themeAssetURLPrefix) {
407 t.Fatalf("bad url %q", url)
408 }
409
410 mw := app.themeAssetMiddleware()
411 handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
412 w.WriteHeader(http.StatusTeapot)
413 }))
414
415 // Valid GET
416 rec := httptest.NewRecorder()
417 req := httptest.NewRequest(http.MethodGet, url, nil)
418 handler.ServeHTTP(rec, req)
419 if rec.Code != http.StatusOK {
420 t.Fatalf("GET status %d body=%s", rec.Code, rec.Body.String())
421 }
422 if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "image/png") {
423 t.Fatalf("content-type %q", ct)
424 }
425
426 // Valid HEAD
427 rec = httptest.NewRecorder()
428 req = httptest.NewRequest(http.MethodHead, url, nil)
429 handler.ServeHTTP(rec, req)
430 if rec.Code != http.StatusOK {
431 t.Fatalf("HEAD status %d", rec.Code)
432 }
433
434 // Wrong digest
435 rec = httptest.NewRecorder()
436 req = httptest.NewRequest(http.MethodGet, themeAssetURLPrefix+"asset-theme/deadbeef/background.png", nil)
437 handler.ServeHTTP(rec, req)
438 if rec.Code != http.StatusNotFound {
439 t.Fatalf("wrong digest status %d", rec.Code)
440 }
441
442 // Path escape style filename
443 rec = httptest.NewRecorder()
444 req = httptest.NewRequest(http.MethodGet, themeAssetURLPrefix+"asset-theme/x/../background.png", nil)
445 handler.ServeHTTP(rec, req)
446 if rec.Code != http.StatusNotFound {
447 t.Fatalf("escape status %d", rec.Code)
448 }
449
450 // Non-theme paths pass through
451 rec = httptest.NewRecorder()
452 req = httptest.NewRequest(http.MethodGet, "/index.html", nil)
453 handler.ServeHTTP(rec, req)
454 if rec.Code != http.StatusTeapot {
455 t.Fatalf("passthrough status %d", rec.Code)
456 }
457 }
458
459 func TestSameIDImportRequiresReplace(t *testing.T) {
460 home := t.TempDir()
461 t.Setenv("REASONIX_HOME", home)
462 app := NewApp()
463 t.Cleanup(clearPendingThemeImport)
464
465 m := &ThemePackManifest{
466 SchemaVersion: 1, ID: "same-id", Name: "V1", BaseStyle: "amber",
467 Recipes: defaultThemePackRecipes(),
468 }
469 staging, err := writeThemeStaging(m, "", nil)
470 if err != nil {
471 t.Fatal(err)
472 }
473 defer os.RemoveAll(staging)
474 if err := publishThemeDir(m.ID, staging, false); err != nil {
475 t.Fatal(err)
476 }
477
478 // Build a second zip with same id.
479 exportPath := filepath.Join(t.TempDir(), "v2.reasonix-theme")
480 m.Name = "V2"
481 if err := writeThemeZip(exportPath, m, nil); err != nil {
482 t.Fatal(err)
483 }
484 // First import stages and asks for replace — no error, no second file pick needed.
485 pending, err := app.ImportThemePack(exportPath, false)
486 if err != nil {
487 t.Fatal(err)
488 }
489 if !pending.NeedsReplace || pending.PendingID == "" {
490 t.Fatalf("expected NeedsReplace staging, got %+v", pending)
491 }
492 if pending.Pack.Name != "V2" {
493 t.Fatalf("staged name = %q", pending.Pack.Name)
494 }
495 // Confirm with replace=true and empty path reuses the staged extract.
496 res, err := app.ImportThemePack("", true)
497 if err != nil {
498 t.Fatal(err)
499 }
500 if !res.Replaced || res.Pack.Name != "V2" {
501 t.Fatalf("confirm result = %+v", res)
502 }
503 }
504
505 func TestSaveThemePackRejectsSameIDWithoutReplace(t *testing.T) {
506 home := t.TempDir()
507 t.Setenv("REASONIX_HOME", home)
508 app := NewApp()
509
510 first, err := app.SaveThemePack(ThemeSaveInput{
511 ID: "my-theme", Name: "First", BaseStyle: "graphite",
512 Recipes: defaultThemePackRecipes(),
513 })
514 if err != nil {
515 t.Fatal(err)
516 }
517 if first.Name != "First" {
518 t.Fatalf("first = %+v", first)
519 }
520 // Default create path must not silently overwrite.
521 if _, err := app.SaveThemePack(ThemeSaveInput{
522 ID: "my-theme", Name: "Second", BaseStyle: "aurora",
523 Recipes: defaultThemePackRecipes(),
524 Replace: false,
525 }); err == nil {
526 t.Fatal("expected already-exists error without Replace")
527 }
528 // Explicit replace overwrites.
529 second, err := app.SaveThemePack(ThemeSaveInput{
530 ID: "my-theme", Name: "Second", BaseStyle: "aurora",
531 Recipes: defaultThemePackRecipes(),
532 Replace: true,
533 })
534 if err != nil {
535 t.Fatal(err)
536 }
537 if second.Name != "Second" || second.BaseStyle != "aurora" {
538 t.Fatalf("second = %+v", second)
539 }
540 }
541
542 func TestCorruptActiveThemeFallsBack(t *testing.T) {
543 home := t.TempDir()
544 t.Setenv("REASONIX_HOME", home)
545 app := NewApp()
546
547 // Point state at a missing theme.
548 if err := saveThemeDesktopState(ThemeDesktopState{SchemaVersion: 1, ActiveThemeID: "missing-theme"}); err != nil {
549 t.Fatal(err)
550 }
551 active, err := app.GetActiveThemePack()
552 if err != nil {
553 t.Fatal(err)
554 }
555 if active.Pack != nil || active.ActiveThemeID != "" {
556 t.Fatalf("expected empty active after corrupt, got %+v", active)
557 }
558 st := loadThemeDesktopState()
559 if st.ActiveThemeID != "" {
560 t.Fatalf("state not cleared: %q", st.ActiveThemeID)
561 }
562 }
563
564 func TestListThemePacksIncludesBuiltins(t *testing.T) {
565 home := t.TempDir()
566 t.Setenv("REASONIX_HOME", home)
567 app := NewApp()
568 list, err := app.ListThemePacks()
569 if err != nil {
570 t.Fatal(err)
571 }
572 if len(list) < 6 {
573 t.Fatalf("expected >=6 packs, got %d", len(list))
574 }
575 var sawGraphite bool
576 for _, p := range list {
577 if p.ID == "graphite" && p.Builtin {
578 sawGraphite = true
579 }
580 }
581 if !sawGraphite {
582 t.Fatal("missing graphite builtin")
583 }
584 }
585
586 func TestValidateThemeImagePNG(t *testing.T) {
587 path := writeTestPNG(t, filepath.Join(t.TempDir(), "background.png"), 8, 8)
588 if err := validateThemeImageFile(path); err != nil {
589 t.Fatal(err)
590 }
591 // Oversized edge
592 big := writeTestPNG(t, filepath.Join(t.TempDir(), "big.png"), themePackMaxImageEdge+1, 2)
593 if err := validateThemeImageFile(big); err == nil {
594 t.Fatal("expected dimension rejection")
595 }
596 }
597
598 func TestContrastWarningLowContrast(t *testing.T) {
599 m := &ThemePackManifest{
600 SchemaVersion: 1, ID: "c", Name: "C", BaseStyle: "graphite",
601 Tokens: ThemePackTokens{
602 Light: map[string]string{"fg": "#eeeeee", "bg": "#ffffff"},
603 },
604 Recipes: defaultThemePackRecipes(),
605 }
606 warns := computeContrastWarnings(m)
607 if len(warns) == 0 {
608 t.Fatal("expected contrast warning")
609 }
610 }
611
612 func TestManifestJSONRoundTrip(t *testing.T) {
613 m := ThemePackManifest{
614 SchemaVersion: 1, ID: "json-rt", Name: "JSON", BaseStyle: "nocturne",
615 Tokens: ThemePackTokens{Dark: map[string]string{"panel": "#121212ff"}},
616 Recipes: ThemePackRecipes{Density: "compact", Corners: "round"},
617 }
618 raw, err := json.Marshal(m)
619 if err != nil {
620 t.Fatal(err)
621 }
622 parsed, err := parseThemePackManifest(raw)
623 if err != nil {
624 t.Fatal(err)
625 }
626 if parsed.Recipes.Density != "compact" || parsed.Tokens.Dark["panel"] != "#121212ff" {
627 t.Fatalf("round trip: %+v", parsed)
628 }
629 }
630
631 func writeTestPNG(t *testing.T, path string, w, h int) string {
632 t.Helper()
633 img := image.NewRGBA(image.Rect(0, 0, w, h))
634 for y := 0; y < h; y++ {
635 for x := 0; x < w; x++ {
636 img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 40, A: 255})
637 }
638 }
639 f, err := os.Create(path)
640 if err != nil {
641 t.Fatal(err)
642 }
643 if err := png.Encode(f, img); err != nil {
644 _ = f.Close()
645 t.Fatal(err)
646 }
647 if err := f.Close(); err != nil {
648 t.Fatal(err)
649 }
650 return path
651 }
652
652 lines GO