Files
arr-tui/internal/ui/library_test.go
T
nilsandClaude Opus 5.5 15f7fc7ee8 Library: cut long titles to the width of the list
Long titles made the movies and series tables wider than their pane, so
they scrolled sideways and status and size left the view. The title
column is now cut on every draw to what the other columns leave, ending
in an ellipsis (done by hand: tview only draws its ellipsis for untagged
text, and every title starts with the colored monitored mark). Titles are
now escaped, so square brackets in them are not taken as color tags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 14:26:09 +02:00

311 lines
11 KiB
Go

package ui
import (
"slices"
"strings"
"testing"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
)
func TestMovieItemStatus(t *testing.T) {
tests := []struct {
name string
m radarr.Movie
want string
have, miss bool
}{
{"missing", radarr.Movie{ID: 1, Title: "A", Monitored: true, IsAvailable: true}, "missing", false, true},
{"unreleased", radarr.Movie{ID: 2, Title: "B", Monitored: true}, "not released", false, false},
{"unmonitored", radarr.Movie{ID: 3, Title: "C", IsAvailable: true}, "unmonitored", false, false},
{"downloaded", radarr.Movie{ID: 4, Title: "D", Monitored: true, HasFile: true, IsAvailable: true}, "✓", true, false},
}
for _, tc := range tests {
it := newMovieItem(tc.m)
if !strings.Contains(it.status, tc.want) || it.have != tc.have || it.missing != tc.miss {
t.Errorf("%s: status=%q have=%v missing=%v", tc.name, it.status, it.have, it.missing)
}
}
}
func TestSeriesItemStatus(t *testing.T) {
s := sonarr.Series{ID: 1, Title: "S", Year: 2008, Monitored: true,
Statistics: sonarr.Statistics{EpisodeFileCount: 3, EpisodeCount: 10}}
it := newSeriesItem(s)
if !it.have || !it.missing || !strings.Contains(it.status, "3/10") || it.label != "S (2008)" {
t.Errorf("item = %+v", it)
}
s.Statistics.EpisodeFileCount = 10
if it := newSeriesItem(s); it.missing || !strings.Contains(it.status, "green") {
t.Errorf("complete series: %+v", it)
}
}
func TestFilterItems(t *testing.T) {
items := []libItem{
{id: 1, title: "Zulu", monitored: true, missing: true},
{id: 2, title: "alpha", monitored: true, have: true},
{id: 3, title: "Beta"},
}
ids := func(f int, text string) string {
var s string
for _, it := range filterItems(items, f, text, attrFilter{}) {
s += itoa(it.id)
}
return s
}
for _, tc := range []struct {
filter int
text string
want string
}{
{0, "", "231"}, // sorted case-insensitively by title
{1, "", "1"},
{2, "", "2"},
{3, "", "21"},
{4, "", "3"},
{0, " ALP ", "2"},
{1, "alpha", ""},
} {
if got := ids(tc.filter, tc.text); got != tc.want {
t.Errorf("filter %d %q = %q, want %q", tc.filter, tc.text, got, tc.want)
}
}
}
func TestEpisodeStatus(t *testing.T) {
const today = "2026-06-01"
for _, tc := range []struct {
e sonarr.Episode
want string
}{
{sonarr.Episode{HasFile: true}, "downloaded"},
{sonarr.Episode{AirDate: "2027-01-01", Monitored: true}, "unaired"},
{sonarr.Episode{Monitored: true}, "unaired"},
{sonarr.Episode{AirDate: "2020-01-01"}, "unmonitored"},
{sonarr.Episode{AirDate: "2020-01-01", Monitored: true}, "missing"},
} {
if got := episodeStatus(tc.e, today); !strings.Contains(got, tc.want) {
t.Errorf("%+v = %q, want %q", tc.e, got, tc.want)
}
}
}
func TestAttrFilter(t *testing.T) {
uhd := arr.FileAttrs{Resolution: 2160, Codec: arr.CodecX265, HDR: true, DV: true, Languages: []string{"English", "German"}}
hd := arr.FileAttrs{Resolution: 1080, Codec: arr.CodecX264, Languages: []string{"French"}}
sd := arr.FileAttrs{Resolution: 480}
for _, tc := range []struct {
name string
f attrFilter
files []arr.FileAttrs
want bool
}{
{"inactive matches any file", attrFilter{}, []arr.FileAttrs{hd}, true},
{"no files never matches an active filter", attrFilter{res: "1080p"}, nil, false},
{"resolution", attrFilter{res: "1080p"}, []arr.FileAttrs{hd}, true},
{"resolution mismatch", attrFilter{res: "2160p"}, []arr.FileAttrs{hd}, false},
{"SD", attrFilter{res: "SD"}, []arr.FileAttrs{sd}, true},
{"SD excludes HD", attrFilter{res: "SD"}, []arr.FileAttrs{hd}, false},
{"codec", attrFilter{codec: "x265"}, []arr.FileAttrs{uhd}, true},
{"hdr includes DV+HDR", attrFilter{hdr: "HDR"}, []arr.FileAttrs{uhd}, true},
{"dv", attrFilter{hdr: "DV"}, []arr.FileAttrs{hd}, false},
{"sdr", attrFilter{hdr: "SDR"}, []arr.FileAttrs{hd}, true},
{"sdr excludes hdr", attrFilter{hdr: "SDR"}, []arr.FileAttrs{uhd}, false},
{"audio", attrFilter{audio: "Atmos"}, []arr.FileAttrs{{Audio: []string{"Atmos", "TrueHD"}}}, true},
{"audio mismatch", attrFilter{audio: "DTS"}, []arr.FileAttrs{{Audio: []string{"DD+"}}}, false},
{"language is case-insensitive", attrFilter{lang: "german"}, []arr.FileAttrs{uhd}, true},
{"all criteria must match one file", attrFilter{res: "1080p", codec: "x265"}, []arr.FileAttrs{hd, uhd}, false},
{"any file of a series", attrFilter{res: "1080p", codec: "x264"}, []arr.FileAttrs{uhd, hd}, true},
} {
if got := tc.f.match(tc.files); got != tc.want {
t.Errorf("%s: match = %v, want %v", tc.name, got, tc.want)
}
}
if s := (attrFilter{res: "1080p", hdr: "DV"}).String(); s != "1080p DV" {
t.Errorf("String = %q", s)
}
}
func TestFilterItemsByAttrs(t *testing.T) {
items := []libItem{
{id: 1, title: "A", attrs: []arr.FileAttrs{{Resolution: 1080, Codec: arr.CodecX265}}, attrsKnown: true},
{id: 2, title: "B", attrs: []arr.FileAttrs{{Resolution: 1080, Codec: arr.CodecX264}}, attrsKnown: true},
{id: 3, title: "C"}, // series whose files were not read yet
}
got := filterItems(items, 0, "", attrFilter{codec: arr.CodecX265})
if len(got) != 1 || got[0].id != 1 {
t.Errorf("got %+v", got)
}
if got := filterItems(items, 0, "", attrFilter{}); len(got) != 3 {
t.Errorf("without filter: %d items", len(got))
}
}
func TestMovieItemAttrs(t *testing.T) {
m := radarr.Movie{ID: 1, Title: "M", HasFile: true, MovieFile: &arr.MediaFile{SceneName: "M.2160p.DV.x265-G"}}
it := newMovieItem(m)
if !it.attrsKnown || len(it.attrs) != 1 || !it.attrs[0].DV || it.attrs[0].Codec != arr.CodecX265 {
t.Errorf("attrs = %+v", it.attrs)
}
if it := newMovieItem(radarr.Movie{ID: 2, Title: "N"}); !it.attrsKnown || len(it.attrs) != 0 {
t.Errorf("movie without file: %+v", it)
}
if it := newSeriesItem(sonarr.Series{ID: 3, Title: "S", Statistics: sonarr.Statistics{EpisodeFileCount: 2}}); it.attrsKnown {
t.Error("series with files must fetch its attributes")
}
}
func TestListedValues(t *testing.T) {
uhd := arr.FileAttrs{Resolution: 2160, Codec: arr.CodecX265, HDR: true, Audio: []string{"Atmos"}, Languages: []string{"English"}}
hd := arr.FileAttrs{Resolution: 1080, Codec: arr.CodecX264, Audio: []string{"DD"}, Languages: []string{"German"}}
pool := filterPool{files: [][]arr.FileAttrs{{uhd}, {hd}, nil}}
byLabel := func(label string) facet {
for _, f := range facetDefs {
if f.label == label {
return f
}
}
t.Fatalf("no facet %q", label)
return facet{}
}
list := func(label string, state attrFilter, p filterPool) []string {
return listedValues(byLabel(label), state, p)
}
// Without other criteria only options present in the pool are offered.
if got := list("Quality", attrFilter{}, pool); !slices.Equal(got, []string{"", "2160p", "1080p"}) {
t.Errorf("quality = %v", got)
}
if got := list("Audio", attrFilter{}, pool); !slices.Equal(got, []string{"", "Atmos", "DD"}) {
t.Errorf("audio = %v", got)
}
if got := list("Language", attrFilter{}, pool); !slices.Equal(got, []string{"", "English", "German"}) {
t.Errorf("language = %v", got)
}
// Other criteria narrow the options: nothing in 2160p is x264.
if got := list("Codec", attrFilter{res: "2160p"}, pool); !slices.Equal(got, []string{"", "x265"}) {
t.Errorf("codec given 2160p = %v", got)
}
// A facet is not narrowed by its own choice.
if got := list("Quality", attrFilter{res: "2160p"}, pool); !slices.Equal(got, []string{"", "2160p", "1080p"}) {
t.Errorf("quality given 2160p = %v", got)
}
// The current choice stays listed even when it matches nothing.
if got := list("Audio", attrFilter{res: "1080p", audio: "Atmos"}, pool); !slices.Equal(got, []string{"", "Atmos", "DD"}) {
t.Errorf("audio = %v", got)
}
if got := list("Language", attrFilter{lang: "Swahili"}, pool); got[1] != "Swahili" {
t.Errorf("a language not in the pool must stay listed: %v", got)
}
// While files are unknown nothing is ruled out.
if got := list("Quality", attrFilter{}, filterPool{unknown: true}); len(got) != 1+len(resOptions) {
t.Errorf("unknown pool: %v", got)
}
// An empty pool offers nothing but "any".
if got := list("Codec", attrFilter{}, filterPool{}); !slices.Equal(got, []string{""}) {
t.Errorf("empty pool: %v", got)
}
}
func TestRemovingATitleKeepsThePlace(t *testing.T) {
a := newTestApp(t)
v := newLibraryView(a, a.radarr, a.sonarr)
p := v.panes[0]
items := func(ids ...int) []libItem {
var out []libItem
for _, id := range ids {
out = append(out, libItem{id: id, title: string(rune('A' + id)), label: string(rune('A' + id))})
}
return out
}
selectedID := func() int {
r, _ := p.table.GetSelection()
return p.shown[r-1].id
}
p.all, p.loaded = items(1, 2, 3, 4, 5), true
v.renderPane(p)
// Remove the third title: the fourth moves up into its place and is selected.
p.table.Select(3, 0)
p.all = items(1, 2, 4, 5)
v.renderPane(p)
if r, _ := p.table.GetSelection(); r != 3 || selectedID() != 4 {
t.Errorf("after removing the third: row %d, title %d", r, selectedID())
}
// Remove the last title: the new last one is selected.
p.table.Select(4, 0)
p.all = items(1, 2, 4)
v.renderPane(p)
if r, _ := p.table.GetSelection(); r != 3 || selectedID() != 4 {
t.Errorf("after removing the last: row %d, title %d", r, selectedID())
}
// A reload that keeps the title keeps it selected, wherever it is now.
p.table.Select(2, 0) // title 2
p.all = items(0, 1, 2, 4)
v.renderPane(p)
if selectedID() != 2 {
t.Errorf("kept title not followed: %d", selectedID())
}
// A title that is only filtered out still starts the list from the top.
p.table.Select(4, 0) // title 4
p.text = "B" // title 1 only: 4 is filtered out, not removed
v.renderPane(p)
if r, _ := p.table.GetSelection(); r != 1 {
t.Errorf("filtered out: row %d, want the top", r)
}
}
func TestLongTitlesAreCutToTheList(t *testing.T) {
sim := tcell.NewSimulationScreen("")
if err := sim.Init(); err != nil {
t.Fatal(err)
}
defer sim.Fini()
sim.SetSize(40, 5)
tbl := tview.NewTable().SetSelectable(true, false).SetFixed(1, 0)
header(tbl, 0, "Title", "Status", "Size")
title := titleCell{prefix: monMark(true) + " ", title: "Neon Genesis Evangelion: The End of Evangelion (1997)"}
tbl.SetCell(1, 0, tview.NewTableCell(title.fit(0)).SetExpansion(1).SetReference(title))
tbl.SetCell(1, 1, tview.NewTableCell("missing"))
tbl.SetCell(1, 2, tview.NewTableCell("12.3 GB").SetAlign(tview.AlignRight))
tbl.SetRect(0, 0, 40, 5)
fitTitles{tbl}.Draw(sim)
row := rowText(sim, 1, 0, 40)
if !strings.HasSuffix(strings.TrimRight(row, " "), "12.3 GB") || !strings.Contains(row, "missing") {
t.Errorf("status and size must stay in view: %q", row)
}
if !strings.Contains(row, "…") {
t.Errorf("the cut title should end in an ellipsis: %q", row)
}
if !strings.HasPrefix(row, "● Neon Genesis") {
t.Errorf("the title should start at the left edge: %q", row)
}
}
func TestCutToWidth(t *testing.T) {
for _, tc := range []struct {
in string
w int
want string
}{
{"Dune (2021)", 20, "Dune (2021)"},
{"Dune (2021)", 11, "Dune (2021)"},
{"Dune (2021)", 8, "Dune (2…"},
{"千と千尋の神隠し", 7, "千と千…"}, // wide characters count twice
{"[Rec] (2007)", 6, "[Rec]…"}, // brackets are text, not color tags
{"Dune", 0, ""},
} {
if got := cutToWidth(tc.in, tc.w); got != tc.want {
t.Errorf("cutToWidth(%q, %d) = %q, want %q", tc.in, tc.w, got, tc.want)
}
}
}