internal/core: ядро-машина состояний (ProcessTurn поверх storage) + история задач в БД
All checks were successful
build-test / build (push) Successful in 1m11s
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:
228
internal/core/core_test.go
Normal file
228
internal/core/core_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user