Files
2026-10-24 22:30:14 +02:00

412 lines
15 KiB
Go

package ui
import (
"context"
"net/http"
"net/http/httptest"
"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)
}
}
}
func TestSeasonPoster(t *testing.T) {
seasonSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/broken.jpg" {
http.NotFound(w, r)
return
}
w.Write([]byte("SEASON"))
}))
defer seasonSrv.Close()
series := coverRef{key: "series", fetch: func(context.Context, bool) ([]byte, error) { return []byte("SERIES"), nil }}
v := &seriesView{cover: series, series: sonarr.Series{Seasons: []sonarr.Season{
{SeasonNumber: 1, Images: []arr.Image{{CoverType: "poster", RemoteURL: seasonSrv.URL + "/s1.jpg"}}},
{SeasonNumber: 2},
{SeasonNumber: 3, Images: []arr.Image{{CoverType: "poster", RemoteURL: seasonSrv.URL + "/broken.jpg"}}},
}}}
fetch := func(season int) string {
data, err := v.seasonCover(season).fetch(t.Context(), false)
if err != nil {
t.Fatal(err)
}
return string(data)
}
if got := fetch(1); got != "SEASON" || v.seasonCover(1).key == series.key {
t.Errorf("season 1: %q", got)
}
if v.seasonCover(2).key != series.key {
t.Error("a season without a poster shows the series poster")
}
if got := fetch(3); got != "SERIES" {
t.Errorf("a season poster that cannot be loaded falls back to the series poster: %q", got)
}
}
func TestDownloadingTitlesAreMarked(t *testing.T) {
movies := queueByTitle([]arr.QueueItem{
{MovieID: 3, Size: 100, SizeLeft: 55, TimeLeft: "00:06:12"},
{MovieID: 0}, // not matched to a movie
}, false)
series := queueByTitle([]arr.QueueItem{{SeriesID: 7, Size: 10, SizeLeft: 10}, {SeriesID: 7}, {MovieID: 3}}, true)
if len(movies) != 1 || movies[3].count != 1 || movies[3].progress != 45 || movies[3].dlLine() != "[blue]Downloading[-] 45% · 00:06:12 left\n" {
t.Errorf("movies %+v", movies)
}
if len(series) != 1 || series[7].count != 2 || series[7].dlLine() != "[blue]Downloading[-] 2 episodes\n" {
t.Errorf("series %+v", series)
}
a := newTestApp(t)
v := newLibraryView(a, a.radarr, a.sonarr)
p := v.panes[kindMovies]
p.all, p.loaded = []libItem{
{id: 3, title: "Dune", label: "Dune (2021)", monitored: true, details: detailText{head: "Dune\n"}},
{id: 4, title: "Heat", label: "Heat (1995)", monitored: true},
}, true
v.downloading[kindMovies] = movies
v.render()
cell := func(row int) string { return p.table.GetCell(row, 0).Text }
if !strings.HasPrefix(cell(1), "[blue]●[-]") || !strings.HasPrefix(cell(2), "[green]●[-]") {
t.Errorf("marks: %q / %q", cell(1), cell(2))
}
p.table.Select(1, 0)
v.renderDetails()
if !strings.Contains(v.details.detail.head, "Downloading[-] 45%") {
t.Errorf("details %q", v.details.detail.head)
}
if libMark(false, true) != "[blue]○[-]" {
t.Error("an unmonitored title in the queue keeps the hollow mark")
}
}
func TestEpisodesInTheQueue(t *testing.T) {
got := queueByEpisode([]arr.QueueItem{
{SeriesID: 7, EpisodeID: 70, Size: 10, SizeLeft: 5, TimeLeft: "00:01:00"},
{SeriesID: 7, EpisodeID: 71},
{SeriesID: 8, EpisodeID: 80}, // another series
{SeriesID: 7}, // no episode
}, 7)
if len(got) != 2 || got[70].progress != 50 || got[70].timeLeft != "00:01:00" {
t.Fatalf("queueByEpisode: %+v", got)
}
s := sonarr.Series{Title: "Signal Fire"}
ep := &sonarr.Episode{SeasonNumber: 3, EpisodeNumber: 5, Title: "Ember", Monitored: true}
if d := seriesRowDetail(s, ep, 3, seasonSummary{}, true, "2026-01-01", got[70]); !strings.Contains(d.head, "Downloading[-] 50% · 00:01:00 left") {
t.Errorf("episode: %q", d.head)
}
sum := seasonSummary{number: 3, total: 10, have: 4}
if d := seriesRowDetail(s, nil, 3, sum, true, "2026-01-01", dlInfo{count: 1}); !strings.Contains(d.head, "Downloading[-] 1 episode\n") {
t.Errorf("season with one: %q", d.head)
}
if d := seriesRowDetail(s, nil, 3, sum, true, "2026-01-01", dlInfo{count: 2}); !strings.Contains(d.head, "Downloading[-] 2 episodes") {
t.Errorf("season with two: %q", d.head)
}
if d := seriesRowDetail(s, ep, 3, seasonSummary{}, true, "2026-01-01", dlInfo{}); strings.Contains(d.head, "Downloading") {
t.Errorf("not downloading: %q", d.head)
}
}