Download
185 lines 6.6 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
package tui

import (
	"strings"
	"testing"

	tea "charm.land/bubbletea/v2"
	"github.com/charmbracelet/x/ansi"
)

// plain is a view without styles or the spaces that end its lines.
func plain(s string) string {
	lines := strings.Split(ansi.Strip(s), "\n")
	for i, l := range lines {
		lines[i] = strings.TrimRight(l, " ")
	}
	return strings.TrimRight(strings.Join(lines, "\n"), "\n")
}

// Without Markdown, the input must show the text where the textarea
// would, with the cursor where it puts it, so that moving the cursor up
// and down goes where it looks like it should.
func TestInputViewMatchesTextarea(t *testing.T) {
	texts := []string{
		"hello world and code",
		strings.Repeat("word ", 30),
		strings.Repeat("x", 70) + " tail",
		"中文字符混合 text 中文字符混合 more 中文字符混合中文字符混合",
		strings.Repeat("line\n", 15) + "last",
		"ends with spaces   ",
	}
	for _, width := range []int{12, 31, 80} {
		for _, text := range texts {
			for _, up := range []int{0, 3, 12} {
				m := Model{input: newInput()}
				m.input.SetWidth(width)
				m.input.SetValue(text)
				for range up {
					m.input, _ = m.input.Update(tea.KeyPressMsg{Code: tea.KeyUp})
				}
				m.input, _ = m.input.Update(tea.KeyPressMsg{Code: tea.KeyLeft, Mod: tea.ModShift})
				view, x, y := m.inputView()
				if got, want := plain(view), plain(m.input.View()); got != want {
					t.Errorf("width %d, %q, up %d:\n%s\nwant:\n%s", width, text, up, got, want)
				}
				if c := m.input.Cursor().Position; x != c.X || y != c.Y {
					t.Errorf("width %d, %q, up %d: cursor at %d,%d, want %d,%d", width, text, up, x, y, c.X, c.Y)
				}
			}
		}
	}
}

// Markdown is rendered except where the cursor is, where it shows as
// typed.
func TestInputViewHidesMarkdown(t *testing.T) {
	for _, c := range []struct {
		text string
		left int // how far the cursor moves left from the end
		want string
		x, y int
	}{
		{"a **b** `c`", 0, "❯ a b `c`", 9, 0},
		{"a **b** `c`", 3, "❯ a b `c`", 6, 0},
		{"a **b** `c`", 4, "❯ a **b** c", 9, 0},
		{"a **b** `c`", 5, "❯ a **b** c", 8, 0},
		{"a **b** `c`", 11, "❯ a b c", 2, 0},
		{"see [docs](http://x.y) ok", 0, "❯ see docs ok", 13, 0},
		{"# Title\nbody", 0, "❯ Title\n❯ body", 6, 1},
		{"# Title\nbody", 5, "❯ # Title\n❯ body", 9, 0},
		{"- item\n> quote\nx", 0, "❯ • item\n❯ │ quote\n❯ x", 3, 2},
		{"```\n**x**\n```", 0, "❯ ```\n❯ **x**\n❯ ```", 5, 2},
		// Not closed yet: styled up to the end while the cursor is after
		// the syntax that opens it, and as typed otherwise.
		{"is *ita", 0, "❯ is *ita", 9, 0},
		{"is **bo", 0, "❯ is **bo", 9, 0},
		{"is `co", 0, "❯ is `co", 8, 0},
		{"is *ita", 4, "❯ is *ita", 5, 0},
		{"2 * 3", 0, "❯ 2 * 3", 7, 0},
		// Bold opened with ** and closed with __ shows both by either.
		{"**a __b__ c**", 0, "❯ a __b c**", 11, 0},
		{"**a __b__ c**", 4, "❯ **a __b__ c**", 11, 0},
	} {
		m := Model{input: newInput()}
		m.input.SetWidth(40)
		m.input.SetValue(c.text)
		for range c.left {
			m.input, _ = m.input.Update(tea.KeyPressMsg{Code: tea.KeyLeft})
		}
		view, x, y := m.inputView()
		if got := plain(view); got != c.want || x != c.x || y != c.y {
			t.Errorf("%q, %d left: %q at %d,%d, want %q at %d,%d", c.text, c.left, got, x, y, c.want, c.x, c.y)
		}
	}
}

