Files
DeepSeek-Reasonix/desktop/theme_pack_test.go
T
YHH 4fbbca6a99 refactor(go): adopt modern stdlib idioms and gate them (#7831)
* refactor(go): adopt modern stdlib idioms across kernel and desktop

Mechanical sweep with golangci-lint's modernize/intrange/copyloopvar/
usestdlibvars fixers plus hand-fixes for what they could not rewrite:
slices.Contains, min/max, slices.Backward, WaitGroup.Go, reflect.TypeFor,
maps.Copy, strings.Cut/CutPrefix/SplitSeq, strings.Builder, range-over-int.
Net -549 lines with no behavior change.

Three loops in session.go stay index-based: slices.Backward yields element
copies, so the fixer's rewrite silently dropped writes to DecisionReceipts
and ToolCalls (caught by TestDelivery* and TestDependentSameBatch*). All 49
slices.Backward sites were audited for that shape; only these three write
through the index, and they carry a nolint with the reason.

CaptureAfter's scan loop had an empty body and never ran any logic, so the
fixer could not express it. Drop it and the accessor it was the only caller
of. omitzero is deliberately untouched: turning omitempty into omitzero
changes the JSON written for nested structs, which is a wire-format change
to session files and the extension protocol.

* build(lint): gate the modern-idiom linters

Nothing stopped the codebase from drifting back to pre-1.22 idioms. Both
modules are now clean for modernize, intrange, copyloopvar, and
usestdlibvars, so enable them rather than leaving the sweep as a one-off.

omitzero stays disabled with the reason inline; usetesting, perfsprint, and
errorlint still have residue and land separately.
2026-08-07 15:30:33 +08:00

652 lines
19 KiB
Go

package main
import (
"archive/zip"
"bytes"
"encoding/json"
"image"
"image/color"
"image/png"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestParseThemePackManifestValid(t *testing.T) {
raw := []byte(`{
"schemaVersion": 1,
"id": "my-theme",
"name": "My Theme",
"baseStyle": "graphite",
"tokens": {
"light": {"bg": "#f4f3ef", "fg": "#111827", "accent": "#2f5fa8"},
"dark": {"bg": "#0c0d10", "fg": "#f1f1ef", "accent": "#ff6a3d"}
},
"recipes": {"density": "comfortable", "corners": "soft"},
"background": {
"image": "background.webp",
"focusX": 0.72,
"focusY": 0.45,
"safeArea": "left",
"homeOpacity": 1,
"taskOpacity": 0.28,
"overlayStrength": 0.62
}
}`)
m, err := parseThemePackManifest(raw)
if err != nil {
t.Fatalf("parse: %v", err)
}
if m.ID != "my-theme" || m.BaseStyle != "graphite" {
t.Fatalf("unexpected manifest: %+v", m)
}
if m.Background == nil || m.Background.SafeArea != "left" {
t.Fatalf("background: %+v", m.Background)
}
if m.Tokens.Light["bg"] != "#f4f3ef" {
t.Fatalf("token normalize = %q", m.Tokens.Light["bg"])
}
}
func TestParseThemePackManifestRejects(t *testing.T) {
cases := []struct {
name string
raw string
}{
{"bad schema", `{"schemaVersion":3,"id":"a","name":"A","baseStyle":"graphite"}`},
{"v1 task background", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","taskBackground":{"image":"task.png"}}`},
{"bad id", `{"schemaVersion":1,"id":"Bad_ID","name":"A","baseStyle":"graphite"}`},
{"unknown token", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","tokens":{"light":{"hack":"#ffffff"}}}`},
{"css url color", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","tokens":{"light":{"bg":"url(x)"}}}`},
{"gradient", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","tokens":{"dark":{"accent":"linear-gradient(red,blue)"}}}`},
{"unknown field", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","scripts":"alert(1)"}`},
{"bad density", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","recipes":{"density":"huge"}}`},
{"bad corners", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","recipes":{"corners":"pill"}}`},
{"svg image", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","background":{"image":"x.svg"}}`},
{"path traversal image", `{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite","background":{"image":"../etc/passwd.png"}}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := parseThemePackManifest([]byte(tc.raw)); err == nil {
t.Fatal("expected error")
}
})
}
}
func TestThemePackV2IndependentSceneImagesRoundTrip(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
assets := t.TempDir()
homeImage := writeTestPNG(t, filepath.Join(assets, "home.png"), 32, 24)
taskImage := writeTestPNG(t, filepath.Join(assets, "task.png"), 40, 30)
m := &ThemePackManifest{
SchemaVersion: 2,
ID: "two-scenes",
Name: "Two Scenes",
BaseStyle: "nocturne",
Recipes: defaultThemePackRecipes(),
Background: &ThemePackBackground{
Image: "background.png", FocusX: 0.25, FocusY: 0.4, SafeArea: "left",
HomeOpacity: 0.9, TaskOpacity: 0.2, OverlayStrength: 0.5,
},
TaskBackground: &ThemePackSceneBackground{
Image: "background-task.png", FocusX: 0.75, FocusY: 0.6, SafeArea: "right",
Opacity: 0.35, OverlayStrength: 0.7,
},
}
staging, err := writeThemeStaging(m, homeImage, nil, themeStagingImage{path: taskImage})
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(staging)
if err := publishThemeDir(m.ID, staging, false); err != nil {
t.Fatal(err)
}
exportPath := filepath.Join(t.TempDir(), "two-scenes.reasonix-theme")
if err := exportThemePackZIP(m.ID, exportPath); err != nil {
t.Fatal(err)
}
if err := deleteUserTheme(m.ID); err != nil {
t.Fatal(err)
}
imported, importedStage, err := importThemePackZIP(exportPath)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(importedStage)
if imported.SchemaVersion != 2 || imported.TaskBackground == nil {
t.Fatalf("missing V2 task background: %+v", imported)
}
if imported.TaskBackground.SafeArea != "right" || imported.TaskBackground.Opacity != 0.35 {
t.Fatalf("unexpected task background: %+v", imported.TaskBackground)
}
if _, err := os.Stat(filepath.Join(importedStage, imported.TaskBackground.Image)); err != nil {
t.Fatalf("task scene image missing: %v", err)
}
}
func TestThemeTokenAndRecipeCSSVars(t *testing.T) {
vars := themePackCSSVars(map[string]string{"fg": "#ffffff", "bg": "#000000", "accent": "#ff0000"})
if vars["--fg"] != "#ffffff" || vars["--text"] != "#ffffff" {
t.Fatalf("fg aliases: %+v", vars)
}
r := recipeCSSVars(ThemePackRecipes{Density: "compact", Corners: "square"})
if r["--r"] != "2px" || r["--theme-row-h"] != "28px" {
t.Fatalf("recipe vars: %+v", r)
}
}
func TestOpacityClamped(t *testing.T) {
bg, err := normalizeThemeBackground(&ThemePackBackground{
Image: "bg.png", FocusX: 2, FocusY: -1, SafeArea: "left",
HomeOpacity: 1.5, TaskOpacity: 0.9, OverlayStrength: 2,
})
if err != nil {
t.Fatal(err)
}
if bg.TaskOpacity > 1 {
t.Fatalf("task opacity not clamped: %v", bg.TaskOpacity)
}
if bg.PaneOpacity == nil || *bg.PaneOpacity != 0.72 {
t.Fatalf("pane opacity default: %v", bg.PaneOpacity)
}
if bg.FocusX != 1 || bg.FocusY != 0 {
t.Fatalf("focus clamp: %v %v", bg.FocusX, bg.FocusY)
}
if bg.HomeOpacity != 1 || bg.OverlayStrength != 1 {
t.Fatalf("opacity clamp: home=%v overlay=%v", bg.HomeOpacity, bg.OverlayStrength)
}
// Task scene background defaults
sbg, err := normalizeThemeSceneBackground(&ThemePackSceneBackground{
Image: "task.png", Opacity: 1.5, OverlayStrength: 3,
})
if err != nil {
t.Fatal(err)
}
if sbg.Opacity > 1 {
t.Fatalf("scene opacity not clamped: %v", sbg.Opacity)
}
if sbg.PaneOpacity == nil || *sbg.PaneOpacity != 0.80 {
t.Fatalf("scene pane opacity default: %v", sbg.PaneOpacity)
}
// Explicit zero is a valid value and must not be confused with an omitted
// field, which preserves the legacy defaults above.
bg, err = normalizeThemeBackground(&ThemePackBackground{
Image: "bg.png", SafeArea: "center", PaneOpacity: themePackFloat64(0),
})
if err != nil {
t.Fatal(err)
}
if bg.PaneOpacity == nil || *bg.PaneOpacity != 0 {
t.Fatalf("explicit zero pane opacity changed: %v", bg.PaneOpacity)
}
sbg, err = normalizeThemeSceneBackground(&ThemePackSceneBackground{
Image: "task.png", SafeArea: "center", PaneOpacity: themePackFloat64(0),
})
if err != nil {
t.Fatal(err)
}
if sbg.PaneOpacity == nil || *sbg.PaneOpacity != 0 {
t.Fatalf("explicit zero scene pane opacity changed: %v", sbg.PaneOpacity)
}
}
func TestPaneOpacityExplicitZeroJSONRoundTrip(t *testing.T) {
raw := []byte(`{
"schemaVersion": 2,
"id": "zero-pane",
"name": "Zero Pane",
"baseStyle": "graphite",
"background": {"image": "home.webp", "safeArea": "center", "paneOpacity": 0},
"taskBackground": {"image": "task.webp", "safeArea": "center", "paneOpacity": 0}
}`)
m, err := parseThemePackManifest(raw)
if err != nil {
t.Fatal(err)
}
if m.Background == nil || m.Background.PaneOpacity == nil || *m.Background.PaneOpacity != 0 {
t.Fatalf("home pane opacity did not preserve zero: %+v", m.Background)
}
if m.TaskBackground == nil || m.TaskBackground.PaneOpacity == nil || *m.TaskBackground.PaneOpacity != 0 {
t.Fatalf("task pane opacity did not preserve zero: %+v", m.TaskBackground)
}
encoded, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
if got := bytes.Count(encoded, []byte(`"paneOpacity":0`)); got != 2 {
t.Fatalf("explicit zero missing after marshal: count=%d json=%s", got, encoded)
}
}
func TestImportExportRoundTrip(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
img := writeTestPNG(t, filepath.Join(t.TempDir(), "background.png"), 32, 24)
m := &ThemePackManifest{
SchemaVersion: 1,
ID: "round-trip",
Name: "Round Trip",
BaseStyle: "aurora",
Tokens: ThemePackTokens{
Dark: map[string]string{"accent": "#11aabb"},
},
Recipes: defaultThemePackRecipes(),
Background: &ThemePackBackground{
Image: "background.png", FocusX: 0.3, FocusY: 0.7, SafeArea: "right",
HomeOpacity: 1, TaskOpacity: 0.2, OverlayStrength: 0.5,
},
}
staging, err := writeThemeStaging(m, img, nil)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(staging)
if err := publishThemeDir(m.ID, staging, false); err != nil {
t.Fatal(err)
}
exportPath := filepath.Join(t.TempDir(), "out.reasonix-theme")
if err := exportThemePackZIP(m.ID, exportPath); err != nil {
t.Fatal(err)
}
// Delete and re-import.
if err := deleteUserTheme(m.ID); err != nil {
t.Fatal(err)
}
im, stage2, err := importThemePackZIP(exportPath)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(stage2)
if im.ID != "round-trip" || im.Background == nil || im.Background.SafeArea != "right" {
t.Fatalf("import manifest: %+v", im)
}
if err := publishThemeDir(im.ID, stage2, false); err != nil {
t.Fatal(err)
}
if !userThemeExists("round-trip") {
t.Fatal("expected theme after re-import")
}
}
func TestImportRejectsZipSlipAndSymlinkAndDuplicate(t *testing.T) {
// Nested path
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, _ := zw.Create("../../evil.json")
_, _ = w.Write([]byte(`{}`))
_ = zw.Close()
if _, _, err := extractThemeZipBytes(buf.Bytes()); err == nil {
t.Fatal("expected zip-slip rejection")
}
// Duplicate entries
buf.Reset()
zw = zip.NewWriter(&buf)
w, _ = zw.Create("theme.json")
_, _ = w.Write([]byte(`{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite"}`))
w2, _ := zw.Create("Theme.json")
_, _ = w2.Write([]byte(`{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite"}`))
_ = zw.Close()
if _, _, err := extractThemeZipBytes(buf.Bytes()); err == nil {
t.Fatal("expected duplicate rejection")
}
}
func TestImportRejectsOversizedAndDisallowedFiles(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, _ := zw.Create("theme.json")
_, _ = w.Write([]byte(`{"schemaVersion":1,"id":"a","name":"A","baseStyle":"graphite"}`))
w2, _ := zw.Create("hack.js")
_, _ = w2.Write([]byte(`alert(1)`))
_ = zw.Close()
if _, _, err := extractThemeZipBytes(buf.Bytes()); err == nil {
t.Fatal("expected disallowed file rejection")
}
}
func TestDeleteActiveThemeFallsBack(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
app := NewApp()
m := &ThemePackManifest{
SchemaVersion: 1, ID: "deleteme", Name: "Delete Me", BaseStyle: "slate",
Recipes: defaultThemePackRecipes(),
}
staging, err := writeThemeStaging(m, "", nil)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(staging)
if err := publishThemeDir(m.ID, staging, false); err != nil {
t.Fatal(err)
}
if err := app.ActivateThemePack("deleteme"); err != nil {
t.Fatal(err)
}
active, err := app.GetActiveThemePack()
if err != nil || active.ActiveThemeID != "deleteme" {
t.Fatalf("active = %+v err=%v", active, err)
}
if err := app.DeleteThemePack("deleteme"); err != nil {
t.Fatal(err)
}
active, err = app.GetActiveThemePack()
if err != nil {
t.Fatal(err)
}
if active.ActiveThemeID != "" || active.Pack != nil {
t.Fatalf("expected fallback after delete, got %+v", active)
}
}
func TestBuiltinCannotDeleteOrOverwrite(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
app := NewApp()
if err := app.DeleteThemePack("graphite"); err == nil {
t.Fatal("expected delete builtin error")
}
m := &ThemePackManifest{
SchemaVersion: 1, ID: "graphite", Name: "Hijack", BaseStyle: "graphite",
Recipes: defaultThemePackRecipes(),
}
staging, err := writeThemeStaging(m, "", nil)
if err != nil {
t.Fatalf("stage builtin theme: %v", err)
}
if staging != "" {
defer os.RemoveAll(staging)
if err := publishThemeDir("graphite", staging, true); err == nil {
t.Fatal("expected publish builtin rejection")
}
}
_, err = app.SaveThemePack(ThemeSaveInput{
ID: "graphite", Name: "Hijack", BaseStyle: "graphite",
})
if err == nil {
t.Fatal("expected save builtin rejection")
}
}
func TestThemeAssetRouteValidatesAndRejectsEscape(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
app := NewApp()
img := writeTestPNG(t, filepath.Join(t.TempDir(), "background.png"), 16, 16)
m := &ThemePackManifest{
SchemaVersion: 1, ID: "asset-theme", Name: "Asset", BaseStyle: "carbon",
Recipes: defaultThemePackRecipes(),
Background: &ThemePackBackground{
Image: "background.png", FocusX: 0.5, FocusY: 0.5, SafeArea: "center",
HomeOpacity: 1, TaskOpacity: 0.28, OverlayStrength: 0.5,
},
}
staging, err := writeThemeStaging(m, img, nil)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(staging)
if err := publishThemeDir(m.ID, staging, false); err != nil {
t.Fatal(err)
}
url := themeBackgroundURL("asset-theme", "background.png")
if url == "" || !strings.HasPrefix(url, themeAssetURLPrefix) {
t.Fatalf("bad url %q", url)
}
mw := app.themeAssetMiddleware()
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
}))
// Valid GET
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, url, nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET status %d body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "image/png") {
t.Fatalf("content-type %q", ct)
}
// Valid HEAD
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodHead, url, nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("HEAD status %d", rec.Code)
}
// Wrong digest
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, themeAssetURLPrefix+"asset-theme/deadbeef/background.png", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("wrong digest status %d", rec.Code)
}
// Path escape style filename
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, themeAssetURLPrefix+"asset-theme/x/../background.png", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("escape status %d", rec.Code)
}
// Non-theme paths pass through
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/index.html", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusTeapot {
t.Fatalf("passthrough status %d", rec.Code)
}
}
func TestSameIDImportRequiresReplace(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
app := NewApp()
t.Cleanup(clearPendingThemeImport)
m := &ThemePackManifest{
SchemaVersion: 1, ID: "same-id", Name: "V1", BaseStyle: "amber",
Recipes: defaultThemePackRecipes(),
}
staging, err := writeThemeStaging(m, "", nil)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(staging)
if err := publishThemeDir(m.ID, staging, false); err != nil {
t.Fatal(err)
}
// Build a second zip with same id.
exportPath := filepath.Join(t.TempDir(), "v2.reasonix-theme")
m.Name = "V2"
if err := writeThemeZip(exportPath, m, nil); err != nil {
t.Fatal(err)
}
// First import stages and asks for replace — no error, no second file pick needed.
pending, err := app.ImportThemePack(exportPath, false)
if err != nil {
t.Fatal(err)
}
if !pending.NeedsReplace || pending.PendingID == "" {
t.Fatalf("expected NeedsReplace staging, got %+v", pending)
}
if pending.Pack.Name != "V2" {
t.Fatalf("staged name = %q", pending.Pack.Name)
}
// Confirm with replace=true and empty path reuses the staged extract.
res, err := app.ImportThemePack("", true)
if err != nil {
t.Fatal(err)
}
if !res.Replaced || res.Pack.Name != "V2" {
t.Fatalf("confirm result = %+v", res)
}
}
func TestSaveThemePackRejectsSameIDWithoutReplace(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
app := NewApp()
first, err := app.SaveThemePack(ThemeSaveInput{
ID: "my-theme", Name: "First", BaseStyle: "graphite",
Recipes: defaultThemePackRecipes(),
})
if err != nil {
t.Fatal(err)
}
if first.Name != "First" {
t.Fatalf("first = %+v", first)
}
// Default create path must not silently overwrite.
if _, err := app.SaveThemePack(ThemeSaveInput{
ID: "my-theme", Name: "Second", BaseStyle: "aurora",
Recipes: defaultThemePackRecipes(),
Replace: false,
}); err == nil {
t.Fatal("expected already-exists error without Replace")
}
// Explicit replace overwrites.
second, err := app.SaveThemePack(ThemeSaveInput{
ID: "my-theme", Name: "Second", BaseStyle: "aurora",
Recipes: defaultThemePackRecipes(),
Replace: true,
})
if err != nil {
t.Fatal(err)
}
if second.Name != "Second" || second.BaseStyle != "aurora" {
t.Fatalf("second = %+v", second)
}
}
func TestCorruptActiveThemeFallsBack(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
app := NewApp()
// Point state at a missing theme.
if err := saveThemeDesktopState(ThemeDesktopState{SchemaVersion: 1, ActiveThemeID: "missing-theme"}); err != nil {
t.Fatal(err)
}
active, err := app.GetActiveThemePack()
if err != nil {
t.Fatal(err)
}
if active.Pack != nil || active.ActiveThemeID != "" {
t.Fatalf("expected empty active after corrupt, got %+v", active)
}
st := loadThemeDesktopState()
if st.ActiveThemeID != "" {
t.Fatalf("state not cleared: %q", st.ActiveThemeID)
}
}
func TestListThemePacksIncludesBuiltins(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
app := NewApp()
list, err := app.ListThemePacks()
if err != nil {
t.Fatal(err)
}
if len(list) < 6 {
t.Fatalf("expected >=6 packs, got %d", len(list))
}
var sawGraphite bool
for _, p := range list {
if p.ID == "graphite" && p.Builtin {
sawGraphite = true
}
}
if !sawGraphite {
t.Fatal("missing graphite builtin")
}
}
func TestValidateThemeImagePNG(t *testing.T) {
path := writeTestPNG(t, filepath.Join(t.TempDir(), "background.png"), 8, 8)
if err := validateThemeImageFile(path); err != nil {
t.Fatal(err)
}
// Oversized edge
big := writeTestPNG(t, filepath.Join(t.TempDir(), "big.png"), themePackMaxImageEdge+1, 2)
if err := validateThemeImageFile(big); err == nil {
t.Fatal("expected dimension rejection")
}
}
func TestContrastWarningLowContrast(t *testing.T) {
m := &ThemePackManifest{
SchemaVersion: 1, ID: "c", Name: "C", BaseStyle: "graphite",
Tokens: ThemePackTokens{
Light: map[string]string{"fg": "#eeeeee", "bg": "#ffffff"},
},
Recipes: defaultThemePackRecipes(),
}
warns := computeContrastWarnings(m)
if len(warns) == 0 {
t.Fatal("expected contrast warning")
}
}
func TestManifestJSONRoundTrip(t *testing.T) {
m := ThemePackManifest{
SchemaVersion: 1, ID: "json-rt", Name: "JSON", BaseStyle: "nocturne",
Tokens: ThemePackTokens{Dark: map[string]string{"panel": "#121212ff"}},
Recipes: ThemePackRecipes{Density: "compact", Corners: "round"},
}
raw, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
parsed, err := parseThemePackManifest(raw)
if err != nil {
t.Fatal(err)
}
if parsed.Recipes.Density != "compact" || parsed.Tokens.Dark["panel"] != "#121212ff" {
t.Fatalf("round trip: %+v", parsed)
}
}
func writeTestPNG(t *testing.T, path string, w, h int) string {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 40, A: 255})
}
}
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := png.Encode(f, img); err != nil {
_ = f.Close()
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
return path
}