Add the Config view: edit and save the settings, applied without a restart
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user