234 lines
7.5 KiB
Go
234 lines
7.5 KiB
Go
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")
|
|
}
|
|
}
|