280 lines
8.2 KiB
Go
280 lines
8.2 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
"github.com/charmbracelet/bubbles/textarea"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
|
|
"nub/internal/agent"
|
|
"nub/internal/llm"
|
|
"nub/internal/skill"
|
|
"nub/internal/tool"
|
|
)
|
|
|
|
func newTestModel(t *testing.T) *Model {
|
|
t.Helper()
|
|
return &Model{
|
|
ctx: context.Background(),
|
|
in: make(chan agent.Input, 1),
|
|
textarea: textarea.New(),
|
|
viewport: viewport.New(80, 20),
|
|
spinner: spinner.New(),
|
|
}
|
|
}
|
|
|
|
func TestSubmit_SlashCommandDoesNotTouchInChannel(t *testing.T) {
|
|
m := newTestModel(t)
|
|
m.entries = []entry{{kind: entryUser, text: "old"}}
|
|
|
|
cmd := m.submit("/clear", false)
|
|
if cmd != nil {
|
|
t.Error("expected /clear to return a nil cmd (no async work)")
|
|
}
|
|
if len(m.entries) != 0 {
|
|
t.Errorf("expected entries cleared, got %d", len(m.entries))
|
|
}
|
|
select {
|
|
case v := <-m.in:
|
|
t.Errorf("slash command must not send on in channel, got %+v", v)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestSubmit_AltEnterDuringActiveTurn_Queues(t *testing.T) {
|
|
m := newTestModel(t)
|
|
m.turnActive = true
|
|
|
|
cmd := m.submit("do this next", true)
|
|
if cmd != nil {
|
|
t.Error("expected queuing to return a nil cmd (nothing sent yet)")
|
|
}
|
|
if len(m.followupQueue) != 1 || m.followupQueue[0] != "do this next" {
|
|
t.Errorf("followupQueue = %+v", m.followupQueue)
|
|
}
|
|
select {
|
|
case v := <-m.in:
|
|
t.Errorf("queued follow-up must not be sent immediately, got %+v", v)
|
|
default:
|
|
}
|
|
if len(m.entries) != 1 || m.entries[0].kind != entryQueued || m.entries[0].text != "do this next" {
|
|
t.Errorf("entries = %+v", m.entries)
|
|
}
|
|
}
|
|
|
|
func TestSubmit_PlainMessage_SendsOnInChannel(t *testing.T) {
|
|
m := newTestModel(t)
|
|
|
|
cmd := m.submit("hello", false)
|
|
if cmd == nil {
|
|
t.Fatal("expected a cmd that sends the message")
|
|
}
|
|
cmd() // tea.Cmd ist nur func() tea.Msg
|
|
|
|
select {
|
|
case got := <-m.in:
|
|
if got.Text != "hello" {
|
|
t.Errorf("sent text = %q, want hello", got.Text)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for input to be sent")
|
|
}
|
|
if !m.turnActive {
|
|
t.Error("expected turnActive=true after sending a message")
|
|
}
|
|
}
|
|
|
|
func TestHandleUIEvent_TurnDoneFlushesFollowupQueue(t *testing.T) {
|
|
m := newTestModel(t)
|
|
m.turnActive = true
|
|
m.followupQueue = []string{"queued message"}
|
|
|
|
cmd := m.handleUIEvent(tool.TurnDone{Stop: llm.StopEnd})
|
|
if m.turnActive {
|
|
t.Error("expected turnActive=false after a terminal TurnDone")
|
|
}
|
|
if len(m.followupQueue) != 0 {
|
|
t.Errorf("expected queue drained, got %+v", m.followupQueue)
|
|
}
|
|
if cmd == nil {
|
|
t.Fatal("expected a cmd flushing the queued follow-up")
|
|
}
|
|
cmd()
|
|
select {
|
|
case got := <-m.in:
|
|
if got.Text != "queued message" {
|
|
t.Errorf("flushed text = %q", got.Text)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for queued follow-up to be sent")
|
|
}
|
|
}
|
|
|
|
func TestHandleUIEvent_ToolUseTurnDoneDoesNotFlushQueue(t *testing.T) {
|
|
m := newTestModel(t)
|
|
m.turnActive = true
|
|
m.followupQueue = []string{"queued message"}
|
|
|
|
m.handleUIEvent(tool.TurnDone{Stop: llm.StopToolUse})
|
|
if !m.turnActive {
|
|
t.Error("turn should still be active while tool_use continues")
|
|
}
|
|
if len(m.followupQueue) != 1 {
|
|
t.Errorf("queue should stay intact until the turn actually ends, got %+v", m.followupQueue)
|
|
}
|
|
}
|
|
|
|
func TestRunCommand_ExitReturnsQuitWithoutCancelingContext(t *testing.T) {
|
|
m := newTestModel(t)
|
|
|
|
cmd := m.runCommand("/exit")
|
|
if cmd == nil {
|
|
t.Fatal("expected /exit to return tea.Quit")
|
|
}
|
|
if !m.quitting {
|
|
t.Error("expected quitting=true")
|
|
}
|
|
// /exit darf den Kontext NICHT selbst canceln: der ist auch an
|
|
// tea.WithContext gebunden, ein Cancel von innen lässt Program.Run()
|
|
// mit einem "program was killed"-Fehler zurückkehren statt sauber über
|
|
// tea.Quit zu beenden (siehe run.go).
|
|
select {
|
|
case <-m.ctx.Done():
|
|
t.Error("/exit must not cancel the context itself, run.go does that after Program.Run() returns")
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestRunCommand_UnknownCommandIsAnError(t *testing.T) {
|
|
m := newTestModel(t)
|
|
m.runCommand("/bogus")
|
|
if len(m.entries) != 1 || m.entries[0].kind != entryError {
|
|
t.Errorf("entries = %+v, want a single entryError", m.entries)
|
|
}
|
|
}
|
|
|
|
func TestRunCommand_HelpListsOneCommandPerLineWithDescription(t *testing.T) {
|
|
m := newTestModel(t)
|
|
m.runCommand("/help")
|
|
if len(m.entries) != 1 || m.entries[0].kind != entryCommand {
|
|
t.Fatalf("entries = %+v, want a single entryCommand", m.entries)
|
|
}
|
|
text := m.entries[0].text
|
|
_, cmdSection, ok := strings.Cut(text, "Kommandos:\n")
|
|
if !ok {
|
|
t.Fatalf("help text missing \"Kommandos:\" header:\n%s", text)
|
|
}
|
|
lines := strings.Split(cmdSection, "\n")
|
|
if len(lines) != len(helpCommands) {
|
|
t.Fatalf("got %d command lines, want %d (one per command):\n%s", len(lines), len(helpCommands), text)
|
|
}
|
|
for _, c := range helpCommands {
|
|
if !strings.Contains(text, c.cmd) || !strings.Contains(text, c.desc) {
|
|
t.Errorf("help text missing %q / %q:\n%s", c.cmd, c.desc, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunCommand_SkillNameInvokesSkillAsDirective(t *testing.T) {
|
|
m := newTestModel(t)
|
|
m.skills = []skill.Skill{{Name: "refactoring", Description: "d"}}
|
|
|
|
cmd := m.runCommand("/refactoring focus on error handling")
|
|
if cmd == nil {
|
|
t.Fatal("expected a cmd sending the skill directive")
|
|
}
|
|
cmd()
|
|
|
|
if len(m.entries) != 1 || m.entries[0].kind != entryUser {
|
|
t.Fatalf("entries = %+v, want a single entryUser", m.entries)
|
|
}
|
|
got := m.entries[0].text
|
|
if !strings.Contains(got, `"refactoring"`) || !strings.Contains(got, "read_skill") {
|
|
t.Errorf("directive should name the skill and read_skill, got: %q", got)
|
|
}
|
|
if !strings.Contains(got, "focus on error handling") {
|
|
t.Errorf("directive should include the trailing args, got: %q", got)
|
|
}
|
|
|
|
select {
|
|
case sent := <-m.in:
|
|
if sent.Text != got {
|
|
t.Errorf("sent text = %q, want it to match the displayed directive %q", sent.Text, got)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for the skill directive to be sent")
|
|
}
|
|
}
|
|
|
|
func TestRunCommand_BuiltinCommandWinsOverSameNamedSkill(t *testing.T) {
|
|
m := newTestModel(t)
|
|
m.skills = []skill.Skill{{Name: "clear", Description: "a skill that happens to be named like a builtin"}}
|
|
|
|
m.entries = []entry{{kind: entryUser, text: "old"}}
|
|
m.runCommand("/clear")
|
|
|
|
if len(m.entries) != 0 {
|
|
t.Errorf("expected the builtin /clear to win and wipe entries, got %+v", m.entries)
|
|
}
|
|
}
|
|
|
|
func TestHelpText_ListsSkillsWhenPresent(t *testing.T) {
|
|
m := newTestModel(t)
|
|
m.skills = []skill.Skill{{Name: "refactoring", Description: "Vorgehen für Refactorings"}}
|
|
|
|
text := m.helpText()
|
|
if !strings.Contains(text, "/refactoring") || !strings.Contains(text, "Vorgehen für Refactorings") {
|
|
t.Errorf("help text should list the skill as a command, got: %q", text)
|
|
}
|
|
}
|
|
|
|
func TestRunCommand_ClearIsCommandNotError(t *testing.T) {
|
|
m := newTestModel(t)
|
|
m.runCommand("/help")
|
|
if len(m.entries) != 1 || m.entries[0].kind != entryCommand {
|
|
t.Errorf("entries = %+v, want a single entryCommand", m.entries)
|
|
}
|
|
}
|
|
|
|
func TestHandleUIEvent_TurnDoneAccumulatesCacheReadTokens(t *testing.T) {
|
|
m := newTestModelWithStore(t) // renderStatusLine liest m.store.ID
|
|
|
|
m.handleUIEvent(tool.TurnDone{Stop: llm.StopEnd, Usage: llm.Usage{InputTokens: 1200, OutputTokens: 5, CacheReadTokens: 896}})
|
|
if m.cacheReadTokens != 896 {
|
|
t.Errorf("cacheReadTokens = %d, want 896", m.cacheReadTokens)
|
|
}
|
|
if m.tokensUsed != 1205 {
|
|
t.Errorf("tokensUsed = %d, want 1205", m.tokensUsed)
|
|
}
|
|
|
|
m.handleUIEvent(tool.TurnDone{Stop: llm.StopEnd, Usage: llm.Usage{InputTokens: 1300, OutputTokens: 8, CacheReadTokens: 1100}})
|
|
if m.cacheReadTokens != 1996 {
|
|
t.Errorf("cacheReadTokens after second turn = %d, want 1996 (cumulative)", m.cacheReadTokens)
|
|
}
|
|
|
|
if !strings.Contains(m.renderStatusLine(), "cached: 1996") {
|
|
t.Errorf("status line should surface cumulative cache_read_tokens, got: %q", m.renderStatusLine())
|
|
}
|
|
}
|
|
|
|
func TestHandleUIEvent_StreamingTextAccumulatesAndCommits(t *testing.T) {
|
|
m := newTestModel(t)
|
|
|
|
m.handleUIEvent(tool.TextDelta{Text: "hello "})
|
|
m.handleUIEvent(tool.TextDelta{Text: "world"})
|
|
if !m.hasLiveText || m.liveText.String() != "hello world" {
|
|
t.Errorf("live text = %q, hasLiveText=%v", m.liveText.String(), m.hasLiveText)
|
|
}
|
|
|
|
m.commitLive()
|
|
if m.hasLiveText {
|
|
t.Error("expected live text cleared after commit")
|
|
}
|
|
if len(m.entries) != 1 || m.entries[0].kind != entryAssistant || m.entries[0].text != "hello world" {
|
|
t.Errorf("entries = %+v", m.entries)
|
|
}
|
|
}
|