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