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