Files
nilsandClaude Opus 5.5 4dba794b26 Trending, most watched and anticipated movies and series from Trakt
With an empty search box, Movies and Series show one of Trakt's public
lists: trending now, most watched this week, anticipated, popular, and
for movies the US box office. [ ] switch lists, r reloads. Each entry is
looked up in Radarr (tmdb:) or Sonarr (tvdb:), so it behaves like a
search result: details, poster, tick when in the library, add, releases.

Needs a Trakt client ID: new optional trakt.client_id setting (Config
view section "Trakt", ARR_TUI_TRAKT_CLIENT_ID), masked like the API keys
and checked by Test. ARR_TUI_TRAKT_URL points at another server for
development.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:05:35 +02:00

452 lines
16 KiB
Go

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")
}
}