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:
2026-10-20 23:31:13 +02:00
co-authored by Claude Sonnet 5.5
parent 0ccd02b3c5
commit a4ec9ad71e
25 changed files with 1168 additions and 54 deletions
+233
View File
@@ -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")
}
}