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

313 lines
7.9 KiB
Go

package tui
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"nub/internal/agent"
"nub/internal/llm"
"nub/internal/session"
"nub/internal/tool"
)
func enterKey() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyEnter} }
func escKey() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyEsc} }
func deleteKey() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyDelete} }
func yKey() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")} }
func nKey() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")} }
func newTestModelWithStore(t *testing.T) *Model {
t.Helper()
dir := t.TempDir()
store, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
return &Model{
ctx: context.Background(),
store: store,
textarea: textarea.New(),
viewport: viewport.New(80, 20),
spinner: spinner.New(),
}
}
func textMsg(role llm.Role, text string) llm.Message {
return llm.Message{Role: role, Content: []llm.Block{{Kind: llm.KindText, Text: text}}}
}
func findItem(items []listItem, id string) int {
for i, it := range items {
if it.id == id {
return i
}
}
return -1
}
func TestCmdOpenTree_MarksCurrentHead(t *testing.T) {
m := newTestModelWithStore(t)
n1 := session.NewNode("", textMsg(llm.RoleUser, "first"), session.NodeMeta{})
if err := m.store.Append(n1); err != nil {
t.Fatal(err)
}
n2 := session.NewNode(n1.ID, textMsg(llm.RoleAssistant, "second"), session.NodeMeta{})
if err := m.store.Append(n2); err != nil {
t.Fatal(err)
}
m.cmdOpenTree()
if m.modal == nil {
t.Fatal("expected a modal to be opened")
}
if len(m.modal.items) != 2 {
t.Fatalf("got %d items, want 2", len(m.modal.items))
}
if m.modal.items[m.modal.cursor].id != n2.ID {
t.Errorf("cursor should default to the current head (%s), got %s", n2.ID, m.modal.items[m.modal.cursor].id)
}
}
func TestHandleModalKey_EnterBranchesToSelectedNode(t *testing.T) {
m := newTestModelWithStore(t)
n1 := session.NewNode("", textMsg(llm.RoleUser, "first"), session.NodeMeta{})
if err := m.store.Append(n1); err != nil {
t.Fatal(err)
}
n2 := session.NewNode(n1.ID, textMsg(llm.RoleAssistant, "second"), session.NodeMeta{})
if err := m.store.Append(n2); err != nil {
t.Fatal(err)
}
m.cmdOpenTree()
m.modal.cursor = findItem(m.modal.items, n1.ID) // n1 auswählen, nicht den aktuellen Head n2
m.handleModalKey(enterKey())
if m.modal != nil {
t.Error("expected modal to close after selection")
}
if m.store.Head != n1.ID {
t.Errorf("head = %s, want %s (branch to selected node)", m.store.Head, n1.ID)
}
}
func TestHandleModalKey_EscClosesWithoutBranching(t *testing.T) {
m := newTestModelWithStore(t)
n1 := session.NewNode("", textMsg(llm.RoleUser, "first"), session.NodeMeta{})
if err := m.store.Append(n1); err != nil {
t.Fatal(err)
}
originalHead := m.store.Head
m.cmdOpenTree()
m.handleModalKey(escKey())
if m.modal != nil {
t.Error("expected modal to close on esc")
}
if m.store.Head != originalHead {
t.Error("esc must not change the head")
}
}
func newTestModelForSessions(t *testing.T, dir string, store *session.Store) *Model {
t.Helper()
return &Model{
ctx: context.Background(),
store: store,
loop: &agent.Loop{Env: tool.Env{RepoRoot: dir}},
textarea: textarea.New(),
viewport: viewport.New(80, 20),
spinner: spinner.New(),
}
}
func TestCmdSessions_ListsAllAndMarksActive(t *testing.T) {
dir := t.TempDir()
active, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { active.Close() })
other, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
other.Close()
m := newTestModelForSessions(t, dir, active)
m.cmdSessions()
if m.modal == nil {
t.Fatal("expected a modal to be opened")
}
if len(m.modal.items) != 2 {
t.Fatalf("got %d items, want 2", len(m.modal.items))
}
idx := findItem(m.modal.items, active.ID)
if idx == -1 {
t.Fatal("active session missing from list")
}
if !m.modal.items[idx].protect || m.modal.items[idx].marker != "(aktiv)" {
t.Errorf("active session item = %+v, want protected and marked", m.modal.items[idx])
}
if m.modal.cursor != idx {
t.Errorf("cursor = %d, want %d (default to active session)", m.modal.cursor, idx)
}
}
func TestHandleModalKey_DeleteRequiresConfirmation(t *testing.T) {
dir := t.TempDir()
active, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { active.Close() })
other, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
other.Close()
m := newTestModelForSessions(t, dir, active)
m.cmdSessions()
m.modal.cursor = findItem(m.modal.items, other.ID)
m.handleModalKey(deleteKey())
if !m.modal.confirmDelete {
t.Fatal("expected Del to arm a confirmation, not delete immediately")
}
if _, err := session.List(dir); err != nil {
t.Fatal(err)
}
m.handleModalKey(nKey())
if m.modal == nil || m.modal.confirmDelete {
t.Error("expected 'n' to cancel the confirmation without closing the modal")
}
infos, err := session.List(dir)
if err != nil {
t.Fatal(err)
}
if len(infos) != 2 {
t.Errorf("declining the confirmation must not delete anything, got %d sessions", len(infos))
}
}
func TestHandleModalKey_DeleteConfirmedRemovesSession(t *testing.T) {
dir := t.TempDir()
active, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { active.Close() })
other, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
other.Close()
m := newTestModelForSessions(t, dir, active)
m.cmdSessions()
m.modal.cursor = findItem(m.modal.items, other.ID)
m.handleModalKey(deleteKey())
m.handleModalKey(yKey())
if m.modal != nil {
t.Error("expected modal to close after a confirmed delete")
}
infos, err := session.List(dir)
if err != nil {
t.Fatal(err)
}
if len(infos) != 1 || infos[0].ID != active.ID {
t.Errorf("expected only the active session left, got %+v", infos)
}
}
func TestHandleModalKey_CannotDeleteActiveSession(t *testing.T) {
dir := t.TempDir()
active, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { active.Close() })
m := newTestModelForSessions(t, dir, active)
m.cmdSessions()
// Nur eine Session vorhanden -> Cursor steht zwangsläufig auf der aktiven.
m.handleModalKey(deleteKey())
if m.modal.confirmDelete {
t.Error("expected the active session to be protected from even arming a delete")
}
infos, err := session.List(dir)
if err != nil {
t.Fatal(err)
}
if len(infos) != 1 {
t.Error("active session must not be deletable")
}
}
func TestCmdInit_CreatesFilesAndReportsResult(t *testing.T) {
dir := t.TempDir()
store, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
m := newTestModelForSessions(t, dir, store)
m.runCommand("/init")
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
if !strings.Contains(text, "angelegt") {
t.Errorf("expected the report to mention what was created, got: %q", text)
}
if _, err := os.Stat(filepath.Join(dir, ".nub", "config.toml")); err != nil {
t.Errorf(".nub/config.toml missing: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "AGENTS.md")); err != nil {
t.Errorf("AGENTS.md missing: %v", err)
}
}
func TestCmdInit_SecondRunSkipsExistingFiles(t *testing.T) {
dir := t.TempDir()
store, err := session.Create(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
m := newTestModelForSessions(t, dir, store)
m.runCommand("/init")
m.entries = nil
m.runCommand("/init")
if len(m.entries) != 1 {
t.Fatalf("entries = %+v", m.entries)
}
if !strings.Contains(m.entries[0].text, "übersprungen") {
t.Errorf("expected the second run to report skipped files, got: %q", m.entries[0].text)
}
}