package tui
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/color"
"image/jpeg"
"image/png"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"charm.land/bubbletea/v2"
)
func makeImage(t *testing.T, path string, solid bool) {
t.Helper()
const n = 300
img := image.NewRGBA(image.Rect(0, 0, n, n))
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
if solid {
img.Set(x, y, color.RGBA{0x2b, 0x2b, 0x2b, 0xff})
continue
}
img.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 0x80, 0xff})
}
}
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if filepath.Ext(path) == ".png" {
if err := png.Encode(f, img); err != nil {
t.Fatal(err)
}
return
}
if err := jpeg.Encode(f, img, nil); err != nil {
t.Fatal(err)
}
}
func countGlyphs(lines []string) int {
n := 0
for _, l := range lines {
for _, r := range stripANSI(l) {
if r != ' ' {
n++
}
}
}
return n
}
func TestPreviewerDrawsSolidCovers(t *testing.T) {
p := NewImagePreviewer()
if !p.Available() {
t.Skip("chafa is not installed")
}
dir := t.TempDir()
solid := filepath.Join(dir, "solid.png")
makeImage(t, solid, true)
lines := p.Render(solid, 40, 20)
if len(lines) == 0 {
t.Fatalf("a solid cover produced no preview at all (file=%s)", solid)
}
if n := countGlyphs(lines); n < 20 {
t.Errorf("a solid cover rendered only %d glyphs; it must show as blocks", n)
}
detailed := filepath.Join(dir, "detail.jpg")
makeImage(t, detailed, false)
lines = p.Render(detailed, 40, 20)
if n := countGlyphs(lines); n < 20 {
t.Errorf("a detailed cover rendered only %d glyphs", n)
}
}
func TestPreviewerNeverExceedsItsBox(t *testing.T) {
p := NewImagePreviewer()
if !p.Available() {
t.Skip("chafa is not installed")
}
dir := t.TempDir()
img := filepath.Join(dir, "x.png")
makeImage(t, img, false)
for _, box := range []struct{ w, h int }{{20, 10}, {40, 20}, {8, 4}, {4, 2}} {
lines := p.Render(img, box.w, box.h)
if len(lines) > box.h {
t.Errorf("box %dx%d: %d lines returned", box.w, box.h, len(lines))
}
for _, l := range lines {
if displayWidth(l) > box.w {
t.Errorf("box %dx%d: line is %d cells", box.w, box.h, displayWidth(l))
}
}
}
}
func TestPreviewerHandlesMissingImage(t *testing.T) {
p := NewImagePreviewer()
if lines := p.Render(filepath.Join(t.TempDir(), "nope.png"), 40, 20); len(lines) != 0 {
t.Error("a missing image should produce no preview, not an error")
}
if lines := p.Render("", 40, 20); lines != nil {
t.Error("an empty path should produce no preview")
}
}
func TestChafaIsOptional(t *testing.T) {
p := &ImagePreviewer{cache: map[string][]string{}}
if p.Available() {
t.Skip("this test only means something without a renderer")
}
if lines := p.Render("/etc/hostname", 40, 20); lines != nil {
t.Error("without a renderer, Render must return nothing")
}
}
var _ = exec.Command
// stagingDir returns the directory the previewer actually stages into, so the
// tests exercise the real path rather than one they made up.
func stagingDir(t *testing.T) string {
t.Helper()
p := &ImagePreviewer{}
dir := p.stageDir()
if dir == "" {
t.Fatal("could not create a staging directory")
}
if !strings.Contains(dir, "tty-graphics-protocol") {
t.Fatalf("staging directory %q lacks the marker the protocol requires", dir)
}
t.Cleanup(p.Close)
return dir
}
func TestStagingConvertsJPEGToPNG(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "cover.jpg")
writeJPEG(t, src, 64)
staged := stageTempImage(stagingDir(t), src)
if staged == "" {
t.Fatal("staging a JPEG failed; the cover would not be shown")
}
defer os.RemoveAll(filepath.Dir(staged))
data, err := os.ReadFile(staged)
if err != nil {
t.Fatal(err)
}
if !bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")) {
t.Fatalf("staged file is not PNG; first bytes %q", data[:min(8, len(data))])
}
if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err != nil {
t.Fatalf("staged file does not decode: %v", err)
}
if !bytes.Contains([]byte(staged), []byte("tty-graphics-protocol")) {
t.Errorf("staged path %q lacks the marker the protocol requires", staged)
}
}
func TestStagingLeavesPNGAlone(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "cover.png")
writePNGFile(t, src, 64)
before, err := os.ReadFile(src)
if err != nil {
t.Fatal(err)
}
staged := stageTempImage(stagingDir(t), src)
if staged == "" {
t.Fatal("staging a PNG failed")
}
defer os.RemoveAll(filepath.Dir(staged))
after, err := os.ReadFile(staged)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(before, after) {
t.Error("a PNG was recompressed instead of copied")
}
}
func TestStagingRefusesWhatItCannotConvert(t *testing.T) {
dir := t.TempDir()
junk := filepath.Join(dir, "cover.jpg")
if err := os.WriteFile(junk, []byte("this is not an image"), 0o600); err != nil {
t.Fatal(err)
}
if got := stageTempImage(stagingDir(t), junk); got != "" {
t.Errorf("staging junk returned %q, want empty", got)
}
if got := stageTempImage(stagingDir(t), filepath.Join(dir, "missing.png")); got != "" {
t.Errorf("staging a missing file returned %q, want empty", got)
}
}
func TestJPEGPreviewQueuesAUsablePlacement(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "cover.jpg")
writeJPEG(t, src, 64)
prev := &ImagePreviewer{mode: imageKitty}
lines := prev.Render(src, 40, 20)
if len(lines) == 0 {
t.Fatal("a JPEG produced no preview")
}
place, ok := prev.TakePlacement()
if !ok {
t.Fatal("a JPEG queued no placement")
}
if place.tempPath == "" {
t.Fatal("the placement has no staged file")
}
if place.pixelW != 64 || place.pixelH != 64 {
t.Errorf("pixels = %dx%d, want 64x64", place.pixelW, place.pixelH)
}
seq := place.transmit()
if !bytes.Contains([]byte(seq), []byte("f=100")) {
t.Error("transmission does not declare PNG")
}
}
func writeJPEG(t *testing.T, path string, n int) {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, n, n))
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
img.Set(x, y, color.RGBA{uint8(200 - x), uint8(120 + y%100), 0x30, 0xff})
}
}
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := jpeg.Encode(f, img, nil); err != nil {
t.Fatal(err)
}
}
func writePNGFile(t *testing.T, path string, n int) {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, n, n))
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
img.Set(x, y, color.RGBA{0x11, 0x22, 0x33, 0xff})
}
}
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
t.Fatal(err)
}
}
func TestFitPixelsKeepsAspect(t *testing.T) {
cases := []struct{ w, h, max, wantW, wantH int }{
{2000, 2000, 1024, 1024, 1024},
{2000, 1000, 1024, 1024, 512},
{1000, 2000, 1024, 512, 1024},
{800, 600, 1024, 800, 600},
{3000, 100, 1024, 1024, 34},
{100, 3000, 1024, 34, 1024},
}
for _, c := range cases {
w, h := fitPixels(c.w, c.h, c.max)
if w != c.wantW || h != c.wantH {
t.Errorf("fitPixels(%d,%d,%d) = %d,%d, want %d,%d", c.w, c.h, c.max, w, h, c.wantW, c.wantH)
}
if w < 1 || h < 1 {
t.Errorf("fitPixels(%d,%d,%d) produced a zero dimension", c.w, c.h, c.max)
}
}
}
func TestScaleDownHonoursBothDimensions(t *testing.T) {
src := image.NewRGBA(image.Rect(0, 0, 2000, 1000))
dw, dh := fitPixels(2000, 1000, maxPreviewPixels)
if dw != 1024 || dh != 512 {
t.Fatalf("target = %dx%d, want 1024x512", dw, dh)
}
out := scaleDown(src, dw, dh)
b := out.Bounds()
if b.Dx() != 1024 || b.Dy() != 512 {
t.Errorf("scaled to %dx%d, want 1024x512", b.Dx(), b.Dy())
}
}
func TestScaleDownAverages(t *testing.T) {
src := image.NewRGBA(image.Rect(0, 0, 2, 2))
src.SetRGBA(0, 0, color.RGBA{255, 255, 255, 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{255, 255, 255, 255})
out := scaleDown(src, 1, 1)
r, g, b, _ := out.At(0, 0).RGBA()
if r>>8 < 120 || r>>8 > 135 {
t.Errorf("average = %d, want about 127", r>>8)
}
_ = g
_ = b
}
func TestPNGExceedsPreviewReadsTheHeader(t *testing.T) {
var small [32]byte
copy(small[12:16], "IHDR")
small[19] = 100
small[23] = 50
if pngExceedsPreview(small[:]) {
t.Error("a 100x50 header should not be oversized")
}
var big [32]byte
copy(big[12:16], "IHDR")
big[16], big[18] = 0x08, 0x00
if !pngExceedsPreview(big[:]) {
t.Error("a 2048-wide header should be oversized")
}
if pngExceedsPreview([]byte("not a png at all")) {
t.Error("nonsense should not be reported as oversized")
}
}
func TestPlaceCoverEmitsABindableTransmission(t *testing.T) {
prev := &ImagePreviewer{mode: imageKitty}
m := Module{prev: prev}
path := writeTempCover(t)
lines := prev.Render(path, 40, 12)
if len(lines) == 0 {
t.Fatal("the previewer produced no grid")
}
cmd := m.placeCover()
if cmd == nil {
t.Fatal("placeCover produced no command")
}
raw, ok := cmd().(tea.RawMsg)
if !ok {
t.Fatalf("expected a raw message, got %T", cmd())
}
seq := fmt.Sprint(raw.Msg)
for _, want := range []string{"a=T", "t=f", "f=100", "U=1", "q=2", "i=7800", "p=7800"} {
if !strings.Contains(seq, want) {
t.Errorf("transmission is missing %q: %s", want, seq)
}
}
payload := seq[strings.Index(seq, ";")+1 : strings.LastIndex(seq, st)]
decoded, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
t.Fatalf("payload is not valid base64: %v", err)
}
if !strings.Contains(string(decoded), "tty-graphics-protocol") {
t.Errorf("staged path %q is not one the terminal will read", decoded)
}
}
func writeTempCover(t *testing.T) string {
t.Helper()
dir := t.TempDir()
path := dir + "/cover.png"
writeSolidPNG(t, path, 120)
return path
}
// Every previewed cover used to get its own temporary directory and nothing ever
// removed it: kitty does not delete the file for the file medium, so one
// directory per image accumulated until /tmp was cleared by hand.
func TestStagingReusesOneDirectoryAndCleansUp(t *testing.T) {
srcs := t.TempDir()
p := &ImagePreviewer{}
dirs := map[string]bool{}
for i := 0; i < 5; i++ {
src := filepath.Join(srcs, fmt.Sprintf("cover%d.jpg", i))
writeJPEG(t, src, 32)
staged := stageTempImage(p.stageDir(), src)
if staged == "" {
t.Fatalf("staging image %d failed", i)
}
dirs[filepath.Dir(staged)] = true
}
if len(dirs) != 1 {
t.Errorf("five images staged into %d directories, want 1", len(dirs))
}
dir := p.stageDir()
p.Close()
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Errorf("Close left %s in place", dir)
}
// Closing twice must not panic; the program can exit by more than one path.
p.Close()
}
// Staging decodes, rescales and re-encodes, which is slow enough to be felt when
// it happens on the UI goroutine as the cursor moves. Warming prepares the whole
// candidate list in the background instead.
func TestWarmStagesCandidatesForLater(t *testing.T) {
srcs := t.TempDir()
var paths []string
for i := 0; i < 3; i++ {
p := filepath.Join(srcs, fmt.Sprintf("cand%d.jpg", i))
writeJPEG(t, p, 1000)
paths = append(paths, p)
}
prev := &ImagePreviewer{mode: imageKitty}
// Calibrate against this machine: staging one of these takes this long, and
// a Warm that did the work itself would take at least three times as long.
start := time.Now()
stageTempImage(prev.stageDir(), paths[0])
one := time.Since(start)
start = time.Now()
prev.Warm(paths)
elapsed := time.Since(start)
if one > 0 && elapsed > one {
t.Errorf("Warm took %v, as long as staging a single image (%v); it is not running in the background", elapsed, one)
}
// It still has to finish the job on its own.
deadline := time.Now().Add(20 * time.Second)
for {
prev.mu.Lock()
done := 0
for _, p := range paths {
if prev.staged[p] != "" {
done++
}
}
prev.mu.Unlock()
if done == len(paths) {
break
}
if time.Now().After(deadline) {
t.Fatalf("only %d of %d candidates were staged", done, len(paths))
}
time.Sleep(5 * time.Millisecond)
}
// And a render then finds the image waiting rather than staging it again.
prev.mu.Lock()
warmed := prev.staged[paths[1]]
prev.mu.Unlock()
if lines := prev.Render(paths[1], 40, 20); len(lines) == 0 {
t.Fatal("no grid for a warmed image")
}
place, ok := prev.TakePlacement()
if !ok {
t.Fatal("no placement queued")
}
if place.tempPath != warmed {
t.Errorf("render staged %q instead of using the warmed %q", place.tempPath, warmed)
}
}
// With images drawn as characters there is nothing to stage, so warming is a
// no-op rather than an error.
func TestWarmDoesNothingForCharactersOrNone(t *testing.T) {
srcs := t.TempDir()
p := filepath.Join(srcs, "c.jpg")
writeJPEG(t, p, 64)
for _, mode := range []imageMode{imageChars, imageNone} {
prev := &ImagePreviewer{mode: mode}
prev.Warm([]string{p})
prev.mu.Lock()
n := len(prev.staged)
prev.mu.Unlock()
if n != 0 {
t.Errorf("mode %v staged %d images", mode, n)
}
}
}