Files
ratatoskr-go/internal/analyst/analyst_test.go
ki.sagidullin 75132bc5f5
Some checks failed
CI / test (push) Failing after 1m56s
CI / build-and-package (amd64, linux) (push) Failing after 1m7s
CI / build-and-package (amd64, windows) (push) Successful in 30s
fix(analyst): steps устойчив к строке — не роняет decide при string-steps (аналог repos)
2026-08-24 22:42:42 +05:00

392 lines
14 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)
}
}
// TestDecideProposeStringSteps — модель вернула steps строкой (а не массивом):
// парсер должен нормализовать и не падать (аналог string-repos).
func TestDecideProposeStringSteps(t *testing.T) {
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
RC: 0,
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"title\":\"Калькулятор\",\"steps\":\"\"}"}}`,
}}, 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) != 0 {
t.Errorf("len(Steps) = %d, want 0 (пустая строка игнорируется)", len(dec.Draft.Steps))
}
}
// TestDecideProposeJSONStepsString — steps пришли строкой с вложенным JSON-массивом.
func TestDecideProposeJSONStepsString(t *testing.T) {
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
RC: 0,
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"steps\":\"[{\\\"title\\\":\\\"Модель\\\",\\\"ac\\\":\\\"ок\\\"}]\"}"}}`,
}}, 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 || dec.Draft.Steps[0].Title != "Модель" {
t.Errorf("Steps = %#v, want [Модель]", dec.Draft.Steps)
}
}
// 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)
}
}
})
}
}