func TestHighlight(t *testing.T) {
	// The cursor is at the end, outside the bold.
	cells := highlight([]string{"a **b** c"}, 0, 9)[0]
	for i, want := range []cell{
		{'a', style{}, false}, {' ', style{}, false},
		{'*', style{mark: true}, true}, {'*', style{mark: true}, true},
		{'b', style{bold: true}, false},
		{'*', style{mark: true}, true}, {'*', style{mark: true}, true},
		{' ', style{}, false}, {'c', style{}, false},
	} {
		if cells[i] != want {
			t.Errorf("rune %d: %+v, want %+v", i, cells[i], want)
		}
	}
	// Not closed yet, with the cursor at the end.
	cells = highlight([]string{"a *it"}, 0, 5)[0]
	if cells[2] != (cell{'*', style{mark: true}, false}) || cells[3] != (cell{'i', style{italic: true}, false}) {
		t.Errorf("unclosed: %+v %+v", cells[2], cells[3])
	}
	// With the cursor before it, it is just a star.
	if c := highlight([]string{"a *it"}, 0, 0)[0][3]; c != (cell{'i', style{}, false}) {
		t.Errorf("unclosed, cursor before: %+v", c)
	}
	// In a code block, Markdown is not.
	if c := highlight([]string{"```", "**x**", "```"}, 0, 0)[1][0]; c != (cell{'*', style{code: true}, false}) {
		t.Errorf("in code: %+v", c)
	}
	// A line with more than fence characters is code, as markdown renders
	// it: "```go" in a block does not close it.
	lines := []string{"```", "```go", "**x**", "```", "**y**"}
	hl := highlight(lines, 0, 0)
	if c := hl[1][0]; c != (cell{'`', style{code: true}, false}) {
		t.Errorf("```go in code: %+v", c)
	}
	if c := hl[2][0]; c != (cell{'*', style{code: true}, false}) {
		t.Errorf("after ```go in code: %+v", c)
	}
	if c := hl[4][2]; c != (cell{'y', style{bold: true}, false}) {
		t.Errorf("after the block: %+v", c)
	}
	if r := plain(markdown(strings.Join(lines, "\n"), 80)); !strings.Contains(r, "**x**") || strings.Contains(r, "**y**") {
		t.Errorf("markdown disagrees:\n%s", r)
	}
}

// However far the cursor moves, it stays on the input, on a row of it.
func TestInputViewKeepsCursorInView(t *testing.T) {
	text := strings.Repeat("# **heading** with `code` that is long enough to wrap\n- item\n", 8)
	m := Model{input: newInput()}
	m.input.SetWidth(30)
	m.input.SetValue(text)
	for _, k := range []rune{tea.KeyUp, tea.KeyDown} {
		for range 40 {
			m.input, _ = m.input.Update(tea.KeyPressMsg{Code: k})
			view, x, y := m.inputView()
			rows := strings.Split(view, "\n")
			if y < 0 || y >= len(rows) || len(rows) > m.input.MaxHeight || x > 32 {
				t.Fatalf("cursor at %d,%d of %d rows:\n%s", x, y, len(rows), plain(view))
			}
		}
	}
}

// Up and down move between the rows of a wrapped line, and go through
// history only from the first row and the last.
func TestUpDownWrappedLine(t *testing.T) {
	var m tea.Model = Model{input: newInput(), reply: &strings.Builder{}, reasoning: &strings.Builder{}, width: 30, history: []string{"earlier"}, histPos: 1}
	m, _ = m.Update(tea.WindowSizeMsg{Width: 30, Height: 20})
	long := strings.Repeat("word ", 12)
	mm := m.(Model)
	mm.input.SetValue(long)
	m = mm
	up := tea.KeyPressMsg{Code: tea.KeyUp}
	down := tea.KeyPressMsg{Code: tea.KeyDown}
	m, _ = m.Update(up)
	if got := m.(Model).input.Value(); got != long {
		t.Fatalf("up from the last row went to history: %q", got)
	}
	for range 5 {
		m, _ = m.Update(up)
	}
	if got := m.(Model).input.Value(); got != "earlier" {
		t.Fatalf("up from the first row: %q", got)
	}
	m, _ = m.Update(down)
	if got := m.(Model).input.Value(); got != long {
		t.Fatalf("down back from history: %q", got)
	}
}