package analyst import ( "context" "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) } } // 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: []string{"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) } } }) } }