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>
199 lines
6.9 KiB
Go
199 lines
6.9 KiB
Go
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")
|
||
}
|
||
}
|