313 lines
7.9 KiB
Go
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)
|
|
}
|
|
}
|