package tui import ( "strings" "testing" "time" "ai-agent/internal/command" tea "charm.land/bubbletea/v2" ) func TestSubmitInput_EmptyReturnsNil(t *testing.T) { m := newTestModel(t) cmd := m.submitInput() if cmd != nil { t.Error("submitInput with empty input should return nil") } } func TestHelp_OnlyWhenIdleAndEmpty(t *testing.T) { t.Run("idle_empty_opens_help", func(t *testing.T) { m := newTestModel(t) m.state = StateIdle updated, _ := m.Update(charKey('?')) m = updated.(*Model) if m.overlay != OverlayHelp { t.Errorf("? with idle+empty should open help, got overlay=%d", m.overlay) } }) t.Run("idle_nonempty_no_help", func(t *testing.T) { m := newTestModel(t) m.state = StateIdle m.input.SetValue("hello") updated, _ := m.Update(charKey('?')) m = updated.(*Model) if m.overlay == OverlayHelp { t.Error("? with non-empty input should not open help") } }) t.Run("waiting_no_help", func(t *testing.T) { m := newTestModel(t) m.state = StateWaiting updated, _ := m.Update(charKey('?')) m = updated.(*Model) if m.overlay == OverlayHelp { t.Error("? in StateWaiting should not open help") } }) } func TestToggleTools_OnlyWhenIdleAndEmpty(t *testing.T) { t.Run("idle_empty_toggles", func(t *testing.T) { m := newTestModel(t) m.state = StateIdle before := m.toolsCollapsed updated, _ := m.Update(charKey('t')) m = updated.(*Model) if m.toolsCollapsed == before { t.Error("'t' with idle+empty should toggle toolsCollapsed") } }) t.Run("idle_nonempty_no_toggle", func(t *testing.T) { m := newTestModel(t) m.state = StateIdle m.input.SetValue("hello") before := m.toolsCollapsed updated, _ := m.Update(charKey('t')) m = updated.(*Model) if m.toolsCollapsed != before { t.Error("'t' with non-empty input should not toggle tools") } }) } func TestESC_CancelOnlyWhenStreamingOrWaiting(t *testing.T) { t.Run("idle_no_cancel", func(t *testing.T) { m := newTestModel(t) m.state = StateIdle cancelCalled := false m.cancel = func() { cancelCalled = true } updated, _ := m.Update(escKey()) _ = updated.(*Model) if cancelCalled { t.Error("ESC in idle should not call cancel") } }) t.Run("streaming_cancels", func(t *testing.T) { m := newTestModel(t) m.state = StateStreaming cancelCalled := false m.cancel = func() { cancelCalled = true } updated, _ := m.Update(escKey()) _ = updated.(*Model) if !cancelCalled { t.Error("ESC in streaming should call cancel") } }) t.Run("waiting_cancels", func(t *testing.T) { m := newTestModel(t) m.state = StateWaiting cancelCalled := false m.cancel = func() { cancelCalled = true } updated, _ := m.Update(escKey()) _ = updated.(*Model) if !cancelCalled { t.Error("ESC in waiting should call cancel") } }) } func TestSystemMessageMsg_AppendsEntry(t *testing.T) { m := newTestModel(t) before := len(m.entries) updated, _ := m.Update(SystemMessageMsg{Msg: "hello system"}) m = updated.(*Model) if len(m.entries) != before+1 { t.Fatalf("expected %d entries, got %d", before+1, len(m.entries)) } last := m.entries[len(m.entries)-1] if last.Kind != "system" { t.Errorf("expected kind 'system', got %q", last.Kind) } if last.Content != "hello system" { t.Errorf("expected content 'hello system', got %q", last.Content) } } func TestErrorMsg_AppendsEntry(t *testing.T) { m := newTestModel(t) before := len(m.entries) updated, _ := m.Update(ErrorMsg{Msg: "something broke"}) m = updated.(*Model) if len(m.entries) != before+1 { t.Fatalf("expected %d entries, got %d", before+1, len(m.entries)) } last := m.entries[len(m.entries)-1] if last.Kind != "error" { t.Errorf("expected kind 'error', got %q", last.Kind) } if last.Content != "something broke" { t.Errorf("expected content 'something broke', got %q", last.Content) } } func TestToolCallResultMsg(t *testing.T) { t.Run("updates_tool_entry", func(t *testing.T) { m := newTestModel(t) m.toolEntries = append(m.toolEntries, ToolEntry{ Name: "read_file", Status: ToolStatusRunning, }) m.toolsPending = 1 updated, _ := m.Update(ToolCallResultMsg{ Name: "read_file", Result: "file contents", IsError: false, Duration: 42 * time.Millisecond, }) m = updated.(*Model) if m.toolEntries[0].Status != ToolStatusDone { t.Errorf("expected ToolStatusDone, got %d", m.toolEntries[0].Status) } if m.toolEntries[0].Result != "file contents" { t.Errorf("expected 'file contents', got %q", m.toolEntries[0].Result) } if m.toolsPending != 0 { t.Errorf("toolsPending should be 0, got %d", m.toolsPending) } }) t.Run("truncates_long_result", func(t *testing.T) { m := newTestModel(t) m.toolEntries = append(m.toolEntries, ToolEntry{ Name: "read_file", Status: ToolStatusRunning, }) longResult := strings.Repeat("x", 2500) updated, _ := m.Update(ToolCallResultMsg{ Name: "read_file", Result: longResult, }) m = updated.(*Model) if len(m.toolEntries[0].Result) != 2000 { t.Errorf("result should be truncated to 2000, got %d", len(m.toolEntries[0].Result)) } if !strings.HasSuffix(m.toolEntries[0].Result, "...") { t.Error("truncated result should end with '...'") } }) t.Run("error_status", func(t *testing.T) { m := newTestModel(t) m.toolEntries = append(m.toolEntries, ToolEntry{ Name: "exec", Status: ToolStatusRunning, }) updated, _ := m.Update(ToolCallResultMsg{ Name: "exec", Result: "command failed", IsError: true, }) m = updated.(*Model) if m.toolEntries[0].Status != ToolStatusError { t.Errorf("expected ToolStatusError, got %d", m.toolEntries[0].Status) } if !m.toolEntries[0].IsError { t.Error("IsError should be true") } }) } func TestAgentDoneMsg(t *testing.T) { m := newTestModel(t) m.state = StateStreaming m.userScrolledUp = true m.anchorActive = false updated, _ := m.Update(AgentDoneMsg{}) m = updated.(*Model) if m.state != StateIdle { t.Errorf("state should be StateIdle, got %d", m.state) } if m.userScrolledUp { t.Error("userScrolledUp should be reset to false") } if !m.anchorActive { t.Error("anchorActive should be reset to true") } } func TestInitCompleteMsg(t *testing.T) { t.Run("basic_fields", func(t *testing.T) { m := newTestModel(t) updated, _ := m.Update(InitCompleteMsg{ Model: "llama3", ModelList: []string{"llama3", "qwen3"}, AgentProfile: "default", AgentList: []string{"default", "coder"}, ToolCount: 5, ServerCount: 2, NumCtx: 8192, }) m = updated.(*Model) if m.model != "llama3" { t.Errorf("model should be 'llama3', got %q", m.model) } if len(m.modelList) != 2 { t.Errorf("modelList should have 2 items, got %d", len(m.modelList)) } if m.toolCount != 5 { t.Errorf("toolCount should be 5, got %d", m.toolCount) } if m.serverCount != 2 { t.Errorf("serverCount should be 2, got %d", m.serverCount) } }) t.Run("with_failed_servers", func(t *testing.T) { m := newTestModel(t) before := len(m.entries) updated, _ := m.Update(InitCompleteMsg{ Model: "llama3", FailedServers: []FailedServer{ {Name: "server1", Reason: "timeout"}, }, }) m = updated.(*Model) if len(m.entries) != before+1 { t.Fatalf("should append system entry for failed servers, got %d entries", len(m.entries)) } last := m.entries[len(m.entries)-1] if last.Kind != "system" { t.Errorf("expected kind 'system', got %q", last.Kind) } if !strings.Contains(last.Content, "server1") { t.Errorf("should contain server name, got %q", last.Content) } }) } func TestHandleCommandAction(t *testing.T) { tests := []struct { name string result command.Result check func(t *testing.T, m *Model, cmd tea.Cmd) }{ { name: "ActionShowHelp", result: command.Result{Action: command.ActionShowHelp}, check: func(t *testing.T, m *Model, cmd tea.Cmd) { if m.overlay != OverlayHelp { t.Errorf("expected OverlayHelp, got %d", m.overlay) } }, }, { name: "ActionClear_with_text", result: command.Result{Action: command.ActionClear, Text: "Cleared."}, check: func(t *testing.T, m *Model, cmd tea.Cmd) { if len(m.entries) != 1 { t.Errorf("expected 1 entry, got %d", len(m.entries)) } if m.entries[0].Kind != "system" { t.Errorf("expected system entry, got %q", m.entries[0].Kind) } }, }, { name: "ActionQuit", result: command.Result{Action: command.ActionQuit}, check: func(t *testing.T, m *Model, cmd tea.Cmd) { if cmd == nil { t.Error("ActionQuit should return a cmd (tea.Quit)") } }, }, { name: "ActionLoadContext", result: command.Result{Action: command.ActionLoadContext, Data: "test.md\x00# Hello", Text: "Loaded."}, check: func(t *testing.T, m *Model, cmd tea.Cmd) { if m.loadedFile != "test.md" { t.Errorf("expected loadedFile='test.md', got %q", m.loadedFile) } }, }, { name: "ActionUnloadContext", result: command.Result{Action: command.ActionUnloadContext, Text: "Unloaded."}, check: func(t *testing.T, m *Model, cmd tea.Cmd) { if m.loadedFile != "" { t.Errorf("expected empty loadedFile, got %q", m.loadedFile) } }, }, { name: "ActionSwitchModel", result: command.Result{Action: command.ActionSwitchModel, Data: "gpt-4", Text: "Switched."}, check: func(t *testing.T, m *Model, cmd tea.Cmd) { if m.model != "gpt-4" { t.Errorf("expected model='gpt-4', got %q", m.model) } }, }, { name: "ActionSwitchAgent", result: command.Result{Action: command.ActionSwitchAgent, Data: "coder", Text: "Switched."}, check: func(t *testing.T, m *Model, cmd tea.Cmd) { if m.agentProfile != "coder" { t.Errorf("expected agentProfile='coder', got %q", m.agentProfile) } }, }, { name: "ActionNone_with_text", result: command.Result{Action: command.ActionNone, Text: "Info message"}, check: func(t *testing.T, m *Model, cmd tea.Cmd) { if len(m.entries) == 0 { t.Fatal("expected at least one entry") } last := m.entries[len(m.entries)-1] if last.Content != "Info message" { t.Errorf("expected 'Info message', got %q", last.Content) } }, }, { name: "ActionNone_empty_text", result: command.Result{Action: command.ActionNone, Text: ""}, check: func(t *testing.T, m *Model, cmd tea.Cmd) { // Should not add any entry. }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := newTestModel(t) if tt.result.Action == command.ActionUnloadContext { m.loadedFile = "old.md" } cmd := m.handleCommandAction(tt.result) tt.check(t, m, cmd) }) } } func TestCommandResultMsg(t *testing.T) { t.Run("with_text", func(t *testing.T) { m := newTestModel(t) before := len(m.entries) updated, _ := m.Update(CommandResultMsg{Text: "Result info"}) m = updated.(*Model) if len(m.entries) != before+1 { t.Fatalf("expected %d entries, got %d", before+1, len(m.entries)) } if m.entries[len(m.entries)-1].Content != "Result info" { t.Errorf("expected 'Result info', got %q", m.entries[len(m.entries)-1].Content) } }) t.Run("empty_text_no_entry", func(t *testing.T) { m := newTestModel(t) before := len(m.entries) updated, _ := m.Update(CommandResultMsg{Text: ""}) m = updated.(*Model) if len(m.entries) != before { t.Errorf("expected %d entries (no change), got %d", before, len(m.entries)) } }) }