package ui import ( "errors" "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" v.theme = "light" 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" || got.UI.Theme != "light" { 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.choices["sonarr.profile"].set("Any") v.edited() 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") } } func TestTestConnectionsButtonSitsBelowTheServices(t *testing.T) { a := newTestApp(t) v := a.settings send := func(g *tview.Form, k tcell.Key) { g.GetInputCapture()(tcell.NewEventKey(k, 0, 0)) } focusedItem := func() tview.FormItem { i, _ := v.form.GetFocusedItemIndex(); return v.form.GetFormItem(i) } // It comes right after the last connection setting (the Trakt client ID) // and before the Interface section. v.focusGroup(0, false) for i := 0; i < 11; i++ { // Radarr 4, Sonarr 4, SABnzbd 2, Trakt 1 fields send(v.form, tcell.KeyDown) } if focusedItem() != tview.FormItem(v.testItem) { t.Fatalf("↓ after the Trakt client ID reached %T %q, want the Test button", focusedItem(), focusedItem().GetLabel()) } send(v.form, tcell.KeyDown) if got := focusedItem().GetLabel(); got != "Downloads refresh (seconds)" { t.Errorf("after the button comes %q", got) } send(v.form, tcell.KeyUp) if focusedItem() != tview.FormItem(v.testItem) { t.Error("↑ must come back to the button") } // Save and Revert stay below everything; Test is not among them. if v.actions.GetButtonCount() != 2 || v.actions.GetButton(0).GetLabel() != "Save" || v.actions.GetButton(1).GetLabel() != "Revert" { t.Errorf("actions bar has %d buttons", v.actions.GetButtonCount()) } // Space and Enter press it. var focus func(p tview.Primitive) focus = func(p tview.Primitive) { p.Focus(focus) } for _, ev := range []*tcell.EventKey{tcell.NewEventKey(tcell.KeyRune, ' ', 0), tcell.NewEventKey(tcell.KeyEnter, 0, 0)} { v.describe() v.testItem.InputHandler()(ev, focus) if !strings.Contains(v.status.GetText(false), "Testing the connections") { t.Errorf("key %v did not start the test: %q", ev.Name(), v.status.GetText(false)) } } } func TestHelpOverConfigTakesTheFocus(t *testing.T) { a := newTestApp(t) a.handleKey(tcell.NewEventKey(tcell.KeyF6, 0, 0)) moveDown := a.settings.form.GetInputCapture() moveDown(tcell.NewEventKey(tcell.KeyDown, 0, 0)) field := a.settings.inputs["radarr.apikey"] if a.app.GetFocus() != field { t.Fatalf("after ↓ the application's focus is %T, not the second field", a.app.GetFocus()) } a.handleKey(tcell.NewEventKey(tcell.KeyF9, 0, 0)) // The field must not keep the focus behind the help: it would draw its // cursor through the window, and the form would take the help's keys. if field.HasFocus() || a.settings.form.HasFocus() { t.Errorf("behind the help: field focused %v, form focused %v", field.HasFocus(), a.settings.form.HasFocus()) } if _, ok := a.app.GetFocus().(*tview.TextView); !ok { t.Errorf("help has no focus: %T", a.app.GetFocus()) } a.closeModal() if a.app.GetFocus() != field || !field.HasFocus() { t.Errorf("closing the help returns to %T, not the field that was in use", a.app.GetFocus()) } } func TestDefaultsAreChosenFromTheServer(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Header.Get("X-Api-Key") != "key": w.WriteHeader(http.StatusUnauthorized) case strings.HasSuffix(r.URL.Path, "/qualityprofile"): w.Write([]byte(`[{"id":1,"name":"Any"},{"id":4,"name":"HD-1080p"}]`)) case strings.HasSuffix(r.URL.Path, "/rootfolder"): w.Write([]byte(`[{"id":1,"path":"/movies"}]`)) } })) defer srv.Close() profiles, folders, err := fetchChoices(t.Context(), srv.URL, "key") if err != nil || strings.Join(profiles, ",") != "Any,HD-1080p" || strings.Join(folders, ",") != "/movies" { t.Fatalf("fetched %v %v %v", profiles, folders, err) } if _, _, err := fetchChoices(t.Context(), srv.URL, "wrong"); err == nil { t.Error("a wrong key must fail") } a := newTestApp(t) v := a.settings p := v.choices["radarr.profile"] p.set("Old profile") // configured, but not on the server v.dirty = false v.choiceGen["radarr"] = 1 v.applyChoices("radarr", 1, profiles, folders, nil) n := p.GetOptionCount() var labels []string for i := 0; i < n; i++ { labels = append(labels, p.valueAt(i)) } if strings.Join(labels, "|") != "|Any|HD-1080p|Old profile" { t.Errorf("options %q", labels) } if _, text := p.GetCurrentOption(); text != "Old profile (not on the server)" || p.value != "Old profile" { t.Errorf("selected %q, value %q", text, p.value) } if v.dirty { t.Error("loading the options is not an edit") } // Choosing one is an edit and is what gets saved. p.SetCurrentOption(2) if p.value != "HD-1080p" || !v.dirty || v.read().radarrProfile != "HD-1080p" { t.Errorf("chosen %q dirty %v", p.value, v.dirty) } // An answer for an address that has changed since is dropped. v.choiceGen["radarr"] = 2 v.applyChoices("radarr", 1, nil, nil, nil) if p.GetOptionCount() != n { t.Error("stale answer applied") } // Typing a new address asks that server again (after a pause); SABnzbd has no such lists. v.inputs["sonarr.url"].SetText("http://elsewhere:8989") v.inputs["sabnzbd.url"].SetText("http://elsewhere:8080") if v.choiceTimers["sonarr"] == nil || v.choiceTimers["sabnzbd"] != nil { t.Errorf("reload scheduled: %v", v.choiceTimers) } v.choiceTimers["sonarr"].Stop() // the test has no event loop to deliver it // Without the server only none and the value in use are offered. v.applyChoices("radarr", 2, nil, nil, errors.New("connection refused")) if p.GetOptionCount() != 2 || !strings.Contains(v.status.GetText(false), "connection refused") { t.Errorf("offline: %d options, result %q", p.GetOptionCount(), v.status.GetText(false)) } } func TestServerChoiceWorksWithTheArrowKeys(t *testing.T) { a := newTestApp(t) v := a.settings p := v.choices["radarr.profile"] v.choiceGen["radarr"] = 1 v.applyChoices("radarr", 1, []string{"Any", "HD-1080p"}, nil, nil) v.focusGroup(0, false) keys := v.form.GetInputCapture() send := func(k tcell.Key) { keys(tcell.NewEventKey(k, 0, 0)) } send(tcell.KeyDown) // API key send(tcell.KeyDown) // Default quality profile if item, _ := v.form.GetFocusedItemIndex(); v.form.GetFormItem(item) != p { t.Fatalf("on %q", v.form.GetFormItem(item).GetLabel()) } // → and ← change the value, like on every other dropdown. send(tcell.KeyRight) send(tcell.KeyRight) if p.value != "HD-1080p" || !v.dirty { t.Errorf("→→: value %q, dirty %v", p.value, v.dirty) } send(tcell.KeyLeft) if p.value != "Any" { t.Errorf("←: value %q", p.value) } // While its list is open, ↑↓ belong to the list, not to the form. var focus func(tview.Primitive) focus = func(q tview.Primitive) { q.Focus(focus) } p.InputHandler()(tcell.NewEventKey(tcell.KeyEnter, 0, 0), focus) if !p.IsOpen() { t.Fatal("Enter does not open the list") } if got := keys(tcell.NewEventKey(tcell.KeyDown, 0, 0)); got == nil { t.Error("↓ in the open list was taken by the form") } }