Files
ratatoskr-go/internal/core/core_test.go
Hermes 2957bf0d63
All checks were successful
build-test / build (push) Successful in 1m11s
internal/core: ядро-машина состояний (ProcessTurn поверх storage) + история задач в БД
- status = единственный источник истины (collecting/ready как фазы диалога)
- команды /start /cancel /skip /retry N /status N /continue N
- Decider интерфейс (аналитик→opencode, мок для тестов)
- task_history таблица для промпта аналитика
- allow ready→collecting (правка черновика)
2026-08-15 00:27:16 +05:00

228 lines
6.7 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 core
import (
"context"
"testing"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
// mockDecider — тестовый Decider с заданным поведением.
type mockDecider struct {
fn func(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error)
}
func (m *mockDecider) Decide(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error) {
if m.fn != nil {
return m.fn(ctx, history, draft, force)
}
return Decision{Phase: "ask", ChatReply: "ok"}, nil
}
func setupCore(t *testing.T, fn func(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error)) (*Core, context.Context, *storage.Storage) {
t.Helper()
ctx := context.Background()
store, err := storage.Open(ctx, ":memory:")
if err != nil {
t.Fatalf("storage.Open: %v", err)
}
t.Cleanup(func() { store.Close() })
c := New(store, &mockDecider{fn: fn})
return c, ctx, store
}
func mkTask(t *testing.T, store *storage.Storage, ctx context.Context, chatID string) int64 {
t.Helper()
id, err := store.CreateTask(ctx, &storage.Task{ChatID: chatID, TaskTag: "tag-" + chatID})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
return id
}
func TestStartCreatesCollecting(t *testing.T) {
c, ctx, store := setupCore(t, nil)
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "/start")
if err != nil {
t.Fatalf("ProcessTurn /start: %v", err)
}
if res.Action != "greeting" {
t.Fatalf("action = %q, want greeting", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusCollecting {
t.Fatalf("status = %s, want collecting", task.Status)
}
}
func TestCancelSetsCancelled(t *testing.T) {
c, ctx, store := setupCore(t, nil)
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "/cancel")
if err != nil {
t.Fatalf("ProcessTurn /cancel: %v", err)
}
if res.Action != "drop" {
t.Fatalf("action = %q, want drop", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusCancelled {
t.Fatalf("status = %s, want cancelled", task.Status)
}
}
func TestSingleTurnPropose(t *testing.T) {
c, ctx, store := setupCore(t, func(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error) {
return Decision{
Phase: "propose",
ChatReply: "Готово",
Draft: storage.Task{Title: "Новое", Goal: "Сделать", Repo: "acme/app"},
}, nil
})
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "Сделай калькулятор")
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "summary" {
t.Fatalf("action = %q, want summary", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusReady {
t.Fatalf("status = %s, want ready", task.Status)
}
if task.Title != "Новое" {
t.Fatalf("title = %q, want Новое", task.Title)
}
}
func TestAskReturnsQuestions(t *testing.T) {
c, ctx, store := setupCore(t, func(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error) {
return Decision{
Phase: "ask",
ChatReply: "Уточню",
Questions: []string{"Какой язык?", "Какой срок?"},
}, nil
})
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "Напиши парсер")
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "send" {
t.Fatalf("action = %q, want send", res.Action)
}
if res.Reply != "Уточню\n1. Какой язык?\n2. Какой срок?" {
t.Fatalf("reply = %q", res.Reply)
}
// в фазе collecting — продолжaeм сбор
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusCollecting {
t.Fatalf("status = %s, want collecting", task.Status)
}
}
func TestAbortReturnsDrop(t *testing.T) {
c, ctx, store := setupCore(t, func(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error) {
return Decision{Phase: "abort", ChatReply: "Не хватает данных"}, nil
})
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "привет")
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "drop" {
t.Fatalf("action = %q, want drop", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusAborted {
t.Fatalf("status = %s, want aborted", task.Status)
}
}
func TestConsentInReady(t *testing.T) {
c, ctx, store := setupCore(t, func(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error) {
return Decision{Phase: "propose", Draft: storage.Task{Title: "X"}}, nil
})
id := mkTask(t, store, ctx, "u1")
_, _ = c.ProcessTurn(ctx, id, "сделай задачу")
res, err := c.ProcessTurn(ctx, id, "создавай")
if err != nil {
t.Fatalf("ProcessTurn создавай: %v", err)
}
if res.Action != "created:"+itoa(id) {
t.Fatalf("action = %q, want created:%d", res.Action, id)
}
}
func TestEditInReadyGoesCollecting(t *testing.T) {
var calls int
c, ctx, store := setupCore(t, func(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error) {
calls++
return Decision{Phase: "propose", Draft: storage.Task{Title: "X"}}, nil
})
id := mkTask(t, store, ctx, "u1")
_, _ = c.ProcessTurn(ctx, id, "сделай X")
// в ready пишем правку, не согласие
res, err := c.ProcessTurn(ctx, id, "нет, лучше Y")
if err != nil {
t.Fatalf("ProcessTurn edit: %v", err)
}
if res.Action != "summary" {
t.Fatalf("action = %q, want summary", res.Action)
}
if calls != 2 {
t.Fatalf("decide calls = %d, want 2", calls)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusReady {
t.Fatalf("status = %s, want ready", task.Status)
}
}
func TestRetryNotFound(t *testing.T) {
c, ctx, _ := setupCore(t, nil)
res, err := c.ProcessTurn(ctx, 999, "/retry 999")
if err != nil {
t.Fatalf("ProcessTurn /retry: %v", err)
}
if res.Action != "send" {
t.Fatalf("action = %q, want send", res.Action)
}
}
func TestUnknownCommand(t *testing.T) {
c, ctx, store := setupCore(t, nil)
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "/bogus")
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "send" {
t.Fatalf("action = %q, want send", res.Action)
}
}
func TestParseTaskID(t *testing.T) {
if v, ok := parseTaskID("5"); !ok || v != 5 {
t.Fatalf("parseTaskID(5) = %d,%v", v, ok)
}
if v, ok := parseTaskID("123"); !ok || v != 123 {
t.Fatalf("parseTaskID(123) = %d,%v", v, ok)
}
if _, ok := parseTaskID(""); ok {
t.Fatal("parseTaskID('') should be invalid")
}
if _, ok := parseTaskID("abc"); ok {
t.Fatal("parseTaskID(abc) should be invalid")
}
}