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) } } }