nub/internal/ui/tui/model_test.go
Tom a97013d876 initial commit
- v 0.1.0 siehe CHANGELOG.md
2026-07-25 11:08:01 +02:00

276 lines
8.1 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
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)
}
}