Colors no longer come from the terminal's palette, whose color 0 many color themes set to a grey that is not their background. A theme sets tview's default styles and the color names used in texts, all chosen from entries 16-255 of the 256-color table so terminals without 24-bit color show them exactly. "auto" picks light or dark from the background color the terminal reports. Settable in the Config view, applied at the next start (widgets copy their colors when created). Replaces using the terminal's own background and text colors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
203 lines
6.5 KiB
Go
203 lines
6.5 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const valid = `{
|
|
"radarr": {"url": "http://localhost:7878/", "apikey": "r"},
|
|
"sonarr": {"url": "http://localhost:8989", "apikey": "s"},
|
|
"sabnzbd": {"url": "https://sab.example.com", "apikey": "n"}
|
|
}`
|
|
|
|
func TestParseValid(t *testing.T) {
|
|
cfg, err := Parse([]byte(valid))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Radarr.URL != "http://localhost:7878" {
|
|
t.Errorf("trailing slash not trimmed: %q", cfg.Radarr.URL)
|
|
}
|
|
if cfg.UI.RefreshSeconds != defaultRefreshSeconds {
|
|
t.Errorf("refresh default = %d", cfg.UI.RefreshSeconds)
|
|
}
|
|
}
|
|
|
|
func TestParseErrors(t *testing.T) {
|
|
tests := map[string]struct {
|
|
json string
|
|
want string
|
|
}{
|
|
"unknown field": {`{"radar": {}}`, "unknown field"},
|
|
"missing key": {`{"radarr":{"url":"http://a"},"sonarr":{"url":"http://b","apikey":"x"},"sabnzbd":{"url":"http://c","apikey":"x"}}`, "radarr: apikey is required"},
|
|
"bad url": {`{"radarr":{"url":"localhost","apikey":"x"},"sonarr":{"url":"http://b","apikey":"x"},"sabnzbd":{"url":"http://c","apikey":"x"}}`, "must be an http(s) URL"},
|
|
"invalid json": {`{`, "parsing config"},
|
|
}
|
|
for name, tc := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
_, err := Parse([]byte(tc.json))
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("err = %v, want containing %q", err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEnvOverride(t *testing.T) {
|
|
t.Setenv("ARR_TUI_RADARR_APIKEY", "from-env")
|
|
cfg, err := Parse([]byte(valid))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Radarr.APIKey != "from-env" {
|
|
t.Errorf("apikey = %q", cfg.Radarr.APIKey)
|
|
}
|
|
}
|
|
|
|
func TestLoadPermissionWarning(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "config.json")
|
|
if err := os.WriteFile(path, []byte(valid), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, warnings, err := Load(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(warnings) != 1 {
|
|
t.Fatalf("warnings = %v", warnings)
|
|
}
|
|
if err := os.Chmod(path, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, warnings, _ = Load(path); len(warnings) != 0 {
|
|
t.Fatalf("unexpected warnings: %v", warnings)
|
|
}
|
|
}
|
|
|
|
func TestShowCovers(t *testing.T) {
|
|
base := `{"radarr":{"url":"http://r","apikey":"k"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"}`
|
|
for _, tc := range []struct {
|
|
ui string
|
|
want bool
|
|
}{
|
|
{``, true},
|
|
{`,"ui":{}`, true},
|
|
{`,"ui":{"covers":true}`, true},
|
|
{`,"ui":{"covers":false}`, false},
|
|
} {
|
|
cfg, err := Parse([]byte(base + tc.ui + `}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := cfg.UI.ShowCovers(); got != tc.want {
|
|
t.Errorf("%q: ShowCovers = %v, want %v", tc.ui, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCoverStyleValidation(t *testing.T) {
|
|
base := `{"radarr":{"url":"http://r","apikey":"k"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"},"ui":{"cover_style":`
|
|
for _, ok := range []string{`"auto"`, `"quadrant"`, `"sextant"`, `"cells"`, `"kitty"`, `""`} {
|
|
if _, err := Parse([]byte(base + ok + `}}`)); err != nil {
|
|
t.Errorf("%s rejected: %v", ok, err)
|
|
}
|
|
}
|
|
if _, err := Parse([]byte(base + `"sixel"}}`)); err == nil {
|
|
t.Error("unknown style accepted")
|
|
}
|
|
}
|
|
|
|
func TestThemeValidation(t *testing.T) {
|
|
base := `{"radarr":{"url":"http://r","apikey":"k"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"},"ui":{"theme":`
|
|
for _, ok := range []string{`"auto"`, `"dark"`, `"light"`, `""`} {
|
|
if _, err := Parse([]byte(base + ok + `}}`)); err != nil {
|
|
t.Errorf("%s rejected: %v", ok, err)
|
|
}
|
|
}
|
|
if _, err := Parse([]byte(base + `"solarized"}}`)); err == nil {
|
|
t.Error("unknown theme accepted")
|
|
}
|
|
}
|
|
|
|
func TestSaveRoundTripAndPermissions(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.json")
|
|
covers := false
|
|
cfg := &Config{
|
|
Radarr: Arr{Service: Service{URL: "http://r:7878", APIKey: "rk"}, DefaultQualityProfile: "HD-1080p"},
|
|
Sonarr: Arr{Service: Service{URL: "http://s:8989", APIKey: "sk"}},
|
|
SABnzbd: Service{URL: "http://a:8080", APIKey: "ak"},
|
|
UI: UI{RefreshSeconds: 5, Covers: &covers, CoverStyle: "sextant", Bell: true},
|
|
}
|
|
if err := Save(path, cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil || info.Mode().Perm() != 0o600 {
|
|
t.Fatalf("new file mode = %v, want 0600 (it holds API keys): %v", info.Mode().Perm(), err)
|
|
}
|
|
got, err := LoadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Radarr.DefaultQualityProfile != "HD-1080p" || got.UI.RefreshSeconds != 5 || got.UI.Covers == nil || *got.UI.Covers || got.UI.CoverStyle != "sextant" || !got.UI.Bell {
|
|
t.Errorf("round trip lost values: %+v", got)
|
|
}
|
|
// Unset optional settings stay out of the file.
|
|
if data, _ := os.ReadFile(path); strings.Contains(string(data), "notify_downloads") || strings.Contains(string(data), "default_root_folder") {
|
|
t.Errorf("unset settings written:\n%s", data)
|
|
}
|
|
|
|
// An existing file keeps its permissions and no temporary file is left behind.
|
|
if err := os.Chmod(path, 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := Save(path, cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info, _ := os.Stat(path); info.Mode().Perm() != 0o640 {
|
|
t.Errorf("existing mode changed to %v", info.Mode().Perm())
|
|
}
|
|
entries, _ := os.ReadDir(dir)
|
|
if len(entries) != 1 {
|
|
t.Errorf("leftover files in the config directory: %v", entries)
|
|
}
|
|
}
|
|
|
|
func TestLoadFileIgnoresEnvironmentAndDefaults(t *testing.T) {
|
|
t.Setenv("ARR_TUI_RADARR_APIKEY", "from-env")
|
|
path := filepath.Join(t.TempDir(), "config.json")
|
|
os.WriteFile(path, []byte(`{"radarr":{"url":"http://r/","apikey":"from-file"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"}}`), 0o600)
|
|
|
|
raw, err := LoadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if raw.Radarr.APIKey != "from-file" || raw.Radarr.URL != "http://r/" || raw.UI.RefreshSeconds != 0 {
|
|
t.Errorf("raw = %+v: environment, trailing slash trimming and defaults must not be applied", raw)
|
|
}
|
|
eff, err := raw.Effective(os.Getenv)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if eff.Radarr.APIKey != "from-env" || eff.Radarr.URL != "http://r" || eff.UI.RefreshSeconds != 3 {
|
|
t.Errorf("effective = %+v", eff)
|
|
}
|
|
if raw.Radarr.APIKey != "from-file" {
|
|
t.Error("Effective must not modify the raw config")
|
|
}
|
|
if got := Overrides(os.Getenv); len(got) != 1 || got["radarr.apikey"] != "ARR_TUI_RADARR_APIKEY" {
|
|
t.Errorf("overrides = %v", got)
|
|
}
|
|
}
|
|
|
|
func TestEffectiveValidates(t *testing.T) {
|
|
raw := &Config{Radarr: Arr{Service: Service{URL: "ftp://x", APIKey: "k"}}}
|
|
if _, err := raw.Effective(func(string) string { return "" }); err == nil {
|
|
t.Error("an invalid config must not become effective")
|
|
}
|
|
}
|