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") } } 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 SABnzbd API key and before the Interface section. v.focusGroup(0, false) for i := 0; i < 10; i++ { // Radarr 4, Sonarr 4, SABnzbd 2 fields send(v.form, tcell.KeyDown) } if focusedItem() != tview.FormItem(v.testItem) { t.Fatalf("↓ after the SABnzbd API key reached %T %q, want the Test connections 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 connections 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)) } } }