Files
arr-tui/internal/ui/detail_test.go
T
nilsandClaude Opus 5.5 12e825cff0 Details pane: director and cast from Trakt
With a Trakt client ID, the details of a movie or series show the
director(s) or creators and up to 8 cast members with their roles below
the plot, in every view. The title is found on Trakt by its TMDb/TVDB
(else IMDb) id, then its people are read. Casts are cached and, like
posters, only loaded once the selection rests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:31:33 +02:00

199 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ui
import (
"context"
"image"
"strings"
"testing"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
func rowText(screen tcell.SimulationScreen, y, x0, x1 int) string {
var b strings.Builder
for x := x0; x < x1; x++ {
ch, _, _, _ := screen.GetContent(x, y)
b.WriteRune(ch)
}
return b.String()
}
func TestFlowWrap(t *testing.T) {
text := "one two three four five six seven eight nine ten"
// Narrow (10) for the first two lines, wide (25) afterwards.
got := flowWrap(text, 10, 25, 2)
want := []string{"one two", "three four", "five six seven eight nine", "ten"}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Errorf("got %q, want %q", got, want)
}
// Without a poster everything is wide.
if got := flowWrap(text, 10, 25, 0); got[0] != "one two three four five" {
t.Errorf("no poster: %q", got)
}
// Newlines are kept, blank lines too; trailing blank lines are dropped.
got = flowWrap("head\nfacts\n\nplot words\n", 20, 20, 0)
if strings.Join(got, "|") != "head|facts||plot words" {
t.Errorf("newlines: %q", got)
}
// Color tags have no width and escaped brackets count as one character.
got = flowWrap("[red]ab[-] cd ef", 5, 5, 0)
if strings.Join(got, "|") != "[red]ab[-] cd|ef" {
t.Errorf("tags: %q", got)
}
if got := flowWrap(tview.Escape("[1080p]")+" x", 9, 9, 0); len(got) != 1 {
t.Errorf("escaped brackets: %q", got)
}
// A word wider than the column is not split or lost.
if got := flowWrap("a verylongwordhere b", 5, 5, 0); strings.Join(got, "|") != "a|verylongwordhere|b" {
t.Errorf("long word: %q", got)
}
}
func TestDetailPaneFlowsAroundPoster(t *testing.T) {
a := newTestApp(t)
screen := tcell.NewSimulationScreen("")
if err := screen.Init(); err != nil {
t.Fatal(err)
}
defer screen.Fini()
screen.SetSize(60, 40)
plot := strings.Repeat("plot ", 220) // long enough to run past the poster
d := newDetailPane(a)
d.SetRect(0, 0, 60, 40)
d.Show(detailText{head: "[::b]The Matrix (1999)[::-]\nStatus: released\n", plot: plot}, coverRef{})
// Without a poster the text uses the full width from the top.
d.Draw(screen)
if got := rowText(screen, 1, 1, 30); !strings.HasPrefix(got, "The Matrix (1999)") {
t.Errorf("first line = %q", got)
}
if got := strings.TrimSpace(rowText(screen, 5, 1, 59)); len(got) < 50 {
t.Errorf("without a poster the text should use the full width: %q", got)
}
// With a poster (23×17 cells at the top right) the text runs beside it.
d.hasCover = true
d.cover.setImage(stripes(40, 60))
d.Draw(screen)
if d.coverW != 23 || d.coverH != 17 {
t.Fatalf("poster size %dx%d, want 23x17", d.coverW, d.coverH)
}
if got := rowText(screen, 1, 1, 30); !strings.HasPrefix(got, "The Matrix (1999)") {
t.Errorf("head moved: %q", got)
}
if got := rowText(screen, 4, 1, 30); !strings.HasPrefix(got, "plot plot") {
t.Errorf("the plot starts beside the poster, right after the head: %q", got)
}
// Beside the poster the text stays left of it with a gap of two columns
// (58-23-2 = 33 columns), and the row right under the poster is kept clear too.
for y := 1; y <= d.coverH+coverGapRows; y++ {
if got := strings.TrimRight(rowText(screen, y, 1, 36), " "); len([]rune(got)) > 33 {
t.Errorf("row %d runs into the poster's margin: %q", y, got)
}
}
// Further down it uses the full width.
if got := strings.TrimSpace(rowText(screen, d.coverH+3, 1, 59)); len(got) < 50 {
t.Errorf("below the poster the text should use the full width: %q", got)
}
// The poster sits against the right edge of the pane.
if ch, _, st, _ := screen.GetContent(58, 1); ch == ' ' && st == tcell.StyleDefault {
t.Error("nothing drawn at the poster's right edge")
}
// A pane too short for the poster falls back to plain text.
d.SetRect(0, 0, 60, 10)
d.Draw(screen)
if d.coverW != 0 {
t.Error("a short pane must drop the poster")
}
}
func TestPosterWaitsWhileTheSelectionMoves(t *testing.T) {
a := newTestApp(t)
img := image.NewRGBA(image.Rect(0, 0, 20, 30))
a.coverCache.imgs["one"], a.coverCache.imgs["two"] = img, img
ref := func(key string) coverRef {
return coverRef{key: key, fetch: func(context.Context, bool) ([]byte, error) { return nil, nil }}
}
d := newDetailPane(a)
d.Show(detailText{head: "One"}, ref("one"))
if d.cover.img != img {
t.Fatal("the first selection should show its poster at once")
}
d.Show(detailText{head: "Two"}, ref("two"))
if d.cover.img != nil || !d.hasCover {
t.Errorf("a quick move should keep the poster's place empty: img %v, hasCover %v", d.cover.img != nil, d.hasCover)
}
d.settle.Stop() // the test has no event loop to deliver it
// The same title again (a refresh) is not a move.
d.lastShow = time.Now().Add(-time.Second)
d.Show(detailText{head: "Two"}, ref("two"))
if d.cover.img != img {
t.Error("a selection at rest should show its poster")
}
}
func TestCastBelowThePlot(t *testing.T) {
a := newTestApp(t)
d := newDetailPane(a)
dune := trakt.Ref{Kind: trakt.Movies, TMDB: 438631}
heat := trakt.Ref{Kind: trakt.Movies, TMDB: 949}
a.people.done[dune.Key()] = trakt.People{
Leaders: []trakt.Person{{Name: "Denis Villeneuve", Role: "Director"}},
Cast: []trakt.Person{{Name: "Timothée Chalamet", Role: "Paul Atreides"}, {Name: "Rebecca Ferguson", Role: "Lady Jessica"}},
}
a.people.done[heat.Key()] = trakt.People{}
// Without a Trakt client ID nothing is asked or shown.
d.Show(detailText{head: "Dune", people: dune}, coverRef{})
if d.cast != "" {
t.Fatalf("cast without Trakt: %q", d.cast)
}
a.trakt.Reconfigure("id")
d.castKey, d.castShown = "", time.Time{}
d.Show(detailText{head: "Dune", plot: "Spice.", people: dune}, coverRef{})
if !strings.Contains(d.cast, "Director[::-] Denis Villeneuve") || !strings.Contains(d.cast, "Timothée Chalamet [gray]as Paul Atreides") {
t.Errorf("cast %q", d.cast)
}
screen := tcell.NewSimulationScreen("")
if err := screen.Init(); err != nil {
t.Fatal(err)
}
defer screen.Fini()
screen.SetSize(60, 20)
d.SetRect(0, 0, 60, 20)
d.Draw(screen)
if got := d.text.GetText(true); !strings.Contains(got, "Spice.\n\nDirector Denis Villeneuve\nCast\nTimothée Chalamet as Paul Atreides") {
t.Errorf("pane text %q", got)
}
// A quick move to the next title waits with its cast until the selection rests.
d.Show(detailText{head: "Heat", people: heat}, coverRef{})
if d.cast != "" || d.castSettle == nil {
t.Errorf("moving: cast %q", d.cast)
}
d.castSettle.Stop()
}
func TestPeopleText(t *testing.T) {
var cast []trakt.Person
for i := 0; i < 10; i++ {
cast = append(cast, trakt.Person{Name: "Actor " + string(rune('A'+i))})
}
got := peopleText(trakt.People{Cast: cast, Leaders: []trakt.Person{{Name: "Vince [Gilligan]"}}}, trakt.Shows)
if !strings.HasPrefix(got, "[::b]Created by[::-] Vince [Gilligan[]") || !strings.HasSuffix(got, "[gray]and 2 more[-]") || strings.Contains(got, "Actor I") {
t.Errorf("text %q", got)
}
if peopleText(trakt.People{}, trakt.Movies) != "" {
t.Error("no people, no text")
}
}