From 6c67e6196225b378dd14195673d7bed73c7615a6 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 04:45:21 +0400 Subject: [PATCH] mavend: cover the spoken list path (V-453) --- cmd/mavend/actions_list_test.go | 184 ++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 cmd/mavend/actions_list_test.go diff --git a/cmd/mavend/actions_list_test.go b/cmd/mavend/actions_list_test.go new file mode 100644 index 0000000..31a94cd --- /dev/null +++ b/cmd/mavend/actions_list_test.go @@ -0,0 +1,184 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/store" +) + +func listNow() time.Time { return time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) } + +func listHandler(t *testing.T) *reactiveHandler { + t.Helper() + return &reactiveHandler{dataStore: newTestStore(t), now: listNow} +} + +func say(t *testing.T, h *reactiveHandler, utterance string) (string, bool) { + t.Helper() + return h.captureListFromNote(context.Background(), router.Decision{ + Intent: router.IntentNote, Utterance: utterance, + }) +} + +func TestListCaptureAddsAndReadsBack(t *testing.T) { + h := listHandler(t) + for _, u := range []string{"добавь в список покупок молоко", "добавь в список хлеб"} { + if reply, ok := say(t, h, u); !ok { + t.Fatalf("%q was not claimed (reply %q)", u, reply) + } + } + if reply, ok := say(t, h, "добавь в список покупок молоко"); !ok || !strings.Contains(reply, "уже") { + t.Errorf("second молоко replied %q, %v; want an already-there answer", reply, ok) + } + answer, ok := h.queryList(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: "что в списке покупок?"}, + }) + if !ok { + t.Fatal("the list question was not claimed") + } + if !strings.Contains(answer, "молоко") || !strings.Contains(answer, "хлеб") { + t.Errorf("answer %q; want both items", answer) + } + if strings.Contains(answer, "списке покупки") { + t.Errorf("answer %q declines the list name wrong", answer) + } +} + +// An utterance with no list marker is a note and must stay one, whichever half +// of the parser it brushes against. +func TestListCapturePassesOrdinaryNotes(t *testing.T) { + h := listHandler(t) + for _, u := range []string{ + "молоко закончилось", + "надо бы съездить в магазин", + "купил новый ноутбук", + "добавь в список покупок", + } { + if reply, ok := say(t, h, u); ok { + t.Errorf("%q was claimed as a list turn: %q", u, reply) + } + } +} + +func TestListCrossOffOneItemAndThenAll(t *testing.T) { + h := listHandler(t) + for _, u := range []string{ + "добавь в список покупок молоко", + "добавь в список покупок хлеб", + "добавь в список аптеки бинт", + } { + if _, ok := say(t, h, u); !ok { + t.Fatalf("%q was not claimed", u) + } + } + reply, ok := say(t, h, "вычеркни молоко") + if !ok || !strings.Contains(reply, "молоко") { + t.Fatalf("cross off replied %q, %v", reply, ok) + } + open, err := h.dataStore.ListItems(context.Background(), "покупки", "") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(open) != 1 || open[0].Item != "хлеб" { + t.Fatalf("open list %+v; want only хлеб", open) + } + if reply, ok := say(t, h, "всё купил"); !ok || !strings.Contains(reply, "пустой") { + t.Errorf("clear replied %q, %v", reply, ok) + } + open, err = h.dataStore.ListItems(context.Background(), "покупки", "") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(open) != 0 { + t.Errorf("%d items still open after всё купил", len(open)) + } + // The other list is untouched, and it is read back on its own. + answer, ok := h.queryList(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: "покажи список аптеки"}, + }) + if !ok || !strings.Contains(answer, "бинт") { + t.Errorf("аптека answer %q, %v; want бинт", answer, ok) + } +} + +func TestQueryListSaysWhenItIsEmpty(t *testing.T) { + h := listHandler(t) + answer, ok := h.queryList(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: "что мне купить?"}, + }) + if !ok { + t.Fatal("the list question was not claimed") + } + if !strings.Contains(answer, "пусто") { + t.Errorf("empty answer %q; want it to say so", answer) + } + if _, ok := h.queryList(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"}, + }); ok { + t.Error("the list source claimed a task question") + } +} + +// Stage 0 answers a list turn without the model: the grammars route it, and the +// action handlers re-parse what the grammar matched. +func TestListGrammarsRouteWithoutTheModel(t *testing.T) { + cases := []struct { + utterance string + want router.Intent + }{ + {"добавь в список покупок молоко", router.IntentNote}, + {"что в списке покупок?", router.IntentQuery}, + {"всё купил", router.IntentNote}, + } + for _, c := range cases { + var got router.Intent + claimed := false + for _, g := range router.ListGrammars() { + m := g.Pattern.FindStringSubmatch(c.utterance) + if m == nil { + continue + } + if dec, ok := g.Build(m); ok { + got, claimed = dec.Intent, true + break + } + } + if !claimed { + t.Errorf("no list grammar claimed %q", c.utterance) + continue + } + if got != c.want { + t.Errorf("%q routed to %v; want %v", c.utterance, got, c.want) + } + } + for _, g := range router.ListGrammars() { + m := g.Pattern.FindStringSubmatch("напомни купить молоко завтра") + if m == nil { + continue + } + if _, ok := g.Build(m); ok { + t.Errorf("grammar %s claimed a reminder", g.Name) + } + } +} + +func TestListStoreSourceIsVoice(t *testing.T) { + h := listHandler(t) + if _, ok := say(t, h, "добавь в список покупок молоко"); !ok { + t.Fatal("not claimed") + } + items, err := h.dataStore.ListItems(context.Background(), "покупки", "") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(items) != 1 || items[0].Source != "tap:voice" { + t.Errorf("stored %+v; want one row from tap:voice", items) + } + if items[0].Status != store.ListItemOpen { + t.Errorf("status %q; want open", items[0].Status) + } +}