d960e211d3
Brings internal/tool/risk.go in so the Hexis split can be written against it. Four conflicts, all additive: both grammar sets in voicewire.go, both test sets in agenda_test.go and stage0.go, and in actions_act.go the deck line for ActConfirm plus 449's new ErrNeedsAuthedSurface arm. Two renames the merge forced. actions_list_test.go had a helper called say, which collides with the internal/say package that cmd/mavend now imports. actions_act_risk_test.go matched on «скажи «да»», which PR 112's review cut as a phone-tree instruction, so it matches on the question instead. --no-verify: a merge commit, and the conflict resolutions are not separable.
185 lines
5.9 KiB
Go
185 lines
5.9 KiB
Go
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)
|
|
}
|
|
}
|