mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
6100e8b3a8
- Add PopLastUserMessage() on *App: walks the current tree branch back to the parent of the most recent user message, syncs the in-memory store, and returns the prompt + image parts for resubmission. - Register /retry (alias /rt) and wire handleRetryCommand which rebuilds the visible ScrollList from the truncated branch before resubmitting via Run/RunWithFiles. Mirrors SubmitMsg display path (badges, pending prints, stateWorking transition). - Recovers from transient provider errors (overloaded, timeout) without duplicating the user message in context — the failed turn's entries become orphaned off-branch rather than being re-sent to the LLM. - Update help text, AppController interface, and stub controller. - Add unit tests covering busy/closed/no-session guards, the happy-path truncation, and the empty-branch error case.
1147 lines
37 KiB
Go
1147 lines
37 KiB
Go
package ui
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"github.com/mark3labs/kit/internal/app"
|
|
"github.com/mark3labs/kit/internal/session"
|
|
"github.com/mark3labs/kit/internal/ui/core"
|
|
kit "github.com/mark3labs/kit/pkg/kit"
|
|
)
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Stub AppController
|
|
// --------------------------------------------------------------------------
|
|
|
|
// stubAppController is a minimal implementation of AppController for tests.
|
|
// It records which methods were called and allows the test to inspect the results.
|
|
type stubAppController struct {
|
|
runCalls []string
|
|
cancelCalled int
|
|
clearQueueCalled int
|
|
clearMsgCalled int
|
|
queueLen int
|
|
}
|
|
|
|
func (s *stubAppController) Run(prompt string) int {
|
|
s.runCalls = append(s.runCalls, prompt)
|
|
return s.queueLen
|
|
}
|
|
|
|
func (s *stubAppController) CancelCurrentStep() {
|
|
s.cancelCalled++
|
|
}
|
|
|
|
func (s *stubAppController) QueueLength() int {
|
|
return s.queueLen
|
|
}
|
|
|
|
func (s *stubAppController) ClearQueue() {
|
|
s.clearQueueCalled++
|
|
s.queueLen = 0
|
|
}
|
|
|
|
func (s *stubAppController) ClearMessages() {
|
|
s.clearMsgCalled++
|
|
}
|
|
|
|
func (s *stubAppController) ReloadMessagesFromTree() {
|
|
// no-op in tests
|
|
}
|
|
|
|
func (s *stubAppController) CompactConversation(_ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *stubAppController) GetTreeSession() *session.TreeManager {
|
|
return nil
|
|
}
|
|
|
|
func (s *stubAppController) SwitchTreeSession(_ *session.TreeManager) {
|
|
// no-op in tests
|
|
}
|
|
|
|
func (s *stubAppController) SendEvent(_ tea.Msg) {
|
|
// no-op in tests
|
|
}
|
|
|
|
func (s *stubAppController) AddContextMessage(_ string) {
|
|
// no-op in tests
|
|
}
|
|
|
|
func (s *stubAppController) RunWithFiles(prompt string, _ []kit.LLMFilePart) int {
|
|
s.runCalls = append(s.runCalls, prompt)
|
|
return s.queueLen
|
|
}
|
|
|
|
func (s *stubAppController) Steer(prompt string) int {
|
|
s.runCalls = append(s.runCalls, prompt)
|
|
return s.queueLen
|
|
}
|
|
|
|
func (s *stubAppController) SteerWithFiles(prompt string, _ []kit.LLMFilePart) int {
|
|
s.runCalls = append(s.runCalls, prompt)
|
|
return s.queueLen
|
|
}
|
|
|
|
func (s *stubAppController) PopLastUserMessage() (string, []kit.LLMFilePart, error) {
|
|
return "", nil, fmt.Errorf("no user message to retry")
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Stub child components
|
|
// --------------------------------------------------------------------------
|
|
|
|
// stubStreamComponent satisfies streamComponentIface without rendering anything.
|
|
type stubStreamComponent struct {
|
|
resetCalled int
|
|
lastMsg tea.Msg
|
|
renderedContent string // returned by GetRenderedContent
|
|
}
|
|
|
|
func (s *stubStreamComponent) Init() tea.Cmd { return nil }
|
|
func (s *stubStreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
s.lastMsg = msg
|
|
return s, nil
|
|
}
|
|
func (s *stubStreamComponent) View() tea.View { return tea.NewView("") }
|
|
func (s *stubStreamComponent) Reset() { s.resetCalled++; s.renderedContent = "" }
|
|
func (s *stubStreamComponent) GetRenderedContent() string { return s.renderedContent }
|
|
func (s *stubStreamComponent) SpinnerView() string { return "" }
|
|
func (s *stubStreamComponent) SetThinkingVisible(bool) {}
|
|
func (s *stubStreamComponent) HasReasoning() bool { return false }
|
|
func (s *stubStreamComponent) UpdateTheme() {}
|
|
|
|
// stubInputComponent satisfies inputComponentIface without rendering anything.
|
|
type stubInputComponent struct {
|
|
lastMsg tea.Msg
|
|
}
|
|
|
|
func (s *stubInputComponent) Init() tea.Cmd { return nil }
|
|
func (s *stubInputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
s.lastMsg = msg
|
|
return s, nil
|
|
}
|
|
func (s *stubInputComponent) View() tea.View { return tea.NewView("") }
|
|
|
|
// --------------------------------------------------------------------------
|
|
// newTestAppModel creates an AppModel with stub children for unit tests.
|
|
// --------------------------------------------------------------------------
|
|
|
|
func newTestAppModel(ctrl AppController) (*AppModel, *stubStreamComponent, *stubInputComponent) {
|
|
stream := &stubStreamComponent{}
|
|
input := &stubInputComponent{}
|
|
m := &AppModel{
|
|
state: stateInput,
|
|
appCtrl: ctrl,
|
|
stream: stream,
|
|
input: input,
|
|
renderer: newMessageRenderer(80, false),
|
|
modelName: "test-model",
|
|
width: 80,
|
|
height: 24,
|
|
streamingBashMaxLines: 50, // Initialize buffer cap like NewAppModel does
|
|
scrollList: NewScrollList(80, 20),
|
|
messages: []MessageItem{},
|
|
}
|
|
return m, stream, input
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Helpers
|
|
// --------------------------------------------------------------------------
|
|
|
|
// sendMsg calls m.Update once with the given message and returns the updated model.
|
|
func sendMsg(m *AppModel, msg tea.Msg) *AppModel {
|
|
updated, _ := m.Update(msg)
|
|
result := updated.(*AppModel)
|
|
// Simulate BubbleTea's frame cycle: View() is called after every Update().
|
|
// This flushes any pending layoutDirty work (e.g. distributeHeight).
|
|
_ = result.View()
|
|
return result
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// State transitions
|
|
// --------------------------------------------------------------------------
|
|
|
|
// TestStateTransition_InputToWorking verifies that a submitMsg while in
|
|
// stateInput transitions the model to stateWorking.
|
|
func TestStateTransition_InputToWorking(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
|
|
if m.state != stateInput {
|
|
t.Fatalf("expected stateInput, got %v", m.state)
|
|
}
|
|
|
|
m = sendMsg(m, core.SubmitMsg{Text: "hello"})
|
|
|
|
if m.state != stateWorking {
|
|
t.Fatalf("expected stateWorking after submitMsg, got %v", m.state)
|
|
}
|
|
if len(ctrl.runCalls) != 1 || ctrl.runCalls[0] != "hello" {
|
|
t.Fatalf("expected Run called with 'hello', got %v", ctrl.runCalls)
|
|
}
|
|
}
|
|
|
|
// TestStateTransition_WorkingToInput_StepComplete verifies that StepCompleteEvent
|
|
// transitions from stateWorking back to stateInput and keeps stream content
|
|
// visible (deferred flush — no Reset until next SpinnerEvent{Show: true}).
|
|
func TestStateTransition_WorkingToInput_StepComplete(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, stream, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
|
|
m = sendMsg(m, app.StepCompleteEvent{ResponseText: "all done"})
|
|
|
|
if m.state != stateInput {
|
|
t.Fatalf("expected stateInput after StepCompleteEvent, got %v", m.state)
|
|
}
|
|
if stream.resetCalled != 0 {
|
|
t.Fatalf("expected stream NOT reset on StepCompleteEvent (deferred flush), got %d resets", stream.resetCalled)
|
|
}
|
|
}
|
|
|
|
// TestStateTransition_WorkingToInput_StepError verifies that StepErrorEvent
|
|
// transitions from stateWorking back to stateInput and keeps stream content
|
|
// visible (deferred flush — no Reset until next SpinnerEvent{Show: true}).
|
|
func TestStateTransition_WorkingToInput_StepError(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, stream, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
|
|
m = sendMsg(m, app.StepErrorEvent{Err: errors.New("something broke")})
|
|
|
|
if m.state != stateInput {
|
|
t.Fatalf("expected stateInput after StepErrorEvent, got %v", m.state)
|
|
}
|
|
if stream.resetCalled != 0 {
|
|
t.Fatalf("expected stream NOT reset on StepErrorEvent (deferred flush), got %d resets", stream.resetCalled)
|
|
}
|
|
}
|
|
|
|
// TestStepError_nilErr verifies that a StepErrorEvent with a nil error does not
|
|
// panic and still transitions to stateInput.
|
|
func TestStepError_nilErr(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
|
|
m = sendMsg(m, app.StepErrorEvent{Err: nil})
|
|
|
|
if m.state != stateInput {
|
|
t.Fatalf("expected stateInput for nil-error StepErrorEvent, got %v", m.state)
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// StepCancelledEvent
|
|
// --------------------------------------------------------------------------
|
|
|
|
// TestStateTransition_WorkingToInput_StepCancelled verifies that StepCancelledEvent
|
|
// transitions from stateWorking back to stateInput and keeps stream content
|
|
// visible (deferred flush — no Reset until next SpinnerEvent{Show: true}).
|
|
func TestStateTransition_WorkingToInput_StepCancelled(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, stream, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
|
|
m = sendMsg(m, app.StepCancelledEvent{})
|
|
|
|
if m.state != stateInput {
|
|
t.Fatalf("expected stateInput after StepCancelledEvent, got %v", m.state)
|
|
}
|
|
if stream.resetCalled != 0 {
|
|
t.Fatalf("expected stream NOT reset on StepCancelledEvent (deferred flush), got %d resets", stream.resetCalled)
|
|
}
|
|
}
|
|
|
|
// TestStepCancelled_clearsCanceling verifies that StepCancelledEvent clears
|
|
// the canceling flag.
|
|
func TestStepCancelled_clearsCanceling(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
m.canceling = true
|
|
|
|
m = sendMsg(m, app.StepCancelledEvent{})
|
|
|
|
if m.canceling {
|
|
t.Fatal("expected canceling=false after StepCancelledEvent")
|
|
}
|
|
}
|
|
|
|
// TestStepCancelled_preservesStreamContent verifies that StepCancelledEvent
|
|
// does NOT flush stream content — it stays visible for deferred flush.
|
|
func TestStepCancelled_preservesStreamContent(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, stream, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
stream.renderedContent = "partial assistant response"
|
|
|
|
_ = sendMsg(m, app.StepCancelledEvent{})
|
|
|
|
if stream.renderedContent != "partial assistant response" {
|
|
t.Fatal("expected stream content preserved after StepCancelledEvent")
|
|
}
|
|
}
|
|
|
|
// TestStepCancelled_noStreamContent_noCmd verifies that StepCancelledEvent with
|
|
// no accumulated stream content produces a nil cmd (nothing to flush).
|
|
func TestStepCancelled_noStreamContent_noCmd(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
|
|
_, cmd := m.Update(app.StepCancelledEvent{})
|
|
|
|
if cmd != nil {
|
|
t.Fatal("expected nil cmd on StepCancelledEvent with no stream content")
|
|
}
|
|
}
|
|
|
|
// TestStepCancelled_noErrorPrinted verifies that StepCancelledEvent does NOT
|
|
// produce an error message (unlike StepErrorEvent).
|
|
func TestStepCancelled_noErrorPrinted(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
|
|
// With no stream content, cmd should be nil (no flush, and no error print).
|
|
_, cmd := m.Update(app.StepCancelledEvent{})
|
|
|
|
if cmd != nil {
|
|
t.Fatal("expected nil cmd for StepCancelledEvent with no stream content — should not print error")
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// ESC cancel flow
|
|
// --------------------------------------------------------------------------
|
|
|
|
// TestESCCancel_singleTap verifies that the first ESC press during stateWorking
|
|
// sets canceling=true (and does not call CancelCurrentStep).
|
|
func TestESCCancel_singleTap(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
|
|
m = sendMsg(m, tea.KeyPressMsg{Code: tea.KeyEscape})
|
|
|
|
if !m.canceling {
|
|
t.Fatal("expected canceling=true after first ESC in stateWorking")
|
|
}
|
|
if ctrl.cancelCalled != 0 {
|
|
t.Fatalf("expected no CancelCurrentStep call after first ESC, got %d", ctrl.cancelCalled)
|
|
}
|
|
}
|
|
|
|
// TestESCCancel_doubleTap verifies that a second ESC press while canceling=true
|
|
// calls CancelCurrentStep() and resets canceling.
|
|
func TestESCCancel_doubleTap(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
m.canceling = true // simulate first ESC already pressed
|
|
|
|
m = sendMsg(m, tea.KeyPressMsg{Code: tea.KeyEscape})
|
|
|
|
if m.canceling {
|
|
t.Fatal("expected canceling=false after second ESC")
|
|
}
|
|
if ctrl.cancelCalled != 1 {
|
|
t.Fatalf("expected CancelCurrentStep called once after double ESC, got %d", ctrl.cancelCalled)
|
|
}
|
|
}
|
|
|
|
// TestESCCancel_timerExpiry verifies that cancelTimerExpiredMsg resets canceling
|
|
// to false (timer fires before second ESC).
|
|
func TestESCCancel_timerExpiry(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
m.canceling = true
|
|
|
|
m = sendMsg(m, core.CancelTimerExpiredMsg{})
|
|
|
|
if m.canceling {
|
|
t.Fatal("expected canceling=false after timer expiry")
|
|
}
|
|
if ctrl.cancelCalled != 0 {
|
|
t.Fatalf("expected no CancelCurrentStep after timer expiry, got %d", ctrl.cancelCalled)
|
|
}
|
|
}
|
|
|
|
// TestESCCancel_noEffectInStateInput verifies that ESC in stateInput does not set
|
|
// canceling (it's passed to child components instead).
|
|
func TestESCCancel_noEffectInStateInput(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
// state is stateInput by default
|
|
|
|
m = sendMsg(m, tea.KeyPressMsg{Code: tea.KeyEscape})
|
|
|
|
if m.canceling {
|
|
t.Fatal("expected canceling=false when ESC pressed in stateInput")
|
|
}
|
|
if ctrl.cancelCalled != 0 {
|
|
t.Fatalf("expected no CancelCurrentStep in stateInput, got %d", ctrl.cancelCalled)
|
|
}
|
|
}
|
|
|
|
// TestESCCancel_clearedOnStepComplete verifies that completing a step clears
|
|
// any pending canceling state.
|
|
func TestESCCancel_clearedOnStepComplete(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
m.canceling = true
|
|
|
|
m = sendMsg(m, app.StepCompleteEvent{ResponseText: "done"})
|
|
|
|
if m.canceling {
|
|
t.Fatal("expected canceling=false after StepCompleteEvent")
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Queued messages
|
|
// --------------------------------------------------------------------------
|
|
|
|
// TestQueuedMessages_storedOnQueuedSubmit verifies that submitting a prompt
|
|
// while the agent is busy stores the text in queuedMessages (not scrollback).
|
|
func TestQueuedMessages_storedOnQueuedSubmit(t *testing.T) {
|
|
ctrl := &stubAppController{queueLen: 1} // simulate busy (will return 1)
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
|
|
_, cmd := m.Update(core.SubmitMsg{Text: "queued prompt"})
|
|
|
|
if len(m.queuedMessages) != 1 {
|
|
t.Fatalf("expected 1 queued message, got %d", len(m.queuedMessages))
|
|
}
|
|
if m.queuedMessages[0] != "queued prompt" {
|
|
t.Fatalf("expected queued message text 'queued prompt', got %q", m.queuedMessages[0])
|
|
}
|
|
// Should NOT flush (message is anchored in ScrollList).
|
|
if cmd != nil {
|
|
t.Fatal("expected nil cmd for queued submit (message should not print to scrollback)")
|
|
}
|
|
}
|
|
|
|
// TestQueuedMessages_poppedOnQueueUpdated verifies that QueueUpdatedEvent pops
|
|
// consumed messages from queuedMessages and moves them to pendingUserPrints.
|
|
// The actual printing is deferred to SpinnerEvent{Show: true} to preserve
|
|
// chronological order with the preceding assistant response.
|
|
func TestQueuedMessages_poppedOnQueueUpdated(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.queuedMessages = []string{"first", "second", "third"}
|
|
|
|
// Simulate drainQueue popping one item (length goes from 3 to 2).
|
|
m = sendMsg(m, app.QueueUpdatedEvent{Length: 2})
|
|
|
|
if len(m.queuedMessages) != 2 {
|
|
t.Fatalf("expected 2 queued messages after pop, got %d", len(m.queuedMessages))
|
|
}
|
|
if m.queuedMessages[0] != "second" {
|
|
t.Fatalf("expected first remaining message 'second', got %q", m.queuedMessages[0])
|
|
}
|
|
// Popped message should be deferred to pendingUserPrints.
|
|
if len(m.pendingUserPrints) != 1 {
|
|
t.Fatalf("expected 1 pending user print, got %d", len(m.pendingUserPrints))
|
|
}
|
|
if m.pendingUserPrints[0] != "first" {
|
|
t.Fatalf("expected pending message 'first', got %q", m.pendingUserPrints[0])
|
|
}
|
|
}
|
|
|
|
// TestQueuedMessages_allPoppedOnDrain verifies that QueueUpdatedEvent with
|
|
// Length=0 pops all remaining queued messages into pendingUserPrints.
|
|
func TestQueuedMessages_allPoppedOnDrain(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.queuedMessages = []string{"alpha", "beta"}
|
|
|
|
m = sendMsg(m, app.QueueUpdatedEvent{Length: 0})
|
|
|
|
if len(m.queuedMessages) != 0 {
|
|
t.Fatalf("expected 0 queued messages after drain, got %d", len(m.queuedMessages))
|
|
}
|
|
if len(m.pendingUserPrints) != 2 {
|
|
t.Fatalf("expected 2 pending user prints, got %d", len(m.pendingUserPrints))
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Window resize
|
|
// --------------------------------------------------------------------------
|
|
|
|
// TestWindowResize_updatesWidthHeight verifies that tea.WindowSizeMsg updates
|
|
// m.width and m.height.
|
|
func TestWindowResize_updatesWidthHeight(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
|
|
m = sendMsg(m, tea.WindowSizeMsg{Width: 120, Height: 40})
|
|
|
|
if m.width != 120 {
|
|
t.Fatalf("expected width=120, got %d", m.width)
|
|
}
|
|
if m.height != 40 {
|
|
t.Fatalf("expected height=40, got %d", m.height)
|
|
}
|
|
}
|
|
|
|
// TestWindowResize_propagatesToStream verifies that tea.WindowSizeMsg is forwarded
|
|
// to the stream component.
|
|
func TestWindowResize_propagatesToStream(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, stream, _ := newTestAppModel(ctrl)
|
|
|
|
_ = sendMsg(m, tea.WindowSizeMsg{Width: 100, Height: 30})
|
|
|
|
if stream.lastMsg == nil {
|
|
t.Fatal("expected stream component to receive WindowSizeMsg")
|
|
}
|
|
if _, ok := stream.lastMsg.(tea.WindowSizeMsg); !ok {
|
|
t.Fatalf("expected stream.lastMsg to be WindowSizeMsg, got %T", stream.lastMsg)
|
|
}
|
|
}
|
|
|
|
// TestWindowResize_distributeHeight verifies that distributeHeight correctly
|
|
// sets the stream height after a resize.
|
|
func TestWindowResize_distributeHeight(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
|
|
// With height=30, scroll height = 30 - 1 (separator) - 8 (input) - 1 (statusBar) = 20
|
|
m = sendMsg(m, tea.WindowSizeMsg{Width: 80, Height: 30})
|
|
_ = m
|
|
|
|
if m.scrollList.height != 20 {
|
|
t.Fatalf("expected scroll list height=20, got %d", m.scrollList.height)
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Step complete behavior
|
|
// --------------------------------------------------------------------------
|
|
|
|
// TestStepComplete_preservesStreamContent verifies that StepCompleteEvent
|
|
// does NOT flush stream content — it stays visible for deferred flush.
|
|
func TestStepComplete_preservesStreamContent(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, stream, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
// Simulate accumulated streaming text.
|
|
stream.renderedContent = "rendered assistant text"
|
|
|
|
_ = sendMsg(m, app.StepCompleteEvent{ResponseText: "final answer"})
|
|
|
|
if stream.renderedContent != "rendered assistant text" {
|
|
t.Fatal("expected stream content preserved after StepCompleteEvent")
|
|
}
|
|
}
|
|
|
|
// TestStepComplete_noStreamContent_noCmd verifies that StepCompleteEvent with
|
|
// no accumulated stream content produces a nil cmd (nothing to flush).
|
|
func TestStepComplete_noStreamContent_noCmd(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
|
|
_, cmd := m.Update(app.StepCompleteEvent{})
|
|
|
|
if cmd != nil {
|
|
t.Fatal("expected nil cmd on StepCompleteEvent with no stream content")
|
|
}
|
|
}
|
|
|
|
// TestSubmitMsg_printsUserMessage verifies that submitMsg adds the user message
|
|
// to the ScrollList messages and triggers a layout update.
|
|
func TestSubmitMsg_printsUserMessage(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
|
|
m = sendMsg(m, core.SubmitMsg{Text: "user query"})
|
|
|
|
// In alt screen mode, user messages are added to the in-memory ScrollList
|
|
// rather than printed separately. Verify the message was added.
|
|
found := false
|
|
for _, msg := range m.messages {
|
|
if tm, ok := msg.(*TextMessageItem); ok && tm.role == "user" && tm.content == "user query" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("expected user message 'user query' in ScrollList messages")
|
|
}
|
|
}
|
|
|
|
// TestToolCallStarted_flushesOnly verifies that ToolCallStartedEvent marks
|
|
// any active StreamingMessageItem as complete and resets the stream.
|
|
func TestToolCallStarted_flushesOnly(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, stream, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
|
|
// With no stream content, nothing should change.
|
|
initialCount := len(m.messages)
|
|
m = sendMsg(m, app.ToolCallStartedEvent{
|
|
ToolName: "bash",
|
|
ToolArgs: `{"cmd":"ls"}`,
|
|
})
|
|
|
|
if len(m.messages) != initialCount {
|
|
t.Fatal("expected no new messages on ToolCallStartedEvent with no stream content")
|
|
}
|
|
|
|
// Simulate a StreamingMessageItem already in messages (as if appendStreamingChunk was called)
|
|
// plus the stream component having rendered content.
|
|
streamItem := NewStreamingMessageItem("stream-1", "assistant", "test-model")
|
|
streamItem.AppendChunk("partial text")
|
|
m.messages = append(m.messages, streamItem)
|
|
stream.renderedContent = "partial text"
|
|
|
|
_ = sendMsg(m, app.ToolCallStartedEvent{
|
|
ToolName: "bash",
|
|
ToolArgs: `{"cmd":"ls"}`,
|
|
})
|
|
|
|
// The StreamingMessageItem should have been marked complete.
|
|
if streamItem.streaming {
|
|
t.Fatal("expected StreamingMessageItem to be marked complete after ToolCallStartedEvent")
|
|
}
|
|
// Stream should have been reset.
|
|
if stream.resetCalled == 0 {
|
|
t.Fatal("expected stream.Reset() to be called")
|
|
}
|
|
}
|
|
|
|
// TestToolResult_printsAndStartsSpinner verifies that ToolResultEvent adds
|
|
// the tool result to the ScrollList and the stream receives a SpinnerEvent.
|
|
func TestToolResult_printsAndStartsSpinner(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, stream, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
|
|
initialCount := len(m.messages)
|
|
|
|
m = sendMsg(m, app.ToolResultEvent{
|
|
ToolName: "bash",
|
|
ToolArgs: "{}",
|
|
Result: "output",
|
|
IsError: false,
|
|
})
|
|
|
|
// Tool result should have been added to ScrollList messages.
|
|
if len(m.messages) <= initialCount {
|
|
t.Fatal("expected tool result message added to ScrollList")
|
|
}
|
|
// Stream should have received a SpinnerEvent to start spinner for next LLM call.
|
|
if stream.lastMsg == nil {
|
|
t.Fatal("expected stream to receive SpinnerEvent after ToolResultEvent")
|
|
}
|
|
if se, ok := stream.lastMsg.(app.SpinnerEvent); !ok || !se.Show {
|
|
t.Fatalf("expected SpinnerEvent{Show:true}, got %T", stream.lastMsg)
|
|
}
|
|
}
|
|
|
|
// TestToolOutputEvent_accumulatesBashOutput verifies that ToolOutputEvent
|
|
// accumulates stdout and stderr lines into a StreamingBashOutputItem in the ScrollList.
|
|
func TestToolOutputEvent_accumulatesBashOutput(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
|
|
// Send stdout chunk.
|
|
m = sendMsg(m, app.ToolOutputEvent{
|
|
ToolCallID: "call-1",
|
|
ToolName: "bash",
|
|
Chunk: "line one\n",
|
|
IsStderr: false,
|
|
})
|
|
|
|
// Should have created a StreamingBashOutputItem in messages.
|
|
var bashItem *StreamingBashOutputItem
|
|
for _, msg := range m.messages {
|
|
if item, ok := msg.(*StreamingBashOutputItem); ok {
|
|
bashItem = item
|
|
break
|
|
}
|
|
}
|
|
if bashItem == nil {
|
|
t.Fatal("expected StreamingBashOutputItem in messages after ToolOutputEvent")
|
|
return
|
|
}
|
|
if len(bashItem.stdoutLines) != 1 || bashItem.stdoutLines[0] != "line one\n" {
|
|
t.Fatalf("expected stdout=['line one\\n'], got %v", bashItem.stdoutLines)
|
|
}
|
|
if len(bashItem.stderrLines) != 0 {
|
|
t.Fatalf("expected empty stderr, got %v", bashItem.stderrLines)
|
|
}
|
|
|
|
// Send another stdout chunk.
|
|
m = sendMsg(m, app.ToolOutputEvent{
|
|
ToolCallID: "call-1",
|
|
ToolName: "bash",
|
|
Chunk: "line two\n",
|
|
IsStderr: false,
|
|
})
|
|
|
|
// Re-find the bash item (same item, updated)
|
|
bashItem = nil
|
|
for _, msg := range m.messages {
|
|
if item, ok := msg.(*StreamingBashOutputItem); ok {
|
|
bashItem = item
|
|
}
|
|
}
|
|
if bashItem == nil || len(bashItem.stdoutLines) != 2 {
|
|
t.Fatalf("expected 2 stdout lines, got %d", len(bashItem.stdoutLines))
|
|
}
|
|
|
|
// Send stderr chunk.
|
|
m = sendMsg(m, app.ToolOutputEvent{
|
|
ToolCallID: "call-1",
|
|
ToolName: "bash",
|
|
Chunk: "error: something failed\n",
|
|
IsStderr: true,
|
|
})
|
|
|
|
bashItem = nil
|
|
for _, msg := range m.messages {
|
|
if item, ok := msg.(*StreamingBashOutputItem); ok {
|
|
bashItem = item
|
|
}
|
|
}
|
|
if bashItem == nil || len(bashItem.stderrLines) != 1 {
|
|
t.Fatalf("expected 1 stderr line, got %d", len(bashItem.stderrLines))
|
|
}
|
|
if bashItem.stderrLines[0] != "error: something failed\n" {
|
|
t.Fatalf("expected stderr 'error: something failed\\n', got %q", bashItem.stderrLines[0])
|
|
}
|
|
}
|
|
|
|
// TestToolResult_clearsStreamingBashOutput verifies that ToolResultEvent clears
|
|
// the streaming bash output buffers since the final result will be printed.
|
|
func TestToolResult_clearsStreamingBashOutput(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
|
|
// Accumulate some bash output.
|
|
m.streamingBashOutput = []string{"output line"}
|
|
m.streamingBashStderr = []string{"error line"}
|
|
|
|
_, _ = m.Update(app.ToolResultEvent{
|
|
ToolName: "bash",
|
|
ToolArgs: `{"cmd":"ls"}`,
|
|
Result: "output line\nerror line\n",
|
|
IsError: false,
|
|
})
|
|
|
|
if len(m.streamingBashOutput) != 0 {
|
|
t.Fatalf("expected streamingBashOutput cleared, got %v", m.streamingBashOutput)
|
|
}
|
|
if len(m.streamingBashStderr) != 0 {
|
|
t.Fatalf("expected streamingBashStderr cleared, got %v", m.streamingBashStderr)
|
|
}
|
|
}
|
|
|
|
// TestToolCallStarted_extractsBashCommand verifies that ToolCallStartedEvent
|
|
// extracts the bash command from ToolArgs and stores it for the streaming output header.
|
|
func TestToolCallStarted_extractsBashCommand(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
|
|
// Send ToolCallStartedEvent with bash command.
|
|
m = sendMsg(m, app.ToolCallStartedEvent{
|
|
ToolCallID: "call-1",
|
|
ToolName: "bash",
|
|
ToolArgs: `{"command":"ls -la /home"}`,
|
|
})
|
|
|
|
if m.streamingBashCommand != "ls -la /home" {
|
|
t.Fatalf("expected streamingBashCommand='ls -la /home', got %q", m.streamingBashCommand)
|
|
}
|
|
|
|
// ToolResultEvent should clear the command.
|
|
m = sendMsg(m, app.ToolResultEvent{
|
|
ToolCallID: "call-1",
|
|
ToolName: "bash",
|
|
ToolArgs: `{"command":"ls -la /home"}`,
|
|
Result: "output",
|
|
IsError: false,
|
|
})
|
|
|
|
if m.streamingBashCommand != "" {
|
|
t.Fatalf("expected streamingBashCommand cleared, got %q", m.streamingBashCommand)
|
|
}
|
|
}
|
|
|
|
// TestToolCallStarted_nonBashTool_doesNotSetCommand verifies that non-bash tools
|
|
// do not set the streamingBashCommand field.
|
|
func TestToolCallStarted_nonBashTool_doesNotSetCommand(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
|
|
// Send ToolCallStartedEvent with a non-bash tool.
|
|
m = sendMsg(m, app.ToolCallStartedEvent{
|
|
ToolCallID: "call-1",
|
|
ToolName: "read",
|
|
ToolArgs: `{"file":"/etc/passwd"}`,
|
|
})
|
|
|
|
if m.streamingBashCommand != "" {
|
|
t.Fatalf("expected streamingBashCommand to remain empty for non-bash tools, got %q", m.streamingBashCommand)
|
|
}
|
|
}
|
|
|
|
// TestStepError_printCmd verifies that StepErrorEvent with a non-nil error
|
|
// adds an error message to the ScrollList.
|
|
func TestStepError_printCmd(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
|
|
initialCount := len(m.messages)
|
|
|
|
m = sendMsg(m, app.StepErrorEvent{Err: errors.New("agent failed")})
|
|
|
|
// Error should have been added to ScrollList messages.
|
|
if len(m.messages) <= initialCount {
|
|
t.Fatal("expected error message added to ScrollList on StepErrorEvent")
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// SpinnerEvent: stateWorking on Show=true
|
|
// --------------------------------------------------------------------------
|
|
|
|
// TestSpinnerEvent_showTransitionsToWorking verifies that SpinnerEvent{Show:true}
|
|
// transitions the model to stateWorking (important for queued step drain path).
|
|
func TestSpinnerEvent_showTransitionsToWorking(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
// After a step completes, model is back in stateInput. The next queued step
|
|
// starts and fires SpinnerEvent{Show: true}.
|
|
m.state = stateInput
|
|
|
|
m = sendMsg(m, app.SpinnerEvent{Show: true})
|
|
|
|
if m.state != stateWorking {
|
|
t.Fatalf("expected stateWorking after SpinnerEvent{Show:true} in stateInput, got %v", m.state)
|
|
}
|
|
}
|
|
|
|
// TestSpinnerEvent_hidDoesNotTransitionState verifies that SpinnerEvent{Show:false}
|
|
// does not change the state.
|
|
func TestSpinnerEvent_hideDoesNotTransitionState(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
|
|
m = sendMsg(m, app.SpinnerEvent{Show: false})
|
|
|
|
if m.state != stateWorking {
|
|
t.Fatalf("expected state to remain stateWorking, got %v", m.state)
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// ctrl+c double-press to quit
|
|
// --------------------------------------------------------------------------
|
|
|
|
// TestCtrlC_producesQuit verifies that double ctrl+c returns a tea.Quit cmd.
|
|
func TestCtrlC_producesQuit(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
|
|
// First Ctrl+C arms the quit flag.
|
|
updated, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
|
|
m = updated.(*AppModel)
|
|
if cmd == nil {
|
|
t.Fatal("expected a command after first ctrl+c, got nil")
|
|
}
|
|
// Should be a reset timer, not quit.
|
|
msg := cmd()
|
|
if _, ok := msg.(core.CtrlCResetMsg); !ok {
|
|
t.Fatalf("expected CtrlCResetMsg after first ctrl+c, got %T", msg)
|
|
}
|
|
|
|
// Second Ctrl+C should quit.
|
|
_, cmd = m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
|
|
if cmd == nil {
|
|
t.Fatal("expected tea.Quit cmd on second ctrl+c, got nil")
|
|
}
|
|
msg = cmd()
|
|
if _, ok := msg.(tea.QuitMsg); !ok {
|
|
t.Fatalf("expected QuitMsg from second ctrl+c, got %T", msg)
|
|
}
|
|
}
|
|
|
|
// TestCtrlC_clearsInput_firstPress tests that Ctrl+C clears input on first
|
|
// press when there's content, and requires a second press to quit.
|
|
func TestCtrlC_clearsInput_firstPress(t *testing.T) {
|
|
// Create a real InputComponent to test the clear behavior
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
|
|
// Replace with real InputComponent that has content
|
|
input := NewInputComponent(80, ctrl)
|
|
input.textarea.SetValue("some text content")
|
|
m.input = input
|
|
|
|
// First Ctrl+C should clear input, not quit
|
|
_, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
|
|
|
|
// Should have cleared the input
|
|
if input.textarea.Value() != "" {
|
|
t.Fatalf("expected input to be cleared, got %q", input.textarea.Value())
|
|
}
|
|
|
|
// Should have set ctrlCPressedOnce flag
|
|
if !m.ctrlCPressedOnce {
|
|
t.Fatal("expected ctrlCPressedOnce to be true after first Ctrl+C")
|
|
}
|
|
|
|
// The command should be a ctrlCResetCmd (not tea.Quit)
|
|
if cmd == nil {
|
|
t.Fatal("expected a command after first Ctrl+C, got nil")
|
|
}
|
|
msg := cmd()
|
|
if _, ok := msg.(core.CtrlCResetMsg); !ok {
|
|
t.Fatalf("expected CtrlCResetMsg, got %T", msg)
|
|
}
|
|
|
|
// Second Ctrl+C should now quit
|
|
_, cmd = m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
|
|
if cmd == nil {
|
|
t.Fatal("expected tea.Quit cmd on second Ctrl+C, got nil")
|
|
}
|
|
msg = cmd()
|
|
if _, ok := msg.(tea.QuitMsg); !ok {
|
|
t.Fatalf("expected QuitMsg on second Ctrl+C, got %T", msg)
|
|
}
|
|
}
|
|
|
|
// TestCtrlC_resetAfterSubmit tests that the Ctrl+C flag is reset after
|
|
// submitting a message, so the next Ctrl+C clears input again.
|
|
func TestCtrlC_resetAfterSubmit(t *testing.T) {
|
|
// Use newTestAppModel but replace the input with a real InputComponent
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
|
|
// Replace with real InputComponent
|
|
input := NewInputComponent(80, ctrl)
|
|
input.textarea.SetValue("content")
|
|
m.input = input
|
|
|
|
// First Ctrl+C clears input
|
|
updated, _ := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
|
|
m = updated.(*AppModel)
|
|
if input.textarea.Value() != "" {
|
|
t.Fatal("expected input to be cleared")
|
|
}
|
|
|
|
// Flag should be set
|
|
if !m.ctrlCPressedOnce {
|
|
t.Fatal("expected ctrlCPressedOnce to be true after first Ctrl+C")
|
|
}
|
|
|
|
// Simulate CtrlCResetMsg being processed (timer expired)
|
|
updated, _ = m.Update(core.CtrlCResetMsg{})
|
|
m = updated.(*AppModel)
|
|
|
|
// Flag should be reset
|
|
if m.ctrlCPressedOnce {
|
|
t.Fatal("expected ctrlCPressedOnce to be false after CtrlCResetMsg")
|
|
}
|
|
|
|
// Add new content to input
|
|
input.textarea.SetValue("new content")
|
|
|
|
// Next Ctrl+C should clear again (not quit) because flag was reset
|
|
_, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
|
|
if input.textarea.Value() != "" {
|
|
t.Fatalf("expected input to be cleared again, got %q", input.textarea.Value())
|
|
}
|
|
if cmd == nil {
|
|
t.Fatal("expected a command after Ctrl+C, got nil")
|
|
}
|
|
msg := cmd()
|
|
if _, ok := msg.(core.CtrlCResetMsg); !ok {
|
|
t.Fatalf("expected CtrlCResetMsg, got %T", msg)
|
|
}
|
|
}
|
|
|
|
// TestCtrlC_emptyInput_armsQuit tests that Ctrl+C on empty input still
|
|
// requires a second press to quit (consistent double-press behavior).
|
|
func TestCtrlC_emptyInput_armsQuit(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
|
|
// Replace with real InputComponent (empty by default)
|
|
input := NewInputComponent(80, ctrl)
|
|
m.input = input
|
|
|
|
// First Ctrl+C on empty input should arm the flag, not quit.
|
|
updated, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
|
|
m = updated.(*AppModel)
|
|
|
|
if !m.ctrlCPressedOnce {
|
|
t.Fatal("expected ctrlCPressedOnce to be true after first Ctrl+C")
|
|
}
|
|
if cmd == nil {
|
|
t.Fatal("expected a command (reset timer), got nil")
|
|
}
|
|
msg := cmd()
|
|
if _, ok := msg.(core.CtrlCResetMsg); !ok {
|
|
t.Fatalf("expected CtrlCResetMsg, got %T", msg)
|
|
}
|
|
|
|
// Second Ctrl+C should quit.
|
|
_, cmd = m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
|
|
if cmd == nil {
|
|
t.Fatal("expected tea.Quit cmd on second Ctrl+C, got nil")
|
|
}
|
|
msg = cmd()
|
|
if _, ok := msg.(tea.QuitMsg); !ok {
|
|
t.Fatalf("expected QuitMsg on second Ctrl+C, got %T", msg)
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// submitMsg during stateWorking (queue path)
|
|
// --------------------------------------------------------------------------
|
|
|
|
// TestSubmit_duringWorking_stays verifies that submitting a new prompt while in
|
|
// stateWorking keeps the model in stateWorking (queued via app.Run).
|
|
func TestSubmit_duringWorking_stays(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.state = stateWorking
|
|
|
|
m = sendMsg(m, core.SubmitMsg{Text: "queued prompt"})
|
|
|
|
if m.state != stateWorking {
|
|
t.Fatalf("expected stateWorking to persist after submitMsg during working, got %v", m.state)
|
|
}
|
|
if len(ctrl.runCalls) != 1 || ctrl.runCalls[0] != "queued prompt" {
|
|
t.Fatalf("expected Run('queued prompt') called, got %v", ctrl.runCalls)
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// truncateMessageForBlock
|
|
// --------------------------------------------------------------------------
|
|
|
|
// TestTruncateMessageForBlock_shortMessage verifies that short messages are
|
|
// returned unchanged.
|
|
func TestTruncateMessageForBlock_shortMessage(t *testing.T) {
|
|
msg := "hello world"
|
|
got := truncateMessageForBlock(msg, 3, 80)
|
|
if got != msg {
|
|
t.Fatalf("expected unchanged message, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestTruncateMessageForBlock_exactLines verifies that a message with exactly
|
|
// maxLines hard lines is returned unchanged.
|
|
func TestTruncateMessageForBlock_exactLines(t *testing.T) {
|
|
msg := "line1\nline2\nline3"
|
|
got := truncateMessageForBlock(msg, 3, 80)
|
|
if got != msg {
|
|
t.Fatalf("expected unchanged message, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestTruncateMessageForBlock_tooManyLines verifies that messages exceeding
|
|
// maxLines are truncated with an ellipsis.
|
|
func TestTruncateMessageForBlock_tooManyLines(t *testing.T) {
|
|
msg := "line1\nline2\nline3\nline4\nline5"
|
|
got := truncateMessageForBlock(msg, 3, 80)
|
|
want := "line1\nline2\nline3…"
|
|
if got != want {
|
|
t.Fatalf("expected %q, got %q", want, got)
|
|
}
|
|
}
|
|
|
|
// TestTruncateMessageForBlock_longWrappingLine verifies that a single long
|
|
// line that would wrap beyond maxLines is truncated.
|
|
func TestTruncateMessageForBlock_longWrappingLine(t *testing.T) {
|
|
// 100 chars at width 20 = 5 visual lines, exceeds maxLines=3
|
|
msg := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
got := truncateMessageForBlock(msg, 3, 20)
|
|
// Should be truncated to 3*20=60 runes + "…"
|
|
if len([]rune(got)) != 61 { // 60 runes + "…"
|
|
t.Fatalf("expected 61 runes (60 + ellipsis), got %d runes: %q", len([]rune(got)), got)
|
|
}
|
|
if got[len(got)-3:] != "…" { // "…" is 3 bytes in UTF-8
|
|
t.Fatal("expected trailing ellipsis")
|
|
}
|
|
}
|
|
|
|
// TestTruncateMessageForBlock_emptyMessage verifies that empty messages are
|
|
// returned unchanged.
|
|
func TestTruncateMessageForBlock_emptyMessage(t *testing.T) {
|
|
got := truncateMessageForBlock("", 3, 80)
|
|
if got != "" {
|
|
t.Fatalf("expected empty string, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestTruncateMessageForBlock_mixedWrapAndHardLines verifies truncation when
|
|
// some hard lines wrap and the total exceeds maxLines.
|
|
func TestTruncateMessageForBlock_mixedWrapAndHardLines(t *testing.T) {
|
|
// First line: 40 chars at width 20 = 2 visual lines
|
|
// Second line: "short" = 1 visual line (total: 3, exactly at limit)
|
|
// Third line: would exceed
|
|
msg := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nshort\nextra"
|
|
got := truncateMessageForBlock(msg, 3, 20)
|
|
want := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nshort…"
|
|
if got != want {
|
|
t.Fatalf("expected %q, got %q", want, got)
|
|
}
|
|
}
|
|
|
|
// TestRenderQueuedMessages_truncatesLongMessages verifies that the rendered
|
|
// queued message view truncates long messages instead of showing them in full.
|
|
func TestRenderQueuedMessages_truncatesLongMessages(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
m, _, _ := newTestAppModel(ctrl)
|
|
m.width = 80
|
|
|
|
// Queue a very long message (20 lines).
|
|
var b strings.Builder
|
|
for i := range 20 {
|
|
if i > 0 {
|
|
b.WriteByte('\n')
|
|
}
|
|
b.WriteString("This is a long line of text for testing purposes")
|
|
}
|
|
m.queuedMessages = []string{b.String()}
|
|
|
|
rendered := m.renderQueuedMessages()
|
|
if rendered == "" {
|
|
t.Fatal("expected non-empty rendered output")
|
|
}
|
|
|
|
// The full message would be ~20+ lines. With truncation to 3 content
|
|
// lines + badge + padding, it should be much shorter.
|
|
lines := len(strings.Split(rendered, "\n"))
|
|
// 3 content lines + 1 badge + 2 padding + border overhead ≈ ~7 lines max
|
|
if lines > 10 {
|
|
t.Fatalf("expected truncated output to be ≤10 lines, got %d lines", lines)
|
|
}
|
|
}
|