package main import ( "context" "errors" "strings" "testing" "time" "github.com/kami/maven/internal/ipc" "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 !strings.Contains(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) } } }