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
311 lines
11 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|