Files
ratatoskr-go/internal/analyst/analyst_test.go
ki.sagidullin 6c0b903794
Some checks failed
CI / test (push) Failing after 1m52s
CI / build-and-package (amd64, linux) (push) Failing after 1m3s
CI / build-and-package (amd64, windows) (push) Successful in 30s
feat(analyst): этапы задачи — аналитик раскладывает на steps с критериями готовности, dev идёт по ним
2026-08-24 18:05:06 +05:00

351 lines
12 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"
"encoding/json"
"errors"
"strings"
"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 TestDecideReady(t *testing.T) {
// ready с пустыми изменёнными полями — ВАЛИДНО (черновик готов как есть)
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
RC: 0,
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"ready\",\"chat_reply\":\"Черновик готов, запускаю.\"}"}}`,
}}, 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 != "ready" {
t.Errorf("Phase = %q, want ready", dec.Phase)
}
}
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)
}
}
// TestDecideProposeStringRepos — модель вернула repos строкой (а не массивом):
// парсер должен нормализовать и не падать.
func TestDecideProposeStringRepos(t *testing.T) {
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
RC: 0,
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"title\":\"Калькулятор\",\"repos\":\"tools/calc, tools/ui\"}"}}`,
}}, 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 != "propose" {
t.Errorf("Phase = %q, want propose", dec.Phase)
}
if dec.Draft.Repos[0] != "tools/calc" || dec.Draft.Repos[1] != "tools/ui" {
t.Errorf("Repos = %#v, want [tools/calc tools/ui]", dec.Draft.Repos)
}
}
// TestDecideProposeSteps — аналитик разложил задачу на этапы с критериями.
func TestDecideProposeSteps(t *testing.T) {
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
RC: 0,
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"title\":\"Калькулятор\",\"steps\":[{\"title\":\"Модель\",\"ac\":\"операции + - * /\"},{\"title\":\"UI\"}]}"}}`,
}}, 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 != "propose" {
t.Errorf("Phase = %q, want propose", dec.Phase)
}
if len(dec.Draft.Steps) != 2 {
t.Fatalf("len(Steps) = %d, want 2", len(dec.Draft.Steps))
}
if dec.Draft.Steps[0].Title != "Модель" || dec.Draft.Steps[0].AC != "операции + - * /" {
t.Errorf("Steps[0] = %q/%q, want Модель/операции + - * /", dec.Draft.Steps[0].Title, dec.Draft.Steps[0].AC)
}
if dec.Draft.Steps[1].Title != "UI" || dec.Draft.Steps[1].AC != "" {
t.Errorf("Steps[1] = %q/%q, want UI/(пусто)", dec.Draft.Steps[1].Title, dec.Draft.Steps[1].AC)
}
}
// TestProposeOnlyStepsValid — propose меняет только steps → валидно.
func TestProposeOnlyStepsValid(t *testing.T) {
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
RC: 0,
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"steps\":[{\"title\":\"Шаг 1\"}],\"chat_reply\":\"Разбил на этапы\"}"}}`,
}}, Worktree: "/tmp"}
history := []core.Message{{Role: "user", Content: "test"}}
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 len(dec.Draft.Steps) != 1 {
t.Fatalf("len(Steps) = %d, want 1", len(dec.Draft.Steps))
}
}
// TestFormatVerdict — человекочитаемое описание вердикта аналитика.
func TestFormatVerdict(t *testing.T) {
tests := []struct {
name string
ar *AnalystResponse
want []string
}{
{
name: "propose with fields",
ar: &AnalystResponse{
Phase: "propose",
Title: "Калькулятор",
Goal: "Сделать веб-калькулятор",
Repos: json.RawMessage(`["tools/calc","tools/ui"]`),
Why: "Нужен для учёта",
AC: "Работает + - * /",
ChatReply: "Готово!",
},
want: []string{
"phase=propose",
"chat_reply=Готово!",
"repos=[tools/calc, tools/ui]",
"title=Калькулятор",
"goal=Сделать веб-калькулятор",
"why=Нужен для учёта",
"ac=Работает + - * /",
},
},
{
name: "ask with questions",
ar: &AnalystResponse{
Phase: "ask",
ChatReply: "Уточню",
Questions: []string{"Где код?", "Какая цель?"},
},
want: []string{"phase=ask", "chat_reply=Уточню", "questions=[Где код? | Какая цель?]"},
},
{
name: "abort with reason",
ar: &AnalystResponse{
Phase: "abort",
AbortReason: "Тема не про код",
},
want: []string{"phase=abort", "abort_reason=Тема не про код"},
},
{
name: "empty verdict",
ar: &AnalystResponse{},
want: []string{"phase="},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatVerdict(tt.ar)
for _, w := range tt.want {
if !strings.Contains(got, w) {
t.Errorf("formatVerdict = %q, want contain %q", got, w)
}
}
})
}
}