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 }