Files
ratatoskr-go/internal/analyst/analyst_test.go
Hermes dfe374dd55
All checks were successful
build-test / build (push) Successful in 1m15s
internal/analyst: Decider через opencode (A1–A4)
- Analyst struct: строит Go-template промпт из history+draft
- opencode.Runner.Run (agent=analyst) → ExtractVerdict → ExtractJSON
- Валидация phase/полей → core.Decision
- A1 DecodeFail, A2 RunError, A3 Validation, A4 NotReady
- MockRunner в тестах (11 тестов, зелёные)
- core.runDecide оборачивает ошибки аналитика в D1 ErrDecideFailed
- allow ready→collecting (фикс для правки готового черновика)
2026-08-15 00:32:49 +05:00

201 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}