internal/core: ядро-машина состояний (ProcessTurn поверх storage) + история задач в БД
All checks were successful
build-test / build (push) Successful in 1m11s

- status = единственный источник истины (collecting/ready как фазы диалога)
- команды /start /cancel /skip /retry N /status N /continue N
- Decider интерфейс (аналитик→opencode, мок для тестов)
- task_history таблица для промпта аналитика
- allow ready→collecting (правка черновика)
This commit is contained in:
Hermes
2026-08-15 00:27:16 +05:00
parent 089e196dbc
commit 2957bf0d63
8 changed files with 866 additions and 1 deletions

228
internal/core/core_test.go Normal file
View File

@@ -0,0 +1,228 @@
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")
}
}