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 lines := strings.Split(text, "\n") if len(lines) != len(helpCommands)+1 { // +1 für die "Kommandos:"-Kopfzeile t.Fatalf("got %d lines, want %d (one per command + header):\n%s", len(lines), len(helpCommands)+1, 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) } }