206 lines
6.6 KiB
Go
206 lines
6.6 KiB
Go
package tui
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
)
|
|
|
|
// runeKey builds a printable-character key press carrying r, mirroring what a
|
|
// terminal sends for a typed rune: Code is the rune and Text is its UTF-8
|
|
// encoding (textarea inserts from Text). This is how CJK / emoji reach the
|
|
// component.
|
|
func runeKey(r rune) tea.KeyPressMsg {
|
|
return tea.KeyPressMsg{Code: r, Text: string(r)}
|
|
}
|
|
|
|
// TestInputCJKByRune drives the input with the runes of "你好" and asserts the
|
|
// buffer holds the full multi-byte string with the cursor left on a rune
|
|
// boundary. This guards against the old REPL bug that keyed on byte length
|
|
// (len==1) and dropped the trailing bytes of every multi-byte rune.
|
|
func TestInputCJKByRune(t *testing.T) {
|
|
in := newInput()
|
|
for _, r := range "你好" {
|
|
var cmd tea.Cmd
|
|
in, cmd = in.Update(runeKey(r))
|
|
_ = cmd
|
|
}
|
|
|
|
got := in.Value()
|
|
if got != "你好" {
|
|
t.Fatalf("Value() = %q, want %q", got, "你好")
|
|
}
|
|
if !utf8.ValidString(got) {
|
|
t.Fatalf("Value() is not valid UTF-8: %q", got)
|
|
}
|
|
if n := utf8.RuneCountInString(got); n != 2 {
|
|
t.Fatalf("rune count = %d, want 2 (no dropped chars)", n)
|
|
}
|
|
// The cursor column is a rune index into the line; after two runes it must be
|
|
// 2, proving textarea advanced by whole runes rather than bytes.
|
|
if col := in.ta.Column(); col != 2 {
|
|
t.Errorf("cursor column = %d, want 2 (rune boundary)", col)
|
|
}
|
|
}
|
|
|
|
// TestInputEmojiByRune confirms a multi-byte emoji is inserted whole.
|
|
func TestInputEmojiByRune(t *testing.T) {
|
|
in := newInput()
|
|
in, _ = in.Update(runeKey('🚀'))
|
|
if got := in.Value(); got != "🚀" {
|
|
t.Fatalf("Value() = %q, want %q", got, "🚀")
|
|
}
|
|
}
|
|
|
|
// TestInputNewlineKeys verifies each newline binding — Shift+Enter (primary),
|
|
// Ctrl+J and Alt+Enter (fallbacks) — inserts a newline into the buffer while
|
|
// plain runes fill each line.
|
|
func TestInputNewlineKeys(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
key tea.KeyPressMsg
|
|
}{
|
|
{"shift+enter", tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift}},
|
|
{"ctrl+j", tea.KeyPressMsg{Code: 'j', Mod: tea.ModCtrl}},
|
|
{"alt+enter", tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModAlt}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
in := newInput()
|
|
in, _ = in.Update(runeKey('你'))
|
|
in, _ = in.Update(tc.key)
|
|
in, _ = in.Update(runeKey('好'))
|
|
if got := in.Value(); got != "你\n好" {
|
|
t.Fatalf("Value() = %q, want %q", got, "你\n好")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestInputEnterIsNotNewline confirms plain Enter does NOT insert a newline in
|
|
// the editor: the model intercepts it as submit, so the editor must leave it
|
|
// alone (only Shift+Enter breaks a line).
|
|
func TestInputEnterIsNotNewline(t *testing.T) {
|
|
in := newInput()
|
|
in, _ = in.Update(runeKey('a'))
|
|
in, _ = in.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
if got := in.Value(); got != "a" {
|
|
t.Fatalf("Value() = %q, want %q (Enter must not add a newline)", got, "a")
|
|
}
|
|
}
|
|
|
|
// TestInputNewlineRendersBothLines guards the viewport-offset regression: after
|
|
// 你 + Shift+Enter + 好 the buffer is "你\n好", but the editor once rendered two
|
|
// blank rows because a manual SetHeight left the textarea's viewport scrolled
|
|
// past the first line. DynamicHeight now resets the scroll offset in the same
|
|
// pass, so both runes must appear in the rendered View.
|
|
func TestInputNewlineRendersBothLines(t *testing.T) {
|
|
in := newInput()
|
|
in.SetWidth(40)
|
|
in, _ = in.Update(runeKey('你'))
|
|
in, _ = in.Update(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift})
|
|
in, _ = in.Update(runeKey('好'))
|
|
|
|
view := in.View()
|
|
if !strings.Contains(view, "你") || !strings.Contains(view, "好") {
|
|
t.Fatalf("rendered view missing content, want both 你 and 好:\n%s", view)
|
|
}
|
|
}
|
|
|
|
// TestInputClearBlurFocus exercises the lifecycle methods the model relies on
|
|
// while gating input during a run.
|
|
func TestInputClearBlurFocus(t *testing.T) {
|
|
in := newInput()
|
|
in, _ = in.Update(runeKey('x'))
|
|
in.Clear()
|
|
if got := in.Value(); got != "" {
|
|
t.Errorf("after Clear, Value() = %q, want empty", got)
|
|
}
|
|
if !in.Focused() {
|
|
t.Error("newInput should start focused")
|
|
}
|
|
in.Blur()
|
|
if in.Focused() {
|
|
t.Error("after Blur, Focused() should be false")
|
|
}
|
|
in.Focus()
|
|
if !in.Focused() {
|
|
t.Error("after Focus, Focused() should be true")
|
|
}
|
|
}
|
|
|
|
// TestModelEnterSubmits feeds a typed line and Enter to the model and asserts
|
|
// the prompt is submitted: the user turn lands in the transcript and the editor
|
|
// is cleared. Enter submits; Shift+Enter is the newline key in the multi-line
|
|
// composer. With no startRunFn wired the model stays idle and records the
|
|
// pre-#392 system note.
|
|
func TestModelEnterSubmits(t *testing.T) {
|
|
m := NewModel(Options{})
|
|
var model tea.Model = m
|
|
for _, r := range "你好世界" {
|
|
model, _ = model.Update(runeKey(r))
|
|
}
|
|
model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
|
|
got := model.(Model)
|
|
if got.input.Value() != "" {
|
|
t.Errorf("after submit, input = %q, want cleared", got.input.Value())
|
|
}
|
|
joined := strings.Join(blockTexts(got.transcript), "\n")
|
|
if !strings.Contains(joined, "你好世界") {
|
|
t.Errorf("submitted prompt missing from transcript: %q", joined)
|
|
}
|
|
}
|
|
|
|
// TestModelTwoStageInterrupt verifies FR-14: while running, Esc / Ctrl+C
|
|
// interrupts the in-flight run (calls interruptFn) and does NOT quit; while
|
|
// idle, the same keys quit the program.
|
|
func TestModelTwoStageInterrupt(t *testing.T) {
|
|
for _, key := range []tea.KeyPressMsg{
|
|
{Code: tea.KeyEscape},
|
|
{Code: 'c', Mod: tea.ModCtrl},
|
|
} {
|
|
// Running: first press interrupts, no quit.
|
|
interrupted := false
|
|
running := NewModel(Options{})
|
|
running.running = true
|
|
running.interruptFn = func() { interrupted = true }
|
|
next, cmd := running.Update(key)
|
|
if !interrupted {
|
|
t.Errorf("%s while running: interruptFn was not called", key.String())
|
|
}
|
|
if next.(Model).quitting {
|
|
t.Errorf("%s while running: model should not be quitting", key.String())
|
|
}
|
|
if cmd != nil {
|
|
if _, isQuit := cmd().(tea.QuitMsg); isQuit {
|
|
t.Errorf("%s while running: should not quit", key.String())
|
|
}
|
|
}
|
|
|
|
// Idle: the same key quits.
|
|
idle := NewModel(Options{})
|
|
got, cmd := idle.Update(key)
|
|
if cmd == nil {
|
|
t.Fatalf("%s while idle: expected a quit command", key.String())
|
|
}
|
|
if _, isQuit := cmd().(tea.QuitMsg); !isQuit {
|
|
t.Errorf("%s while idle: cmd should be tea.Quit", key.String())
|
|
}
|
|
if !got.(Model).quitting {
|
|
t.Errorf("%s while idle: model should be marked quitting", key.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// blockTexts extracts the raw text of every transcript block for assertions.
|
|
func blockTexts(t transcript) []string {
|
|
out := make([]string, len(t.blocks))
|
|
for i, b := range t.blocks {
|
|
out[i] = b.text
|
|
}
|
|
return out
|
|
}
|