diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go new file mode 100644 index 00000000..6ccfde0c --- /dev/null +++ b/internal/ui/model_test.go @@ -0,0 +1,553 @@ +package ui + +import ( + "errors" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/fantasy" + "github.com/mark3labs/mcphost/internal/app" +) + +// -------------------------------------------------------------------------- +// 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) { + s.runCalls = append(s.runCalls, prompt) +} + +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++ +} + +// -------------------------------------------------------------------------- +// Stub child components +// -------------------------------------------------------------------------- + +// stubStreamComponent satisfies streamComponentIface without rendering anything. +type stubStreamComponent struct { + resetCalled int + height int + lastMsg tea.Msg +} + +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++ } +func (s *stubStreamComponent) SetHeight(h int) { s.height = h } + +// 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), + compactRdr: NewCompactRenderer(80, false), + compactMode: false, + modelName: "test-model", + width: 80, + height: 24, + } + 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) + return updated.(*AppModel) +} + +// makeTestResponse constructs a fantasy.Response with the given text content. +// Uses fantasy.TextContent (the type that ResponseContent.Text() recognises) rather +// than TextPart (which is a request-side type). +func makeTestResponse(text string) *fantasy.Response { + return &fantasy.Response{ + Content: fantasy.ResponseContent{fantasy.TextContent{Text: text}}, + } +} + +// -------------------------------------------------------------------------- +// 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, 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_WorkingToApproval verifies that a ToolApprovalNeededEvent +// while in stateWorking transitions the model to stateApproval and creates the +// ApprovalComponent. +func TestStateTransition_WorkingToApproval(t *testing.T) { + ctrl := &stubAppController{} + m, _, _ := newTestAppModel(ctrl) + m.state = stateWorking + + responseChan := make(chan bool, 1) + m = sendMsg(m, app.ToolApprovalNeededEvent{ + ToolName: "test_tool", + ToolArgs: `{"arg":"val"}`, + ResponseChan: responseChan, + }) + + if m.state != stateApproval { + t.Fatalf("expected stateApproval after ToolApprovalNeededEvent, got %v", m.state) + } + if m.approval == nil { + t.Fatal("expected approval component to be set") + } + if m.approvalChan == nil { + t.Fatal("expected approvalChan to be set") + } +} + +// TestStateTransition_ApprovalToWorking verifies that an approvalResultMsg +// while in stateApproval sends the result on approvalChan and transitions back +// to stateWorking. +func TestStateTransition_ApprovalToWorking(t *testing.T) { + ctrl := &stubAppController{} + m, _, _ := newTestAppModel(ctrl) + m.state = stateApproval + + responseChan := make(chan bool, 1) + m.approvalChan = responseChan + + m = sendMsg(m, approvalResultMsg{Approved: true}) + + if m.state != stateWorking { + t.Fatalf("expected stateWorking after approvalResultMsg, got %v", m.state) + } + if m.approvalChan != nil { + t.Fatal("expected approvalChan to be cleared") + } + + // Verify the approval result was sent on the channel. + select { + case approved := <-responseChan: + if !approved { + t.Fatal("expected approved=true to be sent on responseChan") + } + default: + t.Fatal("expected a value on responseChan") + } +} + +// TestStateTransition_WorkingToInput_StepComplete verifies that StepCompleteEvent +// transitions from stateWorking back to stateInput and resets the stream component. +func TestStateTransition_WorkingToInput_StepComplete(t *testing.T) { + ctrl := &stubAppController{} + m, stream, _ := newTestAppModel(ctrl) + m.state = stateWorking + + m = sendMsg(m, app.StepCompleteEvent{ + Response: makeTestResponse("all done"), + Usage: fantasy.Usage{}, + }) + + if m.state != stateInput { + t.Fatalf("expected stateInput after StepCompleteEvent, got %v", m.state) + } + if stream.resetCalled != 1 { + t.Fatalf("expected stream.Reset() called once, got %d", stream.resetCalled) + } +} + +// TestStateTransition_WorkingToInput_StepError verifies that StepErrorEvent +// transitions from stateWorking back to stateInput and resets the stream component. +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 != 1 { + t.Fatalf("expected stream.Reset() called once, got %d", 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) + } +} + +// -------------------------------------------------------------------------- +// 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, 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{ + Response: makeTestResponse("done"), + }) + + if m.canceling { + t.Fatal("expected canceling=false after StepCompleteEvent") + } +} + +// -------------------------------------------------------------------------- +// Queue badge update +// -------------------------------------------------------------------------- + +// TestQueueBadge_updatesOnEvent verifies that QueueUpdatedEvent sets queueCount. +func TestQueueBadge_updatesOnEvent(t *testing.T) { + ctrl := &stubAppController{} + m, _, _ := newTestAppModel(ctrl) + + if m.queueCount != 0 { + t.Fatalf("expected queueCount=0 initially, got %d", m.queueCount) + } + + m = sendMsg(m, app.QueueUpdatedEvent{Length: 3}) + + if m.queueCount != 3 { + t.Fatalf("expected queueCount=3 after QueueUpdatedEvent, got %d", m.queueCount) + } +} + +// TestQueueBadge_resetsToZero verifies that a QueueUpdatedEvent with Length=0 +// resets the badge count. +func TestQueueBadge_resetsToZero(t *testing.T) { + ctrl := &stubAppController{} + m, _, _ := newTestAppModel(ctrl) + m.queueCount = 5 + + m = sendMsg(m, app.QueueUpdatedEvent{Length: 0}) + + if m.queueCount != 0 { + t.Fatalf("expected queueCount=0 after QueueUpdatedEvent{Length:0}, got %d", m.queueCount) + } +} + +// -------------------------------------------------------------------------- +// 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) + + m = 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, stream, _ := newTestAppModel(ctrl) + + // With height=30, stream height = 30 - 1 (separator) - 5 (input) = 24 + m = sendMsg(m, tea.WindowSizeMsg{Width: 80, Height: 30}) + _ = m + + if stream.height != 24 { + t.Fatalf("expected stream height=24, got %d", stream.height) + } +} + +// -------------------------------------------------------------------------- +// tea.Println on step complete +// -------------------------------------------------------------------------- + +// TestStepComplete_printCmd verifies that StepCompleteEvent produces a non-nil +// tea.Cmd (the tea.Println call). We verify the Cmd is non-nil rather than +// executing it, since tea.Println is a runtime command. +func TestStepComplete_printCmd(t *testing.T) { + ctrl := &stubAppController{} + m, _, _ := newTestAppModel(ctrl) + m.state = stateWorking + + _, cmd := m.Update(app.StepCompleteEvent{ + Response: makeTestResponse("final answer"), + Usage: fantasy.Usage{}, + }) + + // A non-nil cmd means printCompletedResponse returned tea.Println(...) + if cmd == nil { + t.Fatal("expected non-nil cmd (tea.Println) on StepCompleteEvent with response") + } +} + +// TestStepComplete_nilResponse_noCmd verifies that StepCompleteEvent with a nil +// response produces a nil cmd (nothing to print). +func TestStepComplete_nilResponse_noCmd(t *testing.T) { + ctrl := &stubAppController{} + m, _, _ := newTestAppModel(ctrl) + m.state = stateWorking + + _, cmd := m.Update(app.StepCompleteEvent{Response: nil}) + + if cmd != nil { + t.Fatal("expected nil cmd on StepCompleteEvent with nil response") + } +} + +// TestStepError_printCmd verifies that StepErrorEvent with a non-nil error +// produces a non-nil cmd (the tea.Println call for the error message). +func TestStepError_printCmd(t *testing.T) { + ctrl := &stubAppController{} + m, _, _ := newTestAppModel(ctrl) + m.state = stateWorking + + _, cmd := m.Update(app.StepErrorEvent{Err: errors.New("agent failed")}) + + if cmd == nil { + t.Fatal("expected non-nil cmd (tea.Println) on StepErrorEvent with error") + } +} + +// -------------------------------------------------------------------------- +// 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 produces tea.Quit +// -------------------------------------------------------------------------- + +// TestCtrlC_producesQuit verifies that ctrl+c always returns a tea.Quit cmd. +func TestCtrlC_producesQuit(t *testing.T) { + ctrl := &stubAppController{} + m, _, _ := newTestAppModel(ctrl) + + _, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + + if cmd == nil { + t.Fatal("expected tea.Quit cmd on ctrl+c, got nil") + } + // We verify it's a quit command by running it and checking the message type. + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Fatalf("expected QuitMsg from ctrl+c cmd, 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, 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) + } +}