b2521988e1
The daemon tests that compared against one literal ask the entry instead: IsAck names the line she could have said without pinning the wording. The eval scores every ack variant on the persona checks the nudges already pass.
256 lines
8.8 KiB
Go
256 lines
8.8 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|