git/intake/main
internal/tui/kitty_test.go
337 lines9 KB last change raw · history
package tui

import (
	"encoding/base64"
	"strings"
	"testing"

	"github.com/charmbracelet/colorprofile"
	"github.com/charmbracelet/x/ansi/kitty"
)

func TestPlaceholderGridEncodesRowAndColumn(t *testing.T) {
	const cols, rows = 3, 2
	grid := placeholderGrid(cols, rows, baseImageID)
	if len(grid) != rows {
		t.Fatalf("got %d rows, want %d", len(grid), rows)
	}
	for i, row := range grid {
		runes := []rune(stripSGR(row))
		if len(runes) != cols*4 {
			t.Fatalf("row %d has %d runes, want %d", i, len(runes), cols*4)
		}
		for col := 0; col < cols; col++ {
			cell := runes[col*4 : col*4+4]
			if cell[0] != kitty.Placeholder {
				t.Errorf("row %d col %d: got %#x, want the placeholder rune", i, col, cell[0])
			}
			if cell[1] != kitty.Diacritic(i) {
				t.Errorf("row %d col %d: row mark = %#x, want %#x", i, col, cell[1], kitty.Diacritic(i))
			}
			if cell[2] != kitty.Diacritic(col) {
				t.Errorf("row %d col %d: column mark = %#x, want %#x", i, col, cell[2], kitty.Diacritic(col))
			}
		}
	}
}

func TestPlaceholderEncodingMatchesKittenIcat(t *testing.T) {
	got := []rune(stripSGR(placeholderGrid(3, 1, baseImageID)[0]))
	want := []rune{
		0x10EEEE, 0x0305, 0x0305, 0x0305,
		0x10EEEE, 0x0305, 0x030D, 0x0305,
		0x10EEEE, 0x0305, 0x030E, 0x0305,
	}
	if len(got) != len(want) {
		t.Fatalf("got %#x, want %#x", got, want)
	}
	for i := range want {
		if got[i] != want[i] {
			t.Errorf("rune %d = %#x, want %#x", i, got[i], want[i])
		}
	}
}

func TestTransmissionIsAValidVirtualPlacement(t *testing.T) {
	p := kittyPlacement{path: "/music/covers/cover.jpg", cols: 30, rows: 15, id: baseImageID, placement: basePlacementID}
	seq := p.transmit()

	if !strings.HasPrefix(seq, kittyEscape) {
		t.Fatalf("must start with the APC introducer, got %q", seq[:min(10, len(seq))])
	}
	if strings.Count(seq, kittyEscape) != 1 {
		t.Errorf("the introducer must appear exactly once: %q", seq)
	}
	if !strings.HasSuffix(seq, st) {
		t.Error("must be terminated with ST")
	}
	for _, want := range []string{
		"a=T",
		"f=100",
		"t=f",
		"U=1",
		"c=30",
		"r=15",
		"i=7800",
		"p=7800",
		"q=2",
	} {
		if !strings.Contains(seq, want) {
			t.Errorf("transmission is missing %q: %s", want, seq)
		}
	}
	if strings.Contains(seq, "C=1") {
		t.Errorf("a virtual placement must not set the cursor policy: %s", seq)
	}
	payload := seq[strings.Index(seq, ";")+1 : len(seq)-len(st)]
	decoded, err := base64.StdEncoding.DecodeString(payload)
	if err != nil {
		t.Fatalf("path payload is not valid base64: %v", err)
	}
	if string(decoded) != p.path {
		t.Errorf("path = %q, want %q", decoded, p.path)
	}
}

func TestClearKeepsTheImageCached(t *testing.T) {
	p := kittyPlacement{path: "/x.jpg", cols: 10, rows: 5, id: baseImageID, placement: basePlacementID}
	seq := p.clear()
	if !strings.Contains(seq, "d=i") {
		t.Errorf("clear should use the lowercase delete form: %s", seq)
	}
	if !strings.Contains(seq, "i=7800") || !strings.Contains(seq, "p=7800") {
		t.Errorf("clear must target the placement: %s", seq)
	}
}

