Colors no longer come from the terminal's palette, whose color 0 many color themes set to a grey that is not their background. A theme sets tview's default styles and the color names used in texts, all chosen from entries 16-255 of the 256-color table so terminals without 24-bit color show them exactly. "auto" picks light or dark from the background color the terminal reports. Settable in the Config view, applied at the next start (widgets copy their colors when created). Replaces using the terminal's own background and text colors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
316 lines
11 KiB
Go
316 lines
11 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"
|
|
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.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 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))
|
|
}
|
|
}
|
|
}
|