package main import ( "bytes" "encoding/json" "fmt" "os" "path/filepath" "strings" "testing" ) func TestStateTransitions(t *testing.T) { value := state{ Current: currentPair{Light: "old-light", Dark: "old-dark"}, Catalog: catalog{ FavoritePairs: []themePair{{Name: "swamp", Dark: "swamp-dark", Light: "swamp-light"}}, FavoriteThemes: []string{"favorite"}, OtherPairs: []themePair{{Name: "nord", Dark: "nord-dark", Light: "nord-light"}}, OtherThemes: []string{"swamp-dark", "nord-light", "other"}, }, } value.Catalog.normalize() if len(value.Catalog.OtherThemes) != 1 || value.Catalog.OtherThemes[0] != "other" { t.Fatalf("pair themes leaked into other themes: %#v", value.Catalog.OtherThemes) } if !value.Catalog.favorite("nord") { t.Fatal("named pair was treated as a theme") } value.Catalog.configurePair(themePair{Name: "nord", Dark: "new-dark", Light: "new-light"}) pair, favorite, found := value.Catalog.findPair("nord") if !found || !favorite || pair.Dark != "new-dark" { t.Fatalf("reconfigured pair lost favorite state: %#v", value.Catalog) } if !value.Catalog.unfavorite("nord") { t.Fatal("favorite pair was treated as a theme") } path := filepath.Join(t.TempDir(), "state.toml") if err := saveState(path, value); err != nil { t.Fatal(err) } roundTrip, err := loadState(path) if err != nil { t.Fatal(err) } if roundTrip.Current != value.Current { t.Fatalf("current pair changed after round trip: %#v", roundTrip.Current) } if _, favorite, found := roundTrip.Catalog.findPair("nord"); !found || favorite { t.Fatalf("pair did not remain in other pairs: %#v", roundTrip.Catalog) } config := readFile(t, path) if strings.Contains(config, "\n ") { t.Fatalf("configuration is unnecessarily indented:\n%s", config) } order := []string{"[current]", "[[favorites.pairs]]", "[favorites]", "[[others.pairs]]", "[others]"} last := -1 for _, section := range order { index := strings.Index(config, section) if index <= last { t.Fatalf("configuration section order is wrong:\n%s", config) } last = index } if strings.Contains(config, "current =") || strings.Contains(config, "[[favorite_themes]]") || strings.Contains(config, "[[other_themes]]") { t.Fatalf("state entries used the wrong TOML shape:\n%s", config) } } func TestCLIUpdatesAndAppliesPair(t *testing.T) { tmp := t.TempDir() bin := filepath.Join(tmp, "bin") if err := os.Mkdir(bin, 0o755); err != nil { t.Fatal(err) } log := filepath.Join(tmp, "applied") writeExecutable(t, filepath.Join(bin, "tinty"), `#!/bin/sh case "$1" in info) [ "$2" != missing ] ;; apply) printf '%s\n' "$2" >> "$TINTY_LOG" ;; *) exit 2 ;; esac `) statePath := filepath.Join(tmp, "state.toml") t.Setenv("PATH", bin+":"+os.Getenv("PATH")) t.Setenv("TINTY_LOG", log) t.Setenv("TINT_STATE_FILE", statePath) t.Setenv("TINTY_PAIR_APPEARANCE", "dark") if err := saveState(statePath, state{Current: currentPair{Light: "old-light", Dark: "old-dark"}}); err != nil { t.Fatal(err) } var stdout, stderr bytes.Buffer if err := runCLI([]string{"dark:new-dark"}, &stdout, &stderr); err != nil { t.Fatalf("update failed: %v: %s", err, stderr.String()) } value, err := loadState(statePath) if err != nil || value.Current.Dark != "new-dark" || value.Current.Light != "old-light" { t.Fatalf("wrong current pair: %#v, %v", value.Current, err) } if got := strings.TrimSpace(readFile(t, log)); got != "new-dark" { t.Fatalf("wrong applied theme: %q", got) } if err := runCLI([]string{"pair", "nord", "dark:nord-dark", "light:nord-light"}, &stdout, &stderr); err != nil { t.Fatal(err) } if err := runCLI([]string{"favorite", "nord"}, &stdout, &stderr); err != nil { t.Fatal(err) } if err := runCLI([]string{"nord"}, &stdout, &stderr); err != nil { t.Fatal(err) } value, err = loadState(statePath) if err != nil || value.Current.Dark != "nord-dark" || value.Current.Light != "nord-light" { t.Fatalf("named pair not applied: %#v, %v", value.Current, err) } if _, favorite, found := value.Catalog.findPair("nord"); !found || !favorite { t.Fatalf("pair not favorited: %#v", value.Catalog) } stdout.Reset() if err := runCLI([]string{"config"}, &stdout, &stderr); err != nil { t.Fatal(err) } if stdout.String() != readFile(t, statePath) { t.Fatal("tint config did not print the state file verbatim") } } func TestApplyAppsUsesTinted8Semantics(t *testing.T) { tmp := t.TempDir() bin := filepath.Join(tmp, "bin") if err := os.Mkdir(bin, 0o755); err != nil { t.Fatal(err) } palette := map[string]tintyColor{ "black-normal": {Hex: "#000000"}, "black-bright": {Hex: "#111111"}, "gray-dim": {Hex: "#222222"}, "gray-normal": {Hex: "#333333"}, "gray-bright": {Hex: "#444444"}, "white-normal": {Hex: "#eeeeee"}, "white-bright": {Hex: "#ffffff"}, "black-dim": {Hex: "#111111"}, "white-dim": {Hex: "#dddddd"}, "red-normal": {Hex: "#aa0000"}, "orange-normal": {Hex: "#bb5500"}, "yellow-normal": {Hex: "#aaaa00"}, "green-normal": {Hex: "#00aa00"}, "cyan-normal": {Hex: "#00aaaa"}, "blue-normal": {Hex: "#0000aa"}, "magenta-normal": {Hex: "#aa00aa"}, "brown-normal": {Hex: "#663300"}, } scheme := tintyScheme{ ID: "tinted8-test", System: "tinted8", Variant: "dark", Palette: palette, UI: map[string]tintyColor{ "accent.normal": {Hex: "#123456"}, "global.background.normal": {Hex: "#010101"}, "global.foreground.normal": {Hex: "#fefefe"}, }, Syntax: map[string]tintyColor{"entity.name.function": {Hex: "#654321"}}, } listPath := filepath.Join(tmp, "schemes.json") data, err := json.Marshal([]tintyScheme{scheme}) if err != nil { t.Fatal(err) } if err := os.WriteFile(listPath, data, 0o644); err != nil { t.Fatal(err) } writeExecutable(t, filepath.Join(bin, "tinty"), `#!/bin/sh case "$1" in current) printf '%s\n' "$TINTY_TEST_SCHEME" ;; list) cat "$TINTY_TEST_LIST" ;; *) exit 2 ;; esac `) t.Setenv("HOME", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, "xdg")) t.Setenv("PATH", bin+":"+os.Getenv("PATH")) t.Setenv("TINTY_TEST_SCHEME", scheme.ID) t.Setenv("TINTY_TEST_LIST", listPath) if err := os.MkdirAll(filepath.Join(tmp, ".config", "herdr"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(tmp, ".config", "herdr", "config.toml"), []byte("onboarding = false\n"), 0o644); err != nil { t.Fatal(err) } zed := filepath.Join(tmp, "zed.json") if err := os.WriteFile(zed, []byte(`{"name":"old","themes":[{"name":"old"}]}`), 0o644); err != nil { t.Fatal(err) } conjureConfig := filepath.Join(tmp, "xdg", "conjure", "config.toml") if err := os.MkdirAll(filepath.Dir(conjureConfig), 0o755); err != nil { t.Fatal(err) } const userConfig = "theme = \"tint\"\n\n[colors]\naccent = \"#abcdef\"\n" if err := os.WriteFile(conjureConfig, []byte(userConfig), 0o644); err != nil { t.Fatal(err) } if err := applyApps(zed); err != nil { t.Fatal(err) } var omp struct { Colors map[string]string `json:"colors"` } if err := json.Unmarshal([]byte(readFile(t, filepath.Join(tmp, ".omp", "agent", "themes", "tinty-current.json"))), &omp); err != nil { t.Fatal(err) } if omp.Colors["accent"] != "#123456" || omp.Colors["syntaxFunction"] != "#654321" { t.Fatalf("semantic colors ignored: %#v", omp.Colors) } herdr := readFile(t, filepath.Join(tmp, ".config", "herdr", "config.toml")) if !strings.Contains(herdr, `accent = "#123456"`) { t.Fatalf("semantic Herdr accent missing:\n%s", herdr) } zedOutput := readFile(t, filepath.Join(tmp, ".config", "zed", "themes", "tinty-current.json")) if !strings.Contains(zedOutput, `"name": "Tinty Current"`) { t.Fatalf("Zed theme was not renamed:\n%s", zedOutput) } var conjure struct { Variant string `json:"variant"` Colors map[string]string `json:"colors"` } if err := json.Unmarshal([]byte(readFile(t, filepath.Join(filepath.Dir(conjureConfig), "theme.json"))), &conjure); err != nil { t.Fatal(err) } if conjure.Variant != "dark" { t.Fatalf("Conjure variant = %q, want dark", conjure.Variant) } expected := map[string]string{ "background": "#010101", "text": "#fefefe", "secondaryText": "#444444", "accent": "#123456", "glow": "#aa0000", "keyBackground": "#111111", "error": "#aa0000", } if len(conjure.Colors) != len(expected) { t.Fatalf("wrong Conjure roles: %#v", conjure.Colors) } for key, value := range expected { if conjure.Colors[key] != value { t.Errorf("Conjure %s = %q, want %q", key, conjure.Colors[key], value) } } if readFile(t, conjureConfig) != userConfig { t.Fatal("Conjure user configuration changed") } scheme.Variant = "light" expected["secondaryText"] = "#222222" expected["keyBackground"] = "#dddddd" data, err = json.Marshal([]tintyScheme{scheme}) if err != nil { t.Fatal(err) } if err := os.WriteFile(listPath, data, 0o644); err != nil { t.Fatal(err) } if err := applyApps(zed); err != nil { t.Fatal(err) } if err := json.Unmarshal([]byte(readFile(t, filepath.Join(filepath.Dir(conjureConfig), "theme.json"))), &conjure); err != nil { t.Fatal(err) } if conjure.Variant != "light" { t.Fatalf("Conjure variant = %q, want light", conjure.Variant) } for key, value := range expected { if conjure.Colors[key] != value { t.Errorf("light Conjure %s = %q, want %q", key, conjure.Colors[key], value) } } if readFile(t, conjureConfig) != userConfig { t.Fatal("Conjure user configuration changed") } } func TestBase16SemanticFallback(t *testing.T) { palette := make(map[string]tintyColor) for i, value := range []string{ "#000000", "#111111", "#222222", "#333333", "#444444", "#555555", "#666666", "#777777", "#880000", "#990000", "#aaaa00", "#00bb00", "#00cccc", "#0000dd", "#ee00ee", "#ff00ff", } { palette[fmt.Sprintf("base%02X", i)] = tintyColor{Hex: value} } colors, err := semanticColors(tintyScheme{System: "base16", Palette: palette}) if err != nil { t.Fatal(err) } omp, herdr := semanticTheme(colors, nil, nil) if omp["accent"] != colors["base0D"] || omp["syntaxFunction"] != colors["base0D"] { t.Fatalf("Base16 OMP fallback changed: %#v", omp) } if herdr["panel_bg"] != colors["base00"] || herdr["text"] != colors["base05"] { t.Fatalf("Base16 Herdr fallback changed: %#v", herdr) } } func TestConjureThemeFallbacks(t *testing.T) { for _, system := range []string{"base16", "base24"} { for _, xdg := range []string{"", "relative", "absolute"} { t.Run(system+"/"+xdg, func(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) configHome := filepath.Join(home, ".config") value := xdg if xdg == "absolute" { value = filepath.Join(home, "xdg") configHome = value } t.Setenv("XDG_CONFIG_HOME", value) palette := make(map[string]tintyColor) for i := range 24 { palette[fmt.Sprintf("base%02X", i)] = tintyColor{Hex: fmt.Sprintf("#%06x", i)} } colors, err := semanticColors(tintyScheme{System: system, Palette: palette}) if err != nil { t.Fatal(err) } if err := writeConjureTheme("dark", colors, nil); err != nil { t.Fatal(err) } var theme struct { Colors map[string]string `json:"colors"` } if err := json.Unmarshal([]byte(readFile(t, filepath.Join(configHome, "conjure", "theme.json"))), &theme); err != nil { t.Fatal(err) } for key, base := range map[string]string{ "background": "base00", "text": "base05", "secondaryText": "base04", "accent": "base0D", "glow": "base08", "keyBackground": "base01", "error": "base08", } { if theme.Colors[key] != colors[base] { t.Errorf("%s = %q, want %q", key, theme.Colors[key], colors[base]) } } }) } } } func TestConjureThemeRejectsInvalidColors(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("XDG_CONFIG_HOME", "") for _, value := range []string{"", "#12345", "#12345678", "#gggggg", "#123456"} { t.Run(value, func(t *testing.T) { colors := map[string]string{ "base00": "#000000", "base01": "#111111", "base04": "#444444", "base05": "#555555", "base08": "#880000", "base0D": value, } if err := writeConjureTheme("dark", colors, nil); err == nil { t.Fatalf("accepted invalid accent %q", value) } }) } } func TestConjureThemeRejectsInvalidVariants(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("XDG_CONFIG_HOME", "") colors := map[string]string{ "base00": "#000000", "base01": "#111111", "base04": "#444444", "base05": "#555555", "base08": "#880000", "base0D": "#0000dd", } for _, variant := range []string{"", "auto", "Light", "DARK"} { t.Run(variant, func(t *testing.T) { if err := writeConjureTheme(variant, colors, nil); err == nil { t.Fatalf("accepted invalid variant %q", variant) } }) } } func writeExecutable(t *testing.T, path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0o755); err != nil { t.Fatal(err) } } func readFile(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(data) }