first commit
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user