The poster box followed the image's shape, so images Radarr/Sonarr hand out in another shape came out smaller than the others in a list. The box is now always 2:3: an image within 12% of that fills it, trimmed at the long sides (block characters too, which were stretched before); one further off is shown whole, centred in the box. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
406 lines
14 KiB
Go
406 lines
14 KiB
Go
package ui
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"image"
|
||
"image/color"
|
||
"image/gif"
|
||
"image/jpeg"
|
||
"image/png"
|
||
"strings"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/gdamore/tcell/v2"
|
||
)
|
||
|
||
// stripes returns a w×h image whose top half is red and bottom half blue.
|
||
func stripes(w, h int) *image.RGBA {
|
||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||
for y := 0; y < h; y++ {
|
||
for x := 0; x < w; x++ {
|
||
c := color.RGBA{255, 0, 0, 255}
|
||
if y >= h/2 {
|
||
c = color.RGBA{0, 0, 255, 255}
|
||
}
|
||
img.SetRGBA(x, y, c)
|
||
}
|
||
}
|
||
return img
|
||
}
|
||
|
||
func TestBoxScaleAverages(t *testing.T) {
|
||
src := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||
src.SetRGBA(0, 0, color.RGBA{200, 0, 0, 255})
|
||
src.SetRGBA(1, 0, color.RGBA{0, 0, 0, 255})
|
||
src.SetRGBA(0, 1, color.RGBA{0, 0, 0, 255})
|
||
src.SetRGBA(1, 1, color.RGBA{0, 0, 0, 255})
|
||
got := boxScale(src, 1, 1).RGBAAt(0, 0)
|
||
if got.R < 49 || got.R > 51 || got.G != 0 || got.A != 255 {
|
||
t.Errorf("average = %+v, want R≈50", got)
|
||
}
|
||
// Upscaling must not leave holes.
|
||
up := boxScale(stripes(1, 2), 3, 4)
|
||
if up.RGBAAt(2, 0).R != 255 || up.RGBAAt(2, 3).B != 255 {
|
||
t.Errorf("upscaled corners: %+v %+v", up.RGBAAt(2, 0), up.RGBAAt(2, 3))
|
||
}
|
||
}
|
||
|
||
func TestDecodeCover(t *testing.T) {
|
||
var buf bytes.Buffer
|
||
if err := jpeg.Encode(&buf, stripes(300, 450), nil); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
img, err := decodeCover(buf.Bytes(), storedCoverHeight)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if b := img.Bounds(); b.Dy() != storedCoverHeight || b.Dx() != 106 {
|
||
t.Errorf("stored size %v, want 106x%d", b, storedCoverHeight)
|
||
}
|
||
|
||
buf.Reset()
|
||
if err := png.Encode(&buf, stripes(20, 30)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if img, err := decodeCover(buf.Bytes(), storedCoverHeight); err != nil || img.Bounds().Dy() != 30 {
|
||
t.Errorf("small png kept as is: %v %v", img, err)
|
||
}
|
||
|
||
if _, err := decodeCover([]byte("<html>not an image</html>"), storedCoverHeight); err == nil {
|
||
t.Error("garbage must be rejected")
|
||
}
|
||
// A header that promises a huge image is refused before decoding it.
|
||
var huge bytes.Buffer
|
||
huge.Write([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x27\x10\x00\x00\x27\x10\x08\x02\x00\x00\x00"))
|
||
if _, err := decodeCover(huge.Bytes(), storedCoverHeight); err == nil {
|
||
t.Error("oversized image must be rejected")
|
||
}
|
||
}
|
||
|
||
func TestQuadCell(t *testing.T) {
|
||
red, blue, black := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}, color.RGBA{0, 0, 0, 255}
|
||
for _, tc := range []struct {
|
||
name string
|
||
px [4]color.RGBA
|
||
wantCh rune
|
||
wantFg color.RGBA
|
||
wantBg color.RGBA
|
||
sameBoth bool
|
||
}{
|
||
{"flat", [4]color.RGBA{red, red, red, red}, ' ', red, red, true},
|
||
{"top half", [4]color.RGBA{red, red, blue, blue}, '▀', red, blue, false},
|
||
{"bottom half", [4]color.RGBA{blue, blue, red, red}, '▀', blue, red, false}, // same picture, canonical form
|
||
{"left half", [4]color.RGBA{red, blue, red, blue}, '▌', red, blue, false},
|
||
{"one corner", [4]color.RGBA{red, blue, blue, blue}, '▘', red, blue, false},
|
||
{"diagonal", [4]color.RGBA{red, black, black, red}, '▞', black, red, false},
|
||
} {
|
||
ch, fg, bg := quadrantStyle.bestCell(tc.px[:])
|
||
if ch != tc.wantCh || fg != tc.wantFg || bg != tc.wantBg {
|
||
t.Errorf("%s: %q fg=%v bg=%v, want %q fg=%v bg=%v", tc.name, ch, fg, bg, tc.wantCh, tc.wantFg, tc.wantBg)
|
||
}
|
||
}
|
||
// Nearly flat noise stays a flat cell instead of a distracting pattern.
|
||
n := color.RGBA{100, 100, 100, 255}
|
||
m := color.RGBA{102, 100, 101, 255}
|
||
if ch, _, _ := quadrantStyle.bestCell([]color.RGBA{n, m, n, n}); ch != ' ' {
|
||
t.Errorf("noise rendered as %q", ch)
|
||
}
|
||
}
|
||
|
||
func TestCoverCells(t *testing.T) {
|
||
// A 2:3 poster in a roomy box is limited by the height: 10 rows are 20
|
||
// width units tall, so 13 columns.
|
||
if c, r := coverCells(40, 60, 30, 10, 2); c != 13 || r != 10 {
|
||
t.Errorf("tall limit: %dx%d", c, r)
|
||
}
|
||
// In a narrow box the width limits it, and the box keeps the poster's shape
|
||
// (8×6 cells is exactly 2:3) instead of 10×7 which would leave bars.
|
||
if c, r := coverCells(40, 60, 10, 20, 2); c != 8 || r != 6 {
|
||
t.Errorf("narrow limit: %dx%d", c, r)
|
||
}
|
||
}
|
||
|
||
func TestCoverViewDraws(t *testing.T) {
|
||
screen := tcell.NewSimulationScreen("")
|
||
if err := screen.Init(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer screen.Fini()
|
||
screen.SetSize(30, 10)
|
||
|
||
c := newCoverView(quadrantStyle, nil, nil)
|
||
c.setImage(stripes(40, 60)) // red above blue
|
||
c.SetRect(0, 0, 30, 10) // fits 13 columns × 10 rows
|
||
|
||
c.Draw(screen)
|
||
rgba := func(c tcell.Color) color.RGBA {
|
||
r, g, b := c.RGB()
|
||
return color.RGBA{uint8(r), uint8(g), uint8(b), 255}
|
||
}
|
||
cell := func(x, y int) (rune, color.RGBA, color.RGBA) {
|
||
ch, _, st, _ := screen.GetContent(x, y)
|
||
fg, bg, _ := st.Decompose()
|
||
return ch, rgba(fg), rgba(bg)
|
||
}
|
||
red, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}
|
||
// The poster is 13 columns wide and sits against the right edge (x 17..29).
|
||
if _, fg, bg := cell(17, 0); fg != red || bg != red {
|
||
t.Errorf("top row = fg %v bg %v, want red", fg, bg)
|
||
}
|
||
if _, fg, bg := cell(29, 9); fg != blue || bg != blue {
|
||
t.Errorf("bottom right = fg %v bg %v, want blue", fg, bg)
|
||
}
|
||
// Left of the poster's footprint nothing is drawn.
|
||
if ch, _, _, _ := screen.GetContent(16, 0); ch != ' ' {
|
||
t.Errorf("poster stretched beyond its aspect ratio: %q", ch)
|
||
}
|
||
}
|
||
|
||
func TestLoadCoverCachesAndDedupes(t *testing.T) {
|
||
a := newTestApp(t)
|
||
var fetches atomic.Int32
|
||
var buf bytes.Buffer
|
||
png.Encode(&buf, stripes(10, 20))
|
||
ref := coverRef{key: "k", fetch: func(context.Context, bool) ([]byte, error) {
|
||
fetches.Add(1)
|
||
return buf.Bytes(), nil
|
||
}}
|
||
|
||
// Run posted callbacks inline instead of through the (not running) event loop.
|
||
delivered := make(chan error, 2)
|
||
a.loadCover(ref, func(_ *image.RGBA, err error) { delivered <- err })
|
||
a.loadCover(ref, func(_ *image.RGBA, err error) { delivered <- err }) // joins the request in flight
|
||
if len(a.coverCache.waiting["k"]) != 2 {
|
||
t.Fatalf("waiters = %d, want 2", len(a.coverCache.waiting["k"]))
|
||
}
|
||
if n := fetches.Load(); n > 1 {
|
||
t.Errorf("fetched %d times", n)
|
||
}
|
||
}
|
||
|
||
func TestLoadCoverRemembersFailures(t *testing.T) {
|
||
a := newTestApp(t)
|
||
a.coverCache.failed["bad"] = time.Now()
|
||
var gotErr error
|
||
a.loadCover(coverRef{key: "bad", fetch: func(context.Context, bool) ([]byte, error) {
|
||
t.Error("a recent failure must not be retried")
|
||
return nil, nil
|
||
}}, func(_ *image.RGBA, err error) { gotErr = err })
|
||
if gotErr == nil {
|
||
t.Error("expected an error for a recently failed cover")
|
||
}
|
||
}
|
||
|
||
func TestSextantGlyphs(t *testing.T) {
|
||
seen := map[rune]int{}
|
||
for mask := 0; mask < 64; mask++ {
|
||
r := sextantGlyph(mask)
|
||
if prev, dup := seen[r]; dup {
|
||
t.Fatalf("masks %d and %d share the glyph %q", prev, mask, r)
|
||
}
|
||
seen[r] = mask
|
||
if mask > 0 && mask < 63 && mask != 21 && mask != 42 && (r < 0x1FB00 || r > 0x1FB3B) {
|
||
t.Errorf("mask %d: %U outside the sextant block", mask, r)
|
||
}
|
||
}
|
||
for mask, want := range map[int]rune{1: 0x1FB00, 2: 0x1FB01, 20: 0x1FB13, 22: 0x1FB14, 43: 0x1FB28, 62: 0x1FB3B, 21: '▌', 42: '▐', 63: '█', 0: ' '} {
|
||
if got := sextantGlyph(mask); got != want {
|
||
t.Errorf("sextantGlyph(%d) = %U, want %U", mask, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestSextantCell(t *testing.T) {
|
||
red, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}
|
||
// Two rows of red above one row of blue.
|
||
ch, fg, bg := sextantStyle.bestCell([]color.RGBA{red, red, red, red, blue, blue})
|
||
if ch != sextantGlyph(15) || fg != red || bg != blue {
|
||
t.Errorf("got %U fg=%v bg=%v", ch, fg, bg)
|
||
}
|
||
// One row each: red, blue, red.
|
||
ch, fg, bg = sextantStyle.bestCell([]color.RGBA{red, red, blue, blue, red, red})
|
||
if ch != sextantGlyph(0b001100) || fg != blue || bg != red {
|
||
t.Errorf("three bands: %U fg=%v bg=%v", ch, fg, bg)
|
||
}
|
||
}
|
||
|
||
func TestCoverStyleForFontCheck(t *testing.T) {
|
||
defer func(old func() bool) { sextantFontAvailable = old }(sextantFontAvailable)
|
||
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
||
|
||
sextantFontAvailable = func() bool { return true }
|
||
for _, tc := range []struct {
|
||
name string
|
||
env map[string]string
|
||
want string
|
||
}{
|
||
{"unknown terminal with a suitable font", map[string]string{"TERM": "xterm-256color"}, "sextant"},
|
||
{"over ssh the local fonts are unknown", map[string]string{"TERM": "xterm-256color", "SSH_CONNECTION": "1 2 3 4"}, "quadrant"},
|
||
{"over ssh with a tty", map[string]string{"SSH_TTY": "/dev/pts/1"}, "quadrant"},
|
||
} {
|
||
if got := coverStyleFor("auto", env(tc.env)).name; got != tc.want {
|
||
t.Errorf("%s: %s, want %s", tc.name, got, tc.want)
|
||
}
|
||
}
|
||
// An explicit setting never asks fontconfig.
|
||
sextantFontAvailable = func() bool { t.Error("fontconfig consulted for an explicit style"); return true }
|
||
if coverStyleFor("quadrant", env(nil)).name != "quadrant" || coverStyleFor("sextant", env(nil)).name != "sextant" {
|
||
t.Error("explicit styles must win")
|
||
}
|
||
// No suitable font: quadrants.
|
||
sextantFontAvailable = func() bool { return false }
|
||
if got := coverStyleFor("auto", env(map[string]string{"TERM": "xterm-256color"})).name; got != "quadrant" {
|
||
t.Errorf("no font: %s", got)
|
||
}
|
||
}
|
||
|
||
func TestCoverStyleFor(t *testing.T) {
|
||
defer func(old func() bool) { sextantFontAvailable = old }(sextantFontAvailable)
|
||
sextantFontAvailable = func() bool { return false } // the table below is about the terminal checks
|
||
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
||
for _, tc := range []struct {
|
||
setting string
|
||
env map[string]string
|
||
want string
|
||
}{
|
||
{"auto", map[string]string{"TERM": "xterm-kitty"}, "sextant"},
|
||
{"", map[string]string{"TERM_PROGRAM": "WezTerm"}, "sextant"},
|
||
{"auto", map[string]string{"TERM": "foot"}, "sextant"},
|
||
{"auto", map[string]string{"KITTY_WINDOW_ID": "3", "TERM": "screen"}, "sextant"},
|
||
{"auto", map[string]string{"TERM": "xterm-256color"}, "quadrant"},
|
||
{"auto", nil, "quadrant"},
|
||
{"quadrant", map[string]string{"TERM": "xterm-kitty"}, "quadrant"},
|
||
{"sextant", nil, "sextant"},
|
||
{"auto", map[string]string{"TERM": "xterm-256color", "TERM_PROGRAM": "Apple_Terminal"}, "cells"},
|
||
{"cells", map[string]string{"TERM": "xterm-kitty"}, "cells"},
|
||
{"quadrant", map[string]string{"TERM_PROGRAM": "Apple_Terminal"}, "quadrant"},
|
||
} {
|
||
if got := coverStyleFor(tc.setting, env(tc.env)).name; got != tc.want {
|
||
t.Errorf("%q %v = %s, want %s", tc.setting, tc.env, got, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestCoverCellsKeepTheShape(t *testing.T) {
|
||
// For many poster shapes, cell shapes and boxes the chosen box must match
|
||
// the poster within the tolerance (or be the closest available).
|
||
for _, img := range [][2]int{{167, 250}, {500, 750}, {2, 3}, {1000, 1500}, {680, 1000}} {
|
||
for _, aspect := range []float64{1.9, 2, 2.1, 2.25} {
|
||
for w := 12; w <= 40; w += 7 {
|
||
for h := 8; h <= 30; h += 11 {
|
||
c, r := coverCells(img[0], img[1], w, h, aspect)
|
||
if c < 1 || r < 1 || c > w || r > h {
|
||
t.Fatalf("%v aspect %v in %dx%d: %dx%d out of bounds", img, aspect, w, h, c, r)
|
||
}
|
||
shape := float64(img[0]) / float64(img[1])
|
||
err := float64(c)/(float64(r)*aspect)/shape - 1
|
||
if err < 0 {
|
||
err = -err
|
||
}
|
||
if err > 0.10 { // the tolerance is 3%; tiny boxes may only get within a cell
|
||
t.Errorf("%v aspect %v in %dx%d: box %dx%d is %.0f%% off", img, aspect, w, h, c, r, err*100)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestDecodeCoverOtherFormats(t *testing.T) {
|
||
// A WebP stored under a .jpg name, as Sonarr keeps what the provider sends.
|
||
webp, _ := base64.StdEncoding.DecodeString("UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==")
|
||
img, err := decodeCover(webp, storedCoverHeight)
|
||
if err != nil || img.Bounds().Dx() != 1 || img.Bounds().Dy() != 1 {
|
||
t.Fatalf("webp: %v %v", img, err)
|
||
}
|
||
|
||
var buf bytes.Buffer
|
||
pal := image.NewPaletted(image.Rect(0, 0, 20, 30), []color.Color{color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}})
|
||
if err := gif.Encode(&buf, pal, nil); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if img, err := decodeCover(buf.Bytes(), storedCoverHeight); err != nil || img.Bounds().Dy() != 30 {
|
||
t.Errorf("gif: %v %v", img, err)
|
||
}
|
||
}
|
||
|
||
func TestDescribeBytesAndTheErrorItGives(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
data []byte
|
||
want string
|
||
}{
|
||
{"empty", nil, "empty"},
|
||
{"avif", []byte("\x00\x00\x00\x1cftypavif\x00\x00"), "AVIF/HEIC, avif"},
|
||
{"html", []byte(" <html><body>Not Found</body></html>"), "HTML or SVG"},
|
||
{"bmp", []byte("BM\x00\x00\x00\x00"), "BMP"},
|
||
{"tiff", []byte("II*\x00\x08\x00"), "TIFF"},
|
||
{"binary", []byte{1, 2, 3, 4, 5}, "5 bytes starting 01 02 03 04 05"},
|
||
} {
|
||
if got := describeBytes(tc.data); !strings.Contains(got, tc.want) {
|
||
t.Errorf("%s: %q, want it to mention %q", tc.name, got, tc.want)
|
||
}
|
||
_, err := decodeCover(tc.data, storedCoverHeight)
|
||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||
t.Errorf("%s: the error %v should say what the data is (%q)", tc.name, err, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestCellsStyleIsFlat(t *testing.T) {
|
||
c := color.RGBA{10, 200, 30, 255}
|
||
ch, fg, bg := cellsStyle.bestCell([]color.RGBA{c})
|
||
if ch != ' ' || fg != c || bg != c {
|
||
t.Errorf("got %q %v %v", ch, fg, bg)
|
||
}
|
||
}
|
||
|
||
func TestPostersShareTheTwoByThreeBox(t *testing.T) {
|
||
sim := tcell.NewSimulationScreen("")
|
||
if err := sim.Init(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer sim.Fini()
|
||
sim.SetSize(40, 30)
|
||
|
||
// extent returns the columns and rows covered by poster cells (white).
|
||
extent := func(w, h int) (x0, y0, x1, y1 int) {
|
||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||
for i := range img.Pix {
|
||
img.Pix[i] = 255
|
||
}
|
||
sim.Clear()
|
||
c := newCoverView(quadrantStyle, nil, func() float64 { return 2 })
|
||
c.setImage(img)
|
||
c.SetRect(0, 0, 40, 30)
|
||
c.Draw(sim)
|
||
x0, y0, x1, y1 = 99, 99, -1, -1
|
||
for y := 0; y < 30; y++ {
|
||
for x := 0; x < 40; x++ {
|
||
_, _, st, _ := sim.GetContent(x, y)
|
||
if _, bg, _ := st.Decompose(); bg == tcell.NewRGBColor(255, 255, 255) {
|
||
x0, y0, x1, y1 = min(x0, x), min(y0, y), max(x1, x), max(y1, y)
|
||
}
|
||
}
|
||
}
|
||
return
|
||
}
|
||
ax0, ay0, ax1, ay1 := extent(2000, 3000) // the usual shape
|
||
bx0, by0, bx1, by1 := extent(2100, 3000) // 5% wider: trimmed, same box
|
||
if ax0 != bx0 || ay0 != by0 || ax1 != bx1 || ay1 != by1 {
|
||
t.Errorf("2:3 at %d,%d-%d,%d but 0.7 at %d,%d-%d,%d", ax0, ay0, ax1, ay1, bx0, by0, bx1, by1)
|
||
}
|
||
if ay0 != 0 || ax1 != 39 {
|
||
t.Errorf("the box sits at the top right: %d,%d-%d,%d", ax0, ay0, ax1, ay1)
|
||
}
|
||
// A square image is shown whole: as wide as the box, shorter, centred.
|
||
sx0, sy0, sx1, sy1 := extent(1000, 1000)
|
||
if sx0 != ax0 || sx1 != ax1 || sy1-sy0 >= ay1-ay0 || sy0 <= ay0 || (sy0-ay0)-(ay1-sy1) > 1 || (ay1-sy1)-(sy0-ay0) > 1 {
|
||
t.Errorf("square at %d,%d-%d,%d in the box %d,%d-%d,%d", sx0, sy0, sx1, sy1, ax0, ay0, ax1, ay1)
|
||
}
|
||
}
|