Files
BlackBean/pigo/internal/cli/tui/input_test.go
T
2026-08-14 23:41:57 +08:00

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
}