package ui import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "git.stinnesbeck.com/nils/arr-tui/internal/config" ) func baseRaw() config.Config { return config.Config{ Radarr: config.Arr{Service: config.Service{URL: "http://r:7878", APIKey: "rk"}}, Sonarr: config.Arr{Service: config.Service{URL: "http://s:8989", APIKey: "sk"}}, SABnzbd: config.Service{URL: "http://a:8080", APIKey: "ak"}, } } func TestApplySettings(t *testing.T) { raw := baseRaw() v := settingsFrom(mustEffective(t, raw)) if v.refresh != "3" || !v.covers || !v.notify || v.bell || v.coverStyle != "auto" { t.Fatalf("defaults shown wrong: %+v", v) } // Unchanged settings add nothing but the refresh interval to the file. got, err := applySettings(raw, v, nil) if err != nil { t.Fatal(err) } if got.UI.Covers != nil || got.UI.NotifyDownloads != nil || got.UI.CoverStyle != "" || got.UI.Bell { t.Errorf("defaults written to the file: %+v", got.UI) } // Edits are applied and whitespace is trimmed. v.radarrURL = " http://new:7878 " v.radarrProfile = " HD-1080p " v.sabKey = "newkey" v.refresh = "10" v.covers = false v.notify = false v.bell = true v.coverStyle = "quadrant" got, err = applySettings(raw, v, nil) if err != nil { t.Fatal(err) } if got.Radarr.URL != "http://new:7878" || got.Radarr.DefaultQualityProfile != "HD-1080p" || got.SABnzbd.APIKey != "newkey" || got.UI.RefreshSeconds != 10 || got.UI.Covers == nil || *got.UI.Covers || got.UI.NotifyDownloads == nil || *got.UI.NotifyDownloads || !got.UI.Bell || got.UI.CoverStyle != "quadrant" { t.Errorf("edits lost: %+v", got) } // A setting that was explicit in the file stays explicit, even at its default. yes := true raw.UI.Covers = &yes v.covers = true got, _ = applySettings(raw, v, nil) if got.UI.Covers == nil || !*got.UI.Covers { t.Error("an explicit setting was dropped") } } func TestApplySettingsValidates(t *testing.T) { v := settingsFrom(mustEffective(t, baseRaw())) for name, mutate := range map[string]func(*settingsValues){ "refresh not a number": func(v *settingsValues) { v.refresh = "often" }, "refresh zero": func(v *settingsValues) { v.refresh = "0" }, "refresh too large": func(v *settingsValues) { v.refresh = "99999" }, "address not http": func(v *settingsValues) { v.sonarrURL = "ftp://x" }, "empty api key": func(v *settingsValues) { v.radarrKey = " " }, "empty address": func(v *settingsValues) { v.sabURL = "" }, } { w := v mutate(&w) if _, err := applySettings(baseRaw(), w, nil); err == nil { t.Errorf("%s: accepted", name) } } } func TestApplySettingsKeepsEnvironmentValuesOutOfTheFile(t *testing.T) { raw := baseRaw() // The fields show the key from the environment... shown := settingsFrom(mustEffective(t, raw)) shown.radarrKey = "secret-from-env" shown.radarrURL = "http://typed-over-env" got, err := applySettings(raw, shown, map[string]string{"radarr.apikey": "ARR_TUI_RADARR_APIKEY", "radarr.url": "ARR_TUI_RADARR_URL"}) if err != nil { t.Fatal(err) } // ...but the file keeps what it had. if got.Radarr.APIKey != "rk" || got.Radarr.URL != "http://r:7878" { t.Errorf("environment values leaked into the file: %+v", got.Radarr) } } func mustEffective(t *testing.T, raw config.Config) *config.Config { t.Helper() eff, err := raw.Effective(func(string) string { return "" }) if err != nil { t.Fatal(err) } return eff } func TestSettingsViewSavesAndAppliesLive(t *testing.T) { t.Setenv("ARR_TUI_RADARR_APIKEY", "") // make sure the environment does not interfere var gotKey atomic.Value // the app also probes the server in the background radarr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotKey.Store(r.Header.Get("X-Api-Key")) w.Write([]byte(`{"version":"6.0"}`)) })) defer radarr.Close() a := newTestApp(t) path := filepath.Join(t.TempDir(), "config.json") raw := baseRaw() if err := config.Save(path, &raw); err != nil { t.Fatal(err) } loaded, _ := config.LoadFile(path) a.SetConfigSource(path, loaded) v := a.settings v.fill(settingsFrom(a.cfg)) // Edit the fields like a user would and save. v.inputs["radarr.url"].SetText(radarr.URL) v.inputs["radarr.apikey"].SetText("brand-new-key") v.inputs["sonarr.profile"].SetText("Any") v.refresh.SetText("7") v.bell.SetChecked(true) if !v.dirty { t.Error("editing must mark the form as changed") } v.save() // 1. The file has the new values and stays private. saved, err := config.LoadFile(path) if err != nil { t.Fatal(err) } if saved.Radarr.URL != radarr.URL || saved.Radarr.APIKey != "brand-new-key" || saved.Sonarr.DefaultQualityProfile != "Any" || saved.UI.RefreshSeconds != 7 || !saved.UI.Bell { t.Errorf("file = %+v", saved) } if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 { t.Errorf("mode %v", info.Mode().Perm()) } // 2. The running app uses them at once. if a.cfg.Radarr.URL != radarr.URL || !a.bell || a.cfg.UI.RefreshSeconds != 7 { t.Errorf("settings not applied: %+v", a.cfg) } if _, err := a.radarr.SystemStatus(t.Context()); err != nil || gotKey.Load() != "brand-new-key" { t.Errorf("the client still talks to the old instance: key=%v err=%v", gotKey.Load(), err) } if got := a.downloads.interval.Seconds(); got != 7 { t.Errorf("downloads interval = %v", got) } if v.dirty { t.Error("saving must clear the changed mark") } if !strings.Contains(v.status.GetText(false), "Saved") { t.Errorf("result box: %q", v.status.GetText(false)) } // An invalid edit is refused and leaves the file alone. before, _ := os.ReadFile(path) v.refresh.SetText("soon") v.save() after, _ := os.ReadFile(path) if string(before) != string(after) || !strings.Contains(v.status.GetText(false), "Not saved") { t.Errorf("invalid settings were saved: %q", v.status.GetText(false)) } // Revert goes back to what is in use. v.revert() if v.refresh.GetText() != "7" || v.dirty { t.Errorf("revert: refresh=%q dirty=%v", v.refresh.GetText(), v.dirty) } } func TestSettingsViewWithoutConfigFile(t *testing.T) { a := newTestApp(t) a.settings.save() if !strings.Contains(a.settings.status.GetText(false), "no config file") { t.Errorf("result box: %q", a.settings.status.GetText(false)) } } func TestSettingsViewFocusMovesBetweenSections(t *testing.T) { a := newTestApp(t) v := a.settings var focus func(p tview.Primitive) focus = func(p tview.Primitive) { p.Focus(focus) } v.focusGroup(3, false) // Interface for _, g := range v.groups { g.Focus(focus) } // Whatever flags were left behind, moving to the actions must leave exactly one section focused. v.focusGroup(4, false) focused := 0 for _, g := range v.groups { if g.HasFocus() { focused++ } } if focused != 1 || !v.actions.HasFocus() { t.Errorf("%d sections claim the focus, want only the actions", focused) } // Down on the last field of a section continues in the next one; Tab cycles. v.focusGroup(0, false) send := func(g *tview.Form, k tcell.Key) { g.GetInputCapture()(tcell.NewEventKey(k, 0, 0)) } g := v.radarr g.SetFocus(g.GetFormItemCount() - 1) send(g, tcell.KeyDown) if !v.sonarr.HasFocus() || v.radarr.HasFocus() { t.Error("Down on the last field must move to the next section") } send(v.sonarr, tcell.KeyBacktab) if !v.radarr.HasFocus() || v.sonarr.HasFocus() { t.Error("Shift+Tab must move back a section") } send(v.radarr, tcell.KeyUp) // first section, first field: nothing before it if !v.radarr.HasFocus() { t.Error("Up on the first field of the first section must stay put") } }