func TestEmptyPlacementProducesNothing(t *testing.T) {
	for _, p := range []kittyPlacement{
		{path: "", cols: 10, rows: 5, id: 1},
		{path: "/x.jpg", cols: 1, rows: 5, id: 1},
		{path: "/x.jpg", cols: 10, rows: 0, id: 1},
	} {
		if seq := p.transmit(); seq != "" {
			t.Errorf("expected no sequence for %+v, got %q", p, seq)
		}
	}
	if seq := (kittyPlacement{}).clear(); seq != "" {
		t.Errorf("clearing an unplaced image should produce nothing, got %q", seq)
	}
}

func TestGraphicsPreviewQueuesAPlacementMatchingItsGrid(t *testing.T) {
	forceChars = false
	p := &ImagePreviewer{mode: imageKitty, cache: map[string][]string{}, staged: map[string]string{}}
	dir := t.TempDir()
	img := dir + "/cover.png"
	makeImage(t, img, false)

	grid := p.Render(img, 30, 20)
	if len(grid) == 0 {
		t.Fatal("graphics mode should still reserve cells")
	}
	place, ok := p.TakePlacement()
	if !ok {
		t.Fatal("graphics mode should queue a placement")
	}
	if place.path != img {
		t.Errorf("placement path = %q, want %q", place.path, img)
	}
	if n := strings.Count(grid[0], string(kitty.Placeholder)); n != place.cols {
		t.Errorf("placement is %d columns but the grid has %d cells", place.cols, n)
	}
	if len(grid) != place.rows {
		t.Errorf("placement is %d rows but the grid has %d", place.rows, len(grid))
	}
	if _, ok := p.TakePlacement(); ok {
		t.Error("taking a placement twice should be empty the second time")
	}
}

func TestCharacterModeEmitsNoEscapes(t *testing.T) {
	forceChars = true
	defer func() { forceChars = false }()
	p := NewImagePreviewer()
	if !p.Available() {
		t.Skip("chafa is not installed")
	}
	dir := t.TempDir()
	img := dir + "/cover.png"
	makeImage(t, img, false)
	lines := p.Render(img, 40, 20)
	if len(lines) == 0 {
		t.Fatal("character mode produced no preview")
	}
	if strings.Contains(lines[0], kittyEscape) {
		t.Error("character mode must not emit graphics escapes")
	}
	if _, ok := p.TakePlacement(); ok {
		t.Error("character mode must not queue a placement")
	}
}

func TestCellShapeMatchesReference(t *testing.T) {
	grid := placeholderGrid(2, 1, 8)
	body := grid[0]
	body = stripSGR(body)
	got := []rune(body)
	want := []rune{
		0x10EEEE, 0x0305, 0x0305, 0x0305,
		0x10EEEE, 0x0305, 0x030D, 0x0305,
	}
	if len(got) != len(want) {
		t.Fatalf("cell runes = %#x, want %#x", got, want)
	}
	for i := range want {
		if got[i] != want[i] {
			t.Errorf("rune %d = %#x, want %#x", i, got[i], want[i])
		}
	}
}

func TestGridIndexesRowsAndColumns(t *testing.T) {
	grid := placeholderGrid(3, 2, 8)
	for row, line := range grid {
		cells := []rune(stripSGR(line))
		for col := 0; col < 3; col++ {
			cell := cells[col*4 : col*4+4]
			if cell[1] != kittyDiacritic(row) {
				t.Errorf("cell (%d,%d) row mark = %#x, want %#x", row, col, cell[1], kittyDiacritic(row))
			}
			if cell[2] != kittyDiacritic(col) {
				t.Errorf("cell (%d,%d) col mark = %#x, want %#x", row, col, cell[2], kittyDiacritic(col))
			}
		}
	}
}

func TestImageIDTravelsInForegroundColour(t *testing.T) {
	cases := []struct {
		id   int
		want string
	}{
		{8, "\x1b[38:2:0:0:8m"},
		{7800, "\x1b[38:2:0:30:120m"},
		{0x010203, "\x1b[38:2:1:2:3m"},
	}
	for _, c := range cases {
		grid := placeholderGrid(1, 1, c.id)
		if !strings.HasPrefix(grid[0], c.want) {
			t.Errorf("id %d: line starts %q, want prefix %q", c.id, grid[0][:min(20, len(grid[0]))], c.want)
		}
	}
}

func TestHighByteGoesInThirdDiacritic(t *testing.T) {
	grid := placeholderGrid(1, 1, 0xD7000000)
	if !strings.HasPrefix(grid[0], "\x1b[38:2:0:0:0m") {
		t.Errorf("expected a black foreground, got %q", grid[0][:min(20, len(grid[0]))])
	}
	cells := []rune(stripSGR(grid[0]))
	if cells[3] != kittyDiacritic(0xD7) {
		t.Errorf("msb diacritic = %#x, want %#x", cells[3], kittyDiacritic(0xD7))
	}
}

func TestEachLineResetsColour(t *testing.T) {
	grid := placeholderGrid(2, 3, 8)
	for row, line := range grid {
		if !strings.HasSuffix(line, "\x1b[39m") {
			t.Errorf("row %d does not reset the foreground colour: %q", row, line)
		}
		if !strings.HasPrefix(line, "\x1b[38:2:") {
			t.Errorf("row %d does not set the id colour: %q", row, line)
		}
	}
}

func kittyDiacritic(i int) rune { return kitty.Diacritic(i) }

func stripSGR(s string) string {
	var b strings.Builder
	for i := 0; i < len(s); {
		if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
			j := i + 2
			for j < len(s) && s[j] != 'm' {
				j++
			}
			i = j + 1
			continue
		}
		b.WriteByte(s[i])
		i++
	}
	return b.String()
}

func min(a, b int) int {
	if a < b {
		return a
	}
	return b
}

func TestDisplayWidthCountsPlaceholderCellsAsOne(t *testing.T) {
	const cols = 30
	grid := placeholderGrid(cols, 1, baseImageID)
	if got := displayWidth(grid[0]); got != cols {
		t.Errorf("placeholder line measures %d cells, want %d", got, cols)
	}
}

func TestDisplayWidthIgnoresEscapesAndCombiningMarks(t *testing.T) {
	cases := []struct {
		name string
		in   string
		want int
	}{
		{"escapes are free", "\x1b[31mred\x1b[0m", 3},
		{"a truecolor sequence", "\x1b[38:2:0:16:146m", 0},
		{"combining mark adds nothing", "a\u0305b", 2},
		{"one placeholder cell", "\U0010EEEE\u0305\u0305", 1},
		{"two placeholder cells", "\U0010EEEE\u0305\u0305\U0010EEEE\u0305\u030D", 2},
		{"empty", "", 0},
	}
	for _, c := range cases {
		if got := displayWidth(c.in); got != c.want {
			t.Errorf("%s: displayWidth(%q) = %d, want %d", c.name, c.in, got, c.want)
		}
	}
}

func TestTruncateToLeavesFittingGridAlone(t *testing.T) {
	grid := placeholderGrid(10, 1, baseImageID)
	got := truncateTo(grid[0], 10)
	if got != grid[0] {
		t.Errorf("a line that fits was rewritten:\n got %q\nwant %q", got, grid[0])
	}
	if !strings.Contains(got, "38:2:") {
		t.Error("the image id colour was stripped from the grid line")
	}
	if n := strings.Count(got, string(placeholderRune)); n != 10 {
		t.Errorf("kept %d cells, want 10", n)
	}
}

func TestKittyImagesRequireATrueColourProfile(t *testing.T) {
	t.Setenv("TERM", "xterm-kitty")
	t.Setenv("INTAKE_IMAGES", "kitty")
	profile, ok := requiredColourProfile()
	if !ok {
		t.Fatal("kitty graphics mode must impose a colour profile")
	}
	if profile != colorprofile.TrueColor {
		t.Errorf("profile = %v, want truecolor", profile)
	}
}

func TestOtherModesLeaveTheProfileAlone(t *testing.T) {
	for _, mode := range []string{"chafa", "none"} {
		t.Setenv("TERM", "xterm-kitty")
		t.Setenv("INTAKE_IMAGES", mode)
		if _, ok := requiredColourProfile(); ok {
			t.Errorf("mode %q should not impose a colour profile", mode)
		}
	}
}