373 lines
14 KiB
Go
373 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/phraser"
|
|
"github.com/kami/maven/internal/router"
|
|
)
|
|
|
|
// taskAPI answers only the three task methods; every other call is
|
|
// unimplemented, which is the assertion that capture needs nothing else — in
|
|
// particular no embedder, so a filed task costs no model call.
|
|
type taskAPI struct {
|
|
ipc.UnimplementedCoreAPI
|
|
|
|
captured []ipc.CaptureTaskReq
|
|
created bool
|
|
promoted bool
|
|
capErr error
|
|
|
|
tasks []ipc.Task
|
|
listArg string
|
|
listErr error
|
|
|
|
moved []setStatusCall
|
|
moveErr error
|
|
}
|
|
|
|
func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
|
a.captured = append(a.captured, req)
|
|
if a.capErr != nil {
|
|
return ipc.CaptureTaskResp{}, a.capErr
|
|
}
|
|
return ipc.CaptureTaskResp{ID: 1, Created: a.created, Promoted: a.promoted}, nil
|
|
}
|
|
|
|
func (a *taskAPI) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
|
a.listArg = status
|
|
return a.tasks, a.listErr
|
|
}
|
|
|
|
func taskNow() time.Time { return time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) }
|
|
|
|
func taskHandler(api ipc.CoreAPI) *reactiveHandler {
|
|
return &reactiveHandler{api: api, now: taskNow}
|
|
}
|
|
|
|
func TestCaptureTaskFromNoteFilesTheTask(t *testing.T) {
|
|
api := &taskAPI{created: true}
|
|
h := taskHandler(api)
|
|
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
|
Intent: router.IntentNote, Utterance: "добавь в задачи купить молоко",
|
|
})
|
|
if !ok {
|
|
t.Fatal("an explicit capture must claim the turn")
|
|
}
|
|
if len(api.captured) != 1 {
|
|
t.Fatalf("captured %d, want 1", len(api.captured))
|
|
}
|
|
got := api.captured[0]
|
|
if got.Text != "купить молоко" {
|
|
t.Errorf("text = %q, want the marker stripped", got.Text)
|
|
}
|
|
if got.Source != "tap:voice" {
|
|
t.Errorf("source = %q, want tap:voice", got.Source)
|
|
}
|
|
if got.Status != "open" {
|
|
t.Errorf("status = %q — work he stated is open, never a candidate", got.Status)
|
|
}
|
|
if !got.Ts.Equal(taskNow()) {
|
|
t.Errorf("ts = %v, want the handler clock", got.Ts)
|
|
}
|
|
if !strings.Contains(reply, "купить молоко") {
|
|
t.Errorf("reply = %q, want it to read the task back", reply)
|
|
}
|
|
}
|
|
|
|
// A note is still a note: capture only fires on an explicit marker, so
|
|
// ordinary recall is untouched.
|
|
func TestCaptureTaskFromNotePassesOrdinaryNotes(t *testing.T) {
|
|
api := &taskAPI{}
|
|
h := taskHandler(api)
|
|
for _, u := range []string{"надо бы поспать", "мне понравился этот фильм", "запиши что я пил воду"} {
|
|
if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: u}); ok {
|
|
t.Errorf("%q was captured as a task", u)
|
|
}
|
|
}
|
|
if len(api.captured) != 0 {
|
|
t.Errorf("captured %d requests, want none", len(api.captured))
|
|
}
|
|
}
|
|
|
|
func TestCaptureTaskFromNoteSaysAlreadyOnTheList(t *testing.T) {
|
|
h := taskHandler(&taskAPI{created: false})
|
|
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь в задачи купить молоко"})
|
|
if !ok {
|
|
t.Fatal("expected the capture path to claim it")
|
|
}
|
|
if !strings.Contains(reply, "уже") {
|
|
t.Errorf("reply = %q — a deduped capture must not claim it saved something new", reply)
|
|
}
|
|
}
|
|
|
|
func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
|
|
h := taskHandler(&taskAPI{capErr: errors.New("db is on fire")})
|
|
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь задачу починить кран"})
|
|
if !ok {
|
|
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
|
|
}
|
|
if !phraser.IsAck(phraser.FailTask, nil, reply) {
|
|
t.Errorf("reply = %q, want an honest failure", reply)
|
|
}
|
|
}
|
|
|
|
func TestQueryTasksRecitesTheLiveList(t *testing.T) {
|
|
api := &taskAPI{tasks: []ipc.Task{
|
|
{ID: 1, Text: "купить молоко", Status: "open"},
|
|
{ID: 2, Text: "продлить страховку", Status: "candidate"},
|
|
}}
|
|
h := taskHandler(api)
|
|
reply, ok := h.queryTasks(context.Background(), &queryTurn{
|
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"},
|
|
})
|
|
if !ok {
|
|
t.Fatal("the task source must claim a task-list question")
|
|
}
|
|
if api.listArg != "live" {
|
|
t.Errorf("ListTasks(%q), want \"live\" — a resolved task is not outstanding work", api.listArg)
|
|
}
|
|
if !strings.Contains(reply, "купить молоко") || !strings.Contains(reply, "продлить страховку") {
|
|
t.Errorf("reply = %q, want both tasks", reply)
|
|
}
|
|
// The candidate must be named as unconfirmed, not recited as his work.
|
|
openIdx := strings.Index(reply, "купить молоко")
|
|
candIdx := strings.Index(reply, "продлить страховку")
|
|
if !(openIdx < candIdx) {
|
|
t.Errorf("reply = %q, want confirmed work before candidates", reply)
|
|
}
|
|
if !strings.Contains(reply, "не подтверждал") {
|
|
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
|
|
}
|
|
}
|
|
|
|
// The stated urgency rides through capture as a weight, so the ranker can use
|
|
// it later (Vikunja #129). "срочно" is not part of the task text.
|
|
func TestCaptureTaskCarriesStatedUrgency(t *testing.T) {
|
|
api := &taskAPI{created: true}
|
|
h := taskHandler(api)
|
|
if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
|
Utterance: "добавь в задачи срочно оплатить интернет",
|
|
}); !ok {
|
|
t.Fatal("expected a capture")
|
|
}
|
|
got := api.captured[0]
|
|
if got.Text != "оплатить интернет" {
|
|
t.Errorf("text = %q, want the urgency word out of the task", got.Text)
|
|
}
|
|
if got.Weight == 0 {
|
|
t.Error("weight = 0 — he said срочно and it was dropped")
|
|
}
|
|
}
|
|
|
|
// The recital is ordered by the ranker, not by insertion: a deadline he named
|
|
// comes before undated work.
|
|
func TestQueryTasksRecitesInPriorityOrder(t *testing.T) {
|
|
due := taskNow()
|
|
api := &taskAPI{tasks: []ipc.Task{
|
|
{ID: 1, Text: "купить молоко", Status: "open", CreatedTs: taskNow()},
|
|
{ID: 2, Text: "оплатить интернет", Status: "open", CreatedTs: taskNow(), Due: &due},
|
|
}}
|
|
h := taskHandler(api)
|
|
reply, _ := h.queryTasks(context.Background(), &queryTurn{
|
|
dec: router.Decision{Utterance: "какие у меня задачи?"},
|
|
})
|
|
if strings.Index(reply, "оплатить интернет") > strings.Index(reply, "купить молоко") {
|
|
t.Errorf("reply = %q, want the dated task first", reply)
|
|
}
|
|
if !strings.Contains(reply, "сегодня") {
|
|
t.Errorf("reply = %q, want the reason named", reply)
|
|
}
|
|
}
|
|
|
|
func TestQueryTasksEmptyList(t *testing.T) {
|
|
h := taskHandler(&taskAPI{})
|
|
reply, ok := h.queryTasks(context.Background(), &queryTurn{
|
|
dec: router.Decision{Utterance: "что мне нужно сделать?"},
|
|
})
|
|
if !ok {
|
|
t.Fatal("expected the task source to claim it")
|
|
}
|
|
if reply != "задач нет." {
|
|
t.Errorf("reply = %q", reply)
|
|
}
|
|
}
|
|
|
|
func TestQueryTasksPassesOtherQuestions(t *testing.T) {
|
|
api := &taskAPI{}
|
|
h := taskHandler(api)
|
|
for _, u := range []string{"как дела?", "какая погода в москве?", "что у меня сегодня?"} {
|
|
if _, ok := h.queryTasks(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok {
|
|
t.Errorf("the task source claimed %q", u)
|
|
}
|
|
}
|
|
if api.listArg != "" {
|
|
t.Error("a non-task question must not read the task list")
|
|
}
|
|
}
|
|
|
|
// The chain must reach the task source before the recall sources, or "что мне
|
|
// нужно сделать?" gets answered by whatever note is nearest.
|
|
func TestQuerySourcesOrderTasksBeforeRecall(t *testing.T) {
|
|
var tasksAt, notesAt = -1, -1
|
|
for i, src := range querySources {
|
|
switch src.name {
|
|
case "tasks":
|
|
tasksAt = i
|
|
case "notes":
|
|
notesAt = i
|
|
}
|
|
}
|
|
if tasksAt < 0 || notesAt < 0 {
|
|
t.Fatalf("sources missing: tasks=%d notes=%d", tasksAt, notesAt)
|
|
}
|
|
if tasksAt > notesAt {
|
|
t.Errorf("tasks source at %d, after notes at %d", tasksAt, notesAt)
|
|
}
|
|
}
|
|
|
|
// Saying a task out loud that Maven had only proposed is a confirmation. She
|
|
// used to answer "это уже в списке" and then read it back, in the same
|
|
// conversation, as something he had not confirmed.
|
|
func TestCaptureTaskFromNoteAcknowledgesAPromotion(t *testing.T) {
|
|
api := &taskAPI{promoted: true}
|
|
h := taskHandler(api)
|
|
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
|
Intent: router.IntentNote, Utterance: "добавь в задачи продлить страховку",
|
|
})
|
|
if !ok {
|
|
t.Fatal("an explicit capture must claim the turn")
|
|
}
|
|
if strings.Contains(reply, "уже в списке") {
|
|
t.Errorf("reply = %q — he just confirmed it, that is not a duplicate", reply)
|
|
}
|
|
if !strings.Contains(reply, "продлить страховку") {
|
|
t.Errorf("reply = %q, want the task named back", reply)
|
|
}
|
|
// Persona: feminine, informal.
|
|
for _, bad := range []string{"рад ", "вы ", "ваш"} {
|
|
if strings.Contains(reply, bad) {
|
|
t.Errorf("reply %q contains %q", reply, bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
// setStatusCall — one SetTaskStatus the arm made, in order, so a candidate he
|
|
// says is done can be shown to take both legal moves.
|
|
type setStatusCall struct {
|
|
id int64
|
|
status string
|
|
by string
|
|
}
|
|
|
|
func (a *taskAPI) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time, by string) error {
|
|
a.moved = append(a.moved, setStatusCall{id: id, status: status, by: by})
|
|
return a.moveErr
|
|
}
|
|
|
|
func TestResolveTaskStatusMovesTheNamedTask(t *testing.T) {
|
|
api := &taskAPI{tasks: []ipc.Task{
|
|
{ID: 7, Text: "купить молоко", Status: "open"},
|
|
{ID: 8, Text: "оплатить интернет", Status: "open"},
|
|
}}
|
|
h := taskHandler(api)
|
|
reply := h.resolveTaskStatus(context.Background(), router.Decision{
|
|
Intent: router.IntentAct,
|
|
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "молоко"},
|
|
}, routeCandidate(router.TaskStatusFn))
|
|
if api.listArg != "live" {
|
|
t.Errorf("listed %q, want live — a resolved task cannot be resolved again", api.listArg)
|
|
}
|
|
if len(api.moved) != 1 {
|
|
t.Fatalf("moved %d tasks, want 1: %+v", len(api.moved), api.moved)
|
|
}
|
|
if api.moved[0].id != 7 || api.moved[0].status != "done" {
|
|
t.Errorf("moved %+v, want id 7 → done", api.moved[0])
|
|
}
|
|
if !strings.Contains(reply, "купить молоко") {
|
|
t.Errorf("reply = %q, want the task named back", reply)
|
|
}
|
|
}
|
|
|
|
// The regression crosses the grammar/action seam instead of handing the
|
|
// action a repaired Decision. The stored title is a normal imperative title,
|
|
// while the spoken marker names only its topic; framing words must not become
|
|
// identity and the unrelated live row must remain untouched.
|
|
func TestActionActMarkerReferentMovesOnlyTheNamedStoredTask(t *testing.T) {
|
|
api := &taskAPI{tasks: []ipc.Task{
|
|
{ID: 17, Text: "настроить бэкапы", Status: "open"},
|
|
{ID: 18, Text: "обновить сертификаты", Status: "open"},
|
|
}}
|
|
h := taskHandler(api)
|
|
dec, matched, accepted := router.TaskStatusGrammar().Evaluate("отметь задачу про бэкапы как сделанную")
|
|
if !matched || !accepted {
|
|
t.Fatalf("task-status grammar matched=%v accepted=%v", matched, accepted)
|
|
}
|
|
|
|
reply := h.actionAct(context.Background(), dec)
|
|
if api.listArg != "live" {
|
|
t.Errorf("listed %q, want live", api.listArg)
|
|
}
|
|
if len(api.moved) != 1 {
|
|
t.Fatalf("moved %+v, want exactly the named stored task", api.moved)
|
|
}
|
|
if got := api.moved[0]; got.id != 17 || got.status != "done" || got.by != "tap:voice" {
|
|
t.Errorf("moved %+v, want task 17 → done by tap:voice", got)
|
|
}
|
|
if !strings.Contains(reply, "настроить бэкапы") {
|
|
t.Errorf("reply = %q, want the transitioned stored title", reply)
|
|
}
|
|
}
|
|
|
|
func TestResolveTaskStatusRefusesToGuess(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
tasks []ipc.Task
|
|
named string
|
|
want string
|
|
}{
|
|
{"no match", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "позвонить маме", "не нашла"},
|
|
{"two matches", []ipc.Task{
|
|
{ID: 7, Text: "купить молоко", Status: "open"},
|
|
{ID: 8, Text: "купить молоко и хлеб", Status: "open"},
|
|
}, "купить молоко", "несколько"},
|
|
{"none named", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "", "какую"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
api := &taskAPI{tasks: c.tasks}
|
|
h := taskHandler(api)
|
|
reply := h.resolveTaskStatus(context.Background(), router.Decision{
|
|
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: c.named},
|
|
}, routeCandidate(router.TaskStatusFn))
|
|
if len(api.moved) != 0 {
|
|
t.Errorf("moved %+v — closing the wrong task is the failure this arm exists to avoid", api.moved)
|
|
}
|
|
if !strings.Contains(reply, c.want) {
|
|
t.Errorf("reply = %q, want it to contain %q", reply, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveTaskStatusOpensACandidateFirst(t *testing.T) {
|
|
// The store refuses candidate → done. Saying it is done is the confirmation
|
|
// the candidate was waiting for, so the arm makes both legal moves.
|
|
api := &taskAPI{tasks: []ipc.Task{{ID: 9, Text: "продлить домен", Status: "candidate"}}}
|
|
h := taskHandler(api)
|
|
h.resolveTaskStatus(context.Background(), router.Decision{
|
|
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "продлить домен"},
|
|
}, routeCandidate(router.TaskStatusFn))
|
|
if len(api.moved) != 2 {
|
|
t.Fatalf("moved %+v, want open then done", api.moved)
|
|
}
|
|
if api.moved[0].status != "open" || api.moved[1].status != "done" {
|
|
t.Errorf("moved %+v, want open then done", api.moved)
|
|
}
|
|
}
|