Files
arr-tui/internal/ui/cover_test.go
T

247 lines
8.1 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"
"image"
"image/color"
"image/jpeg"
"image/png"
"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())
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()); 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>")); 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()); 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); c != 13 || r != 10 {
t.Errorf("tall limit: %dx%d", c, r)
}
// In a narrow box the width limits it.
if c, r := coverCells(40, 60, 10, 20); c != 10 || r != 7 {
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)
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)
fetches := 0
var buf bytes.Buffer
png.Encode(&buf, stripes(10, 20))
ref := coverRef{key: "k", fetch: func(context.Context) ([]byte, error) {
fetches++
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 fetches > 1 {
t.Errorf("fetched %d times", fetches)
}
}
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 TestCoverStyleFor(t *testing.T) {
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"},
} {
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)
}
}
}