package analyst import ( "context" "errors" "testing" "github.com/kamelion/ratatoskr-go/internal/core" "github.com/kamelion/ratatoskr-go/internal/opencode" "github.com/kamelion/ratatoskr-go/internal/storage" ) // mockRunner — тестовый OpenCodeRunner с задаваемым поведением. type mockRunner struct { result *opencode.Result err error } func (m *mockRunner) Run(_ context.Context, _, _, _, _ string) (*opencode.Result, error) { return m.result, m.err } func TestDecideAsk(t *testing.T) { a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ RC: 0, Stdout: `{"type":"text","part":{"text":"{\"phase\":\"ask\",\"chat_reply\":\"Уточню про репозиторий\",\"questions\":[\"Где лежит код?\",\"Какая цель?\"]}"}}`, }}, Worktree: "/tmp"} history := []core.Message{{Role: "user", Content: "Сделай калькулятор"}} dec, err := a.Decide(context.Background(), history, storage.Task{}, false) if err != nil { t.Fatalf("Decide err: %v", err) } if dec.Phase != "ask" { t.Errorf("Phase = %q, want ask", dec.Phase) } if len(dec.Questions) != 2 { t.Errorf("len(Questions) = %d, want 2", len(dec.Questions)) } if dec.ChatReply == "" { t.Error("ChatReply пустой") } } func TestDecidePropose(t *testing.T) { a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ RC: 0, Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"title\":\"Калькулятор\",\"goal\":\"Сделать веб-калькулятор\",\"repo\":\"tools/calc\",\"why\":\"Нужен для учёта\",\"ac\":\"Работает + - * /\",\"chat_reply\":\"Готово!\"}"}}`, }}, Worktree: "/tmp"} history := []core.Message{ {Role: "user", Content: "Сделай калькулятор"}, {Role: "assistant", Content: "Где репозиторий?"}, {Role: "user", Content: "tools/calc"}, } dec, err := a.Decide(context.Background(), history, storage.Task{}, false) if err != nil { t.Fatalf("Decide err: %v", err) } if dec.Phase != "propose" { t.Errorf("Phase = %q, want propose", dec.Phase) } if dec.Draft.Title != "Калькулятор" { t.Errorf("Draft.Title = %q, want Калькулятор", dec.Draft.Title) } if dec.Draft.Repo != "tools/calc" { t.Errorf("Draft.Repo = %q", dec.Draft.Repo) } } func TestDecideAbort(t *testing.T) { a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ RC: 0, Stdout: `{"type":"text","part":{"text":"{\"phase\":\"abort\",\"chat_reply\":\"Это не про код.\",\"abort_reason\":\"Тема не подходит opencode\"}"}}`, }}, Worktree: "/tmp"} history := []core.Message{{Role: "user", Content: "Почини принтер"}} dec, err := a.Decide(context.Background(), history, storage.Task{}, false) if err != nil { t.Fatalf("Decide err: %v", err) } if dec.Phase != "abort" { t.Errorf("Phase = %q, want abort", dec.Phase) } if dec.ChatReply != "Это не про код." { t.Errorf("ChatReply = %q", dec.ChatReply) } } func TestDecodeFail(t *testing.T) { a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ RC: 0, Stdout: `{"type":"text","part":{"text":"не JSON, а просто текст"}}`, }}, Worktree: "/tmp"} history := []core.Message{{Role: "user", Content: "тест"}} _, err := a.Decide(context.Background(), history, storage.Task{}, false) if err == nil { t.Fatal("expected A1 error") } if !errors.Is(err, ErrDecodeFail) { t.Errorf("err = %v, want A1", err) } } func TestRunError(t *testing.T) { a := &Analyst{Runner: &mockRunner{err: errors.New("opencode not found")}, Worktree: "/tmp"} history := []core.Message{{Role: "user", Content: "тест"}} _, err := a.Decide(context.Background(), history, storage.Task{}, false) if err == nil { t.Fatal("expected A2 error") } if !errors.Is(err, ErrRunError) { t.Errorf("err = %v, want A2", err) } } func TestEmptyHistory(t *testing.T) { a := &Analyst{Runner: &mockRunner{result: &opencode.Result{RC: 0}}} _, err := a.Decide(context.Background(), nil, storage.Task{}, false) if err == nil { t.Fatal("expected A4 error") } if !errors.Is(err, ErrNotReady) { t.Errorf("err = %v, want A4", err) } } func TestForceEmptyHistory(t *testing.T) { a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ RC: 0, Stdout: `{"type":"text","part":{"text":"{\"phase\":\"ask\",\"chat_reply\":\"Опишите задачу.\"}"}}`, }}, Worktree: "/tmp"} _, err := a.Decide(context.Background(), nil, storage.Task{}, true) if err != nil { t.Fatalf("Decide(force=true) err: %v", err) } } func TestInvalidPhase(t *testing.T) { a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ RC: 0, Stdout: `{"type":"text","part":{"text":"{\"phase\":\"unknown\"}"}}`, }}, Worktree: "/tmp"} history := []core.Message{{Role: "user", Content: "test"}} _, err := a.Decide(context.Background(), history, storage.Task{}, false) if err == nil { t.Fatal("expected A3 error") } if !errors.Is(err, ErrValidation) { t.Errorf("err = %v, want A3", err) } } func TestNonZeroExit(t *testing.T) { a := &Analyst{Runner: &mockRunner{result: &opencode.Result{RC: 1, Stdout: "fail"}}, Worktree: "/tmp"} history := []core.Message{{Role: "user", Content: "test"}} _, err := a.Decide(context.Background(), history, storage.Task{}, false) if err == nil { t.Fatal("expected A2 error for rc=1") } if !errors.Is(err, ErrRunError) { t.Errorf("err = %v, want A2", err) } } func TestProposeEmptyFields(t *testing.T) { // propose без изменённых полей — A3 a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ RC: 0, Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"chat_reply\":\"ok\"}"}}`, }}, Worktree: "/tmp"} history := []core.Message{{Role: "user", Content: "test"}} _, err := a.Decide(context.Background(), history, storage.Task{}, false) if err == nil { t.Fatal("expected A3 error for propose with no fields") } if !errors.Is(err, ErrValidation) { t.Errorf("err = %v, want A3", err) } } func TestAskEmptyReplyAndQuestions(t *testing.T) { // ask без chat_reply и questions — A3 a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ RC: 0, Stdout: `{"type":"text","part":{"text":"{\"phase\":\"ask\"}"}}`, }}, Worktree: "/tmp"} history := []core.Message{{Role: "user", Content: "test"}} _, err := a.Decide(context.Background(), history, storage.Task{}, false) if err == nil { t.Fatal("expected A3 error for empty ask") } if !errors.Is(err, ErrValidation) { t.Errorf("err = %v, want A3", err) } }