mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
* 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.
652 lines
19 KiB
Go
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
|
|
}
|