Files
arr-tui/internal/ui/cover_test.go
T
nilsandClaude Opus 5.5 8f162729ea Cover style "halves": left and right half blocks only
Twice the horizontal detail of "cells", for terminals such as macOS
Terminal whose upper and lower half blocks do not meet at the middle of
the cell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 11:38:04 +02:00

370 lines
13 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 (
"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) ([]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) ([]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"},
{"halves", map[string]string{"TERM_PROGRAM": "Apple_Terminal"}, "halves"},
{"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 TestHalvesStyle(t *testing.T) {
dark, light := color.RGBA{0, 0, 0, 255}, color.RGBA{250, 250, 250, 255}
ch, fg, bg := halvesStyle.bestCell([]color.RGBA{light, dark})
if ch != '▌' || fg != light || bg != dark {
t.Errorf("light left half: %q %v %v", ch, fg, bg)
}
}