test(ui): add 23 unit tests for AppModel state machine (TAS-33)

Cover state transitions (input->working->approval->input), StepError->input,
ESC cancel flow (single tap sets canceling, double tap calls CancelCurrentStep,
timer expiry resets), queue badge updates, window resize/distributeHeight, and
tea.Println cmd emission on StepCompleteEvent/StepErrorEvent.
This commit is contained in:
Ed Zynda
2026-02-26 02:22:13 +03:00
parent 00771cf291
commit 977f6c75bd
+553
View File
@@ -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)
}
}