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) } } }) } }