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