275 lines
9.3 KiB
Go
275 lines
9.3 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 TestSettingsViewIsOneListNavigatedWithArrows(t *testing.T) {
|
|
a := newTestApp(t)
|
|
v := a.settings
|
|
var focus func(p tview.Primitive)
|
|
focus = func(p tview.Primitive) { p.Focus(focus) }
|
|
|
|
// Whatever focus flags were left behind, moving to the buttons must leave exactly one group focused.
|
|
v.focusGroup(0, false)
|
|
v.form.Focus(focus)
|
|
v.actions.Focus(focus)
|
|
v.focusGroup(1, false)
|
|
if v.form.HasFocus() || !v.actions.HasFocus() {
|
|
t.Error("only the buttons may have the focus")
|
|
}
|
|
|
|
send := func(g *tview.Form, k tcell.Key) { g.GetInputCapture()(tcell.NewEventKey(k, 0, 0)) }
|
|
focusedItem := func() int { i, _ := v.form.GetFocusedItemIndex(); return i }
|
|
labelAt := func(i int) string { return v.form.GetFormItem(i).GetLabel() }
|
|
|
|
// The list starts on the first real field, not on the "Radarr" heading.
|
|
v.focusGroup(0, false)
|
|
if i := focusedItem(); labelAt(i) != "Address" || i != 1 {
|
|
t.Fatalf("starts on item %d (%q)", i, labelAt(i))
|
|
}
|
|
// ↓ walks through the fields of a section and over the next heading into the next section.
|
|
for _, want := range []string{"API key", "Default quality profile", "Default root folder", "Address"} {
|
|
send(v.form, tcell.KeyDown)
|
|
if got := labelAt(focusedItem()); got != want {
|
|
t.Fatalf("after ↓ on %q, want %q", got, want)
|
|
}
|
|
}
|
|
if focusedItem() != 6 { // 5 is the Sonarr heading
|
|
t.Errorf("↓ must skip the section heading, now on item %d", focusedItem())
|
|
}
|
|
send(v.form, tcell.KeyUp)
|
|
send(v.form, tcell.KeyUp)
|
|
if focusedItem() != 3 && focusedItem() != 4 {
|
|
t.Errorf("↑ must also skip the heading, now on item %d", focusedItem())
|
|
}
|
|
|
|
// ↓ on the very last field moves to the buttons; ↑ on a button goes back to the last field.
|
|
v.focusGroup(0, true)
|
|
if got := labelAt(focusedItem()); got != "Show API keys" {
|
|
t.Fatalf("last field = %q", got)
|
|
}
|
|
send(v.form, tcell.KeyDown)
|
|
if !v.actions.HasFocus() || v.form.HasFocus() {
|
|
t.Error("↓ past the last field must reach the buttons")
|
|
}
|
|
send(v.actions, tcell.KeyUp)
|
|
if !v.form.HasFocus() || v.actions.HasFocus() || labelAt(focusedItem()) != "Show API keys" {
|
|
t.Error("↑ on the buttons must go back to the last field")
|
|
}
|
|
// ↑ on the first field has nowhere to go.
|
|
v.focusGroup(0, false)
|
|
send(v.form, tcell.KeyUp)
|
|
if focusedItem() != 1 {
|
|
t.Errorf("↑ on the first field moved to item %d", focusedItem())
|
|
}
|
|
}
|
|
|
|
func TestFormHeadingNeverTakesFocus(t *testing.T) {
|
|
f := tview.NewForm()
|
|
f.AddFormItem(newFormHeading("Section"))
|
|
f.AddInputField("One", "", 10, nil, nil)
|
|
f.AddFormItem(newFormHeading("Other"))
|
|
f.AddInputField("Two", "", 10, nil, nil)
|
|
if firstFocusable(f) != 1 {
|
|
t.Errorf("first focusable = %d", firstFocusable(f))
|
|
}
|
|
if neighbour(f, 1, 1) != 3 || neighbour(f, 3, -1) != 1 || neighbour(f, 3, 1) != 3 || neighbour(f, 1, -1) != 1 {
|
|
t.Error("neighbour must skip headings and stay at the ends")
|
|
}
|
|
// tview's own Tab handling passes over a heading as well.
|
|
var focus func(p tview.Primitive)
|
|
focus = func(p tview.Primitive) { p.Focus(focus) }
|
|
f.Focus(focus)
|
|
if i, _ := f.GetFocusedItemIndex(); i == 0 {
|
|
t.Error("a heading got the focus")
|
|
}
|
|
}
|