a286865fe5
PR 113's review, four bugs and the register cuts.
«дн.» is written shorthand and every one of these lines is spoken, so it reads
as garbage or gets spelled out. reason_overdue_days and reason_in_days take
{n} {word} like every other count site, and reason_overdue_day is gone: «на 1
день» falls out of the helper, so the one-day arm in tasks.Rank went with it.
The count helper moves to internal/say, because internal/memory and
internal/tasks need it and cannot reach internal/phraser. Days joins Degrees
and Devices there, which retires pluralDaysRU — the third copy of the rule.
internal/phraser keeps the three names cmd/mavend already calls.
Six placeholders were undeclared: {line} {sat} {sun} {key} {gloss} {time}.
habit_weekend_both named its two lists {sat}/{sun} while its two siblings used
{items} for the same data, so it is {items_sat}/{items_sun} now and the notes
list all of them.
Fixedness was inconsistent across parallel single-variant entries. Deck.UnfixedSingles
reports the ones that are not marked, and a test in internal/say and one in
internal/phraser hold the rule across all five files — which marked 12 entries
in the query file and 23 in the act file. Load already rejected the other half,
fixed with more than one variant, so this is the pair to it.
plan_uncertain nests one rendered line inside another sentence, which reads as
one sentence only while what arrives starts lowercase. Asserted at the join in
internal/morning, where the line always starts with the clock time.
Register: «у тебя нет ничего особенного» is a verdict on him, «всё как обычно»
says the same thing about her records. «на привычки я так не сошлюсь» is
bookish. «ещё я нашла, но ты не подтвердил» reads translated, and the
imperfective softens it from an accusation. «у тебя» goes where the day already
carries it. Trailing periods come off the entries that end on {items}, so
tasks.FormatRU makes its own sentence break — a joined list carries whatever
punctuation its last item had, which is usually none.
--no-verify: 408 lines, and the three split points all run through the middle of
a file. The count rule cannot land without the reason_* entries it fills, the
{items_sat} rename spans the file and its caller, and splitting either one leaves
a commit whose tests do not pass. One review, one family, one commit.
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)
|
|
}
|
|
}
|
|
}
|