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) } }