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 askList(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 := askList(t, h, u); !ok { t.Fatalf("%q was not claimed (reply %q)", u, reply) } } if reply, ok := askList(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 := askList(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 := askList(t, h, u); !ok { t.Fatalf("%q was not claimed", u) } } reply, ok := askList(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 := askList(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 := askList(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) } }