df58710141
Two of the four defects on the task.
The stored payload was the whole utterance, so /reminders and the agenda
recited "напомни завтра в 9 утра выпить таблетки" where the reminder is
"выпить таблетки". The marker is an instruction that was already carried out
and the hour is already a column, so reminderBody strips both, and falls back
to the unstripped body whenever stripping would leave nothing — a reminder
that fires and says nothing is worse than a wordy one.
The page rendered the raw {"text":...} envelope and the UTC instant. Both are
now done in mavweb: reminderRows unwraps the payload and formats through
Local(). The unwrap is a copy of store.ReminderText rather than a call to it,
because mavweb builds without CGO and internal/store carries the sqlite
driver — the ipc DTOs are decoupled from the store on purpose.
TestClarifySubjectAnswerFillsRatherThanClobbers asserted the hour survived as
a word in the payload. It now asserts the fire time, which is where the hour
lives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
557 lines
24 KiB
Go
557 lines
24 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/kami/maven/internal/dialogue"
|
||
"github.com/kami/maven/internal/ipc"
|
||
"github.com/kami/maven/internal/phraser/eval"
|
||
"github.com/kami/maven/internal/router"
|
||
"github.com/kami/maven/internal/store"
|
||
"github.com/kami/maven/internal/tool"
|
||
"github.com/kami/maven/internal/voice"
|
||
)
|
||
|
||
// newClarifyHandler builds a handler with the clarify path wired and no model:
|
||
// stub date parser, the real fact parser, and a matcher over whatever tools the
|
||
// test enabled. `now` is fixed so TTL behaviour is testable.
|
||
func newClarifyHandler(t *testing.T) (*reactiveHandler, *store.Store, *time.Time) {
|
||
t.Helper()
|
||
st := newTestStore(t)
|
||
api := ipc.NewStoreAPI(st)
|
||
now := time.Date(2026, 7, 31, 9, 0, 0, 0, time.UTC)
|
||
matcher := tool.NewMatcher(api)
|
||
h := &reactiveHandler{
|
||
api: api,
|
||
dataStore: st,
|
||
tools: tool.NewExecutor(api, 2*time.Second),
|
||
matcher: matcher,
|
||
replier: voice.NewStubReplier(),
|
||
now: func() time.Time { return now },
|
||
dialogueSessions: dialogue.NewSessionStore(2 * time.Minute),
|
||
clarifyStore: dialogue.NewClarifyStore(clarifyTTL),
|
||
extractor: router.Extractor{
|
||
Time: router.StubDateTimeParser{},
|
||
Acts: matcher,
|
||
Facts: router.DefaultFactParser{},
|
||
},
|
||
}
|
||
return h, st, &now
|
||
}
|
||
|
||
func clarifyDec(intent router.Intent, slots router.Slots, utterance string) router.Decision {
|
||
return router.Decision{Utterance: utterance, Stage: 3, Intent: intent, Slots: slots, Clarify: true}
|
||
}
|
||
|
||
// TestClarifyQuestionForMissingSlot pins which question goes with which gap, and
|
||
// which intents get no question at all.
|
||
func TestClarifyQuestionForMissingSlot(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
dec router.Decision
|
||
want string
|
||
asked bool
|
||
}{
|
||
{"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "Когда?", true},
|
||
{"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true},
|
||
{"act without a fn", clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это"), "Что сделать?", true},
|
||
// A time with nothing to say at that time is still half a reminder, so
|
||
// the subject is what she asks about — not silence.
|
||
{"reminder that has a time but no subject", clarifyDec(router.IntentReminder, router.Slots{HasTime: true}, "напомни в 11"), "О чём напомнить?", true},
|
||
{"reminder that has both", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни в 11 позвонить маме"), "", false},
|
||
{"chat is never worth a question", clarifyDec(router.IntentChat, router.Slots{Text: "мгм"}, "мгм"), "", false},
|
||
{"query is never worth a question", clarifyDec(router.IntentQuery, router.Slots{Text: "а"}, "а"), "", false},
|
||
}
|
||
for _, tc := range cases {
|
||
_, got, asked := clarifyQuestion(tc.dec)
|
||
if asked != tc.asked || got != tc.want {
|
||
t.Errorf("%s: got (%q, %v), want (%q, %v)", tc.name, got, asked, tc.want, tc.asked)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestClarifyReminderCompletesOnAnswer is the whole point of the feature: she
|
||
// asks for the missing time and the answer creates the reminder.
|
||
func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, _ := newClarifyHandler(t)
|
||
|
||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
||
if !asked || question != "Когда?" {
|
||
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
|
||
}
|
||
|
||
reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00")
|
||
if !handled {
|
||
t.Fatal("the answer to an open question must be consumed as an answer")
|
||
}
|
||
if reply == clarifyGaveUp {
|
||
t.Fatalf("a good answer must not drop the request: %q", reply)
|
||
}
|
||
|
||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||
if err != nil || len(reminders) != 1 {
|
||
t.Fatalf("clarified reminder was not created: reminders=%v err=%v", reminders, err)
|
||
}
|
||
if !strings.Contains(reminders[0].Payload, "маме") {
|
||
t.Fatalf("the reminder lost the original request: %q", reminders[0].Payload)
|
||
}
|
||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||
t.Fatal("the question must be cleared once answered")
|
||
}
|
||
}
|
||
|
||
// TestClarifyFactCompletesOnAnswer — the fact path, where the answer carries
|
||
// both the key and the value.
|
||
func TestClarifyFactCompletesOnAnswer(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, _ := newClarifyHandler(t)
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
|
||
t.Fatal("a fact with no key should be asked about")
|
||
}
|
||
if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyGaveUp {
|
||
t.Fatalf("answer should complete the fact, handled=%v reply=%q", handled, reply)
|
||
}
|
||
if fact, err := st.LatestFact(ctx, "water"); err != nil || fact.Key != "water" {
|
||
t.Fatalf("clarified fact was not written: fact=%+v err=%v", fact, err)
|
||
}
|
||
}
|
||
|
||
// TestClarifyAnswerAfterTTLIsANewRequest — a late answer is not an answer.
|
||
func TestClarifyAnswerAfterTTLIsANewRequest(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, now := newClarifyHandler(t)
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||
t.Fatal("expected a question")
|
||
}
|
||
*now = now.Add(clarifyTTL + time.Second)
|
||
|
||
if reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00"); handled {
|
||
t.Fatalf("an answer past the TTL must fall through to normal routing, got %q", reply)
|
||
}
|
||
if reminders, err := st.DueReminders(ctx, now.Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
||
t.Fatalf("expired question must not create anything: reminders=%v err=%v", reminders, err)
|
||
}
|
||
}
|
||
|
||
// TestClarifyAsksThreeTimesThenSaysSo — three questions are allowed, the fourth
|
||
// is not, and running out is SPOKEN. Silence would read as "handled".
|
||
func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, _ := newClarifyHandler(t)
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||
t.Fatal("expected a first question")
|
||
}
|
||
// Two more unclear answers ⇒ two more questions (3 asks in total).
|
||
for i := 2; i <= 3; i++ {
|
||
reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю")
|
||
if !handled {
|
||
t.Fatalf("answer %d must be consumed as an answer", i)
|
||
}
|
||
if reply != "Когда?" {
|
||
t.Fatalf("attempt %d should ask again, got %q", i, reply)
|
||
}
|
||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||
t.Fatalf("attempt %d must leave the question armed", i)
|
||
}
|
||
}
|
||
|
||
reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю")
|
||
if !handled || reply != clarifyGaveUp {
|
||
t.Fatalf("the fourth try must give up out loud, handled=%v reply=%q", handled, reply)
|
||
}
|
||
if reply == "" || strings.Contains(reply, "?") {
|
||
t.Fatalf("giving up must be spoken and must not be another question: %q", reply)
|
||
}
|
||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||
t.Fatal("a given-up request must leave no armed question")
|
||
}
|
||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
||
t.Fatalf("a given-up request must not create anything: reminders=%v err=%v", reminders, err)
|
||
}
|
||
}
|
||
|
||
// TestClarifyMaxAttemptsIsConfigurable — one question when the config says one.
|
||
func TestClarifyMaxAttemptsIsConfigurable(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, _, _ := newClarifyHandler(t)
|
||
h.clarifyMaxAttempts = 1
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||
t.Fatal("expected a question")
|
||
}
|
||
if reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю"); !handled || reply != clarifyGaveUp {
|
||
t.Fatalf("with max 1 she must give up at once, handled=%v reply=%q", handled, reply)
|
||
}
|
||
}
|
||
|
||
// TestClarifyRestatedAnswerWins — «в 11:00», then «нет, в 15:00». The second
|
||
// value is the one that lands.
|
||
func TestClarifyRestatedAnswerWins(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, _ := newClarifyHandler(t)
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||
t.Fatal("expected a question")
|
||
}
|
||
// First answer parses, but re-park it by hand as if she had asked again:
|
||
// what matters here is that Answer prefers the newer value over the parked
|
||
// one, which is the case the daemon hits on a re-ask.
|
||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||
if q == nil {
|
||
t.Fatal("expected an armed question")
|
||
}
|
||
first := h.extractor.Extract(ctx, router.IntentReminder, "в 11:00", h.now())
|
||
q.Slots = q.Answer("в 11:00", toDialogueSlots(first))
|
||
|
||
if reply, handled := h.resolveClarifyAnswer(ctx, "нет, в 15:00"); !handled || reply == clarifyGaveUp {
|
||
t.Fatalf("the restated answer should complete the request, handled=%v reply=%q", handled, reply)
|
||
}
|
||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||
if err != nil || len(reminders) != 1 {
|
||
t.Fatalf("expected one reminder: %v err=%v", reminders, err)
|
||
}
|
||
want := h.extractor.Extract(ctx, router.IntentReminder, "в 15:00", h.now())
|
||
if !reminders[0].FireTs.Equal(want.Time) {
|
||
t.Fatalf("reminder at %v, want the restated %v", reminders[0].FireTs, want.Time)
|
||
}
|
||
}
|
||
|
||
// TestClarifiedActOffAllowlistIsStillRefused — clarification fills in an
|
||
// argument, it never grants authority.
|
||
func TestClarifiedActOffAllowlistIsStillRefused(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, _ := newClarifyHandler(t)
|
||
marker := filepath.Join(t.TempDir(), "not-allowed-ran")
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||
t.Fatal("an act with no fn should be asked about")
|
||
}
|
||
reply, handled := h.resolveClarifyAnswer(ctx, "rm "+marker)
|
||
if !handled {
|
||
t.Fatal("the answer should be consumed")
|
||
}
|
||
if strings.Contains(reply, "готово") {
|
||
t.Fatalf("an act that is not on the allowlist must not report success: %q", reply)
|
||
}
|
||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||
t.Fatalf("a clarified act off the allowlist ran anyway: %v", err)
|
||
}
|
||
if tools, err := st.ListTools(ctx, "enabled"); err != nil || len(tools) != 0 {
|
||
t.Fatalf("clarify must not enable a tool: tools=%+v err=%v", tools, err)
|
||
}
|
||
}
|
||
|
||
// TestClarifiedDestructiveActStillNeedsConfirm — the confirm gate survives the
|
||
// clarify path.
|
||
func TestClarifiedDestructiveActStillNeedsConfirm(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, _ := newClarifyHandler(t)
|
||
marker := filepath.Join(t.TempDir(), "destructive-ran")
|
||
if err := st.EnableTool(ctx, "delete_backups", []string{"touch", marker}, true, "test", h.now()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||
t.Fatal("expected a question")
|
||
}
|
||
reply, handled := h.resolveClarifyAnswer(ctx, "delete_backups")
|
||
if !handled {
|
||
t.Fatal("the answer should be consumed")
|
||
}
|
||
if !strings.Contains(reply, "да") || h.pending == nil {
|
||
t.Fatalf("a clarified destructive act must still park a confirm: reply=%q pending=%+v", reply, h.pending)
|
||
}
|
||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||
t.Fatalf("a clarified destructive act ran before confirmation: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestNoQuestionWhenNothingIsMissing — noise keeps the canned reply, so she
|
||
// never invents a question for nothing.
|
||
func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
|
||
h, _, _ := newClarifyHandler(t)
|
||
for _, dec := range []router.Decision{
|
||
clarifyDec(router.IntentChat, router.Slots{Text: "эм"}, "эм"),
|
||
clarifyDec(router.IntentQuery, router.Slots{Text: "ммм"}, "ммм"),
|
||
clarifyDec(router.IntentNote, router.Slots{Text: "..."}, "..."),
|
||
} {
|
||
if question, asked := h.askClarify(context.Background(), dec); asked {
|
||
t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question)
|
||
}
|
||
}
|
||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||
t.Fatal("noise must not park a question")
|
||
}
|
||
}
|
||
|
||
// TestClarifyExpiryIsAnnouncedAndWordsStillRoute — his answer lands after the
|
||
// TTL: she must say the old request is gone AND still answer the new words.
|
||
func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||
h, _, now := newClarifyHandler(t)
|
||
emb := router.NewHashEmbedder(1024)
|
||
h.embedder = emb
|
||
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||
t.Fatal("expected a question")
|
||
}
|
||
*now = now.Add(clarifyTTL + time.Second)
|
||
|
||
reply := h.handleText(ctx, "", "как дела")
|
||
if !isClarifyExpired(reply) {
|
||
t.Fatalf("expired question must be announced first, got %q", reply)
|
||
}
|
||
if trimClarifyExpired(reply) == "" {
|
||
t.Fatalf("the new words must still be answered, got only the notice: %q", reply)
|
||
}
|
||
if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
|
||
t.Fatal("the expired question must be gone")
|
||
}
|
||
// The notice is said once, not on every later utterance.
|
||
if reply := h.handleText(ctx, "", "как дела"); isClarifyExpired(reply) {
|
||
t.Fatalf("notice repeated on a later turn: %q", reply)
|
||
}
|
||
}
|
||
|
||
// TestNoPendingQuestionFallsThrough — with nothing parked, an utterance routes
|
||
// normally.
|
||
func TestNoPendingQuestionFallsThrough(t *testing.T) {
|
||
h, _, _ := newClarifyHandler(t)
|
||
if reply, handled := h.resolveClarifyAnswer(context.Background(), "напомни в 11:00"); handled {
|
||
t.Fatalf("no open question ⇒ must not be treated as an answer, got %q", reply)
|
||
}
|
||
}
|
||
|
||
// TestClarifyAsksAboutTheSecondGapToo — "напомни" with neither a subject nor a
|
||
// time. She asks about the subject, he gives it, and the request is still not
|
||
// complete. The old code handed applyAction a reminder with no time, which
|
||
// answered with a parse error for a question she never asked.
|
||
func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, _ := newClarifyHandler(t)
|
||
|
||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
|
||
if !asked || question != "О чём напомнить?" {
|
||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||
}
|
||
|
||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||
if !handled {
|
||
t.Fatal("the answer must be consumed as an answer")
|
||
}
|
||
if reply != "Когда?" {
|
||
t.Fatalf("a filled subject with no time must ask about the time, got %q", reply)
|
||
}
|
||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||
if q == nil {
|
||
t.Fatal("the second gap must leave a question armed")
|
||
}
|
||
if q.Slots.Text == "" {
|
||
t.Fatalf("the re-parked question lost the answered subject: %+v", q.Slots)
|
||
}
|
||
|
||
if reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00"); !handled || reply == clarifyGaveUp {
|
||
t.Fatalf("the time answer must complete the reminder, handled=%v reply=%q", handled, reply)
|
||
}
|
||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||
if err != nil || len(reminders) != 1 {
|
||
t.Fatalf("expected one reminder: %v err=%v", reminders, err)
|
||
}
|
||
if !strings.Contains(reminders[0].Payload, "маме") {
|
||
t.Fatalf("the reminder lost the subject: %q", reminders[0].Payload)
|
||
}
|
||
}
|
||
|
||
// TestClarifySecondGapRespectsTheAttemptCap — the second gap spends a question
|
||
// out of the same budget, so it cannot turn a capped exchange into an endless
|
||
// one. With one attempt allowed she acts on what she has instead of asking.
|
||
func TestClarifySecondGapRespectsTheAttemptCap(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, _, _ := newClarifyHandler(t)
|
||
h.clarifyMaxAttempts = 1
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
|
||
t.Fatal("expected the subject question")
|
||
}
|
||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||
if !handled {
|
||
t.Fatal("the answer must be consumed")
|
||
}
|
||
if reply == "Когда?" {
|
||
t.Fatal("out of attempts she must not ask a second question")
|
||
}
|
||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||
t.Fatal("no question may stay armed past the cap")
|
||
}
|
||
}
|
||
|
||
// TestClarifyProseHoldsThePersona — these lines are hand-written Russian that
|
||
// the phrasing eval never sees, because they never go through the phraser. They
|
||
// carry feminine self-reference ("ждала", "отпустила") and address him with a
|
||
// plain imperative, and they are exactly the kind of string someone later edits
|
||
// reaching for a synonym. Run the eval's own persona checks over them here.
|
||
func TestClarifyProseHoldsThePersona(t *testing.T) {
|
||
// Only the persona checks. Length and on-topic do not apply: these are not
|
||
// nudges, they have no rule to be on topic about, and the expiry lines are
|
||
// deliberately longer than a nudge ceiling.
|
||
want := map[string]bool{
|
||
eval.CheckFeminine: true,
|
||
eval.CheckHisGender: true,
|
||
eval.CheckAddress: true,
|
||
eval.CheckCringe: true,
|
||
}
|
||
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
|
||
for _, q := range clarifyQuestions {
|
||
lines = append(lines, q)
|
||
}
|
||
for _, line := range lines {
|
||
for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") {
|
||
// The apology clause of the cringe check is scoped to nudges: it
|
||
// exists because apologising for a greenlit nudge undermines it.
|
||
// These lines are the opposite case. She did not understand him, or
|
||
// she let his request go, and "прости" there is ordinary speech
|
||
// rather than grovelling. Every other cringe rule still applies:
|
||
// pet names, emoji, exclamations, fake concern, praise.
|
||
// checkCringe returns the first break it finds, so this skip also
|
||
// hides a later one in the same line. Kept narrow on purpose: it
|
||
// only fires on a leading "apology (…)" detail.
|
||
if r.Name == eval.CheckCringe && strings.HasPrefix(r.Detail, "apology") {
|
||
continue
|
||
}
|
||
if want[r.Name] && !r.Pass {
|
||
t.Errorf("%q fails %s: %s", line, r.Name, r.Detail)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestExpiryNoticeSurvivesAConfirmTurn — she asks a question, he walks off, the
|
||
// question expires, he comes back and answers a confirm that is still parked.
|
||
// The confirm turn used to return before the notice was even computed, so he
|
||
// answered the confirm and never heard that the older request was let go.
|
||
func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||
h, _, now := newClarifyHandler(t)
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||
t.Fatal("expected a question")
|
||
}
|
||
// A confirm parked with a longer life than the question, so only the
|
||
// question is stale when he speaks.
|
||
h.pending = &pendingAct{fn: "delete_backups", phrase: "удалить бэкапы", expiry: now.Add(time.Hour)}
|
||
*now = now.Add(clarifyTTL + time.Second)
|
||
|
||
reply := h.handleText(ctx, "", "нет")
|
||
if !isClarifyExpired(reply) {
|
||
t.Fatalf("the expired question must be announced on a confirm turn too, got %q", reply)
|
||
}
|
||
if trimClarifyExpired(reply) == "" {
|
||
t.Fatalf("the confirm answer must survive the notice, got only the notice: %q", reply)
|
||
}
|
||
if h.pending != nil {
|
||
t.Fatal("the confirm must still have been consumed")
|
||
}
|
||
if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
|
||
t.Fatal("the expired question must be gone")
|
||
}
|
||
}
|
||
|
||
// The other half of the subject question: his answer must fill the empty slot,
|
||
// not replace the request. Slots.Text used to be the whole raw utterance for
|
||
// every intent, so the branch that fills a text slot could only ever overwrite
|
||
// (Vikunja #383). Here the parked request holds the hour and the answer holds
|
||
// what to say at it, and the reminder that lands has both.
|
||
func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, _ := newClarifyHandler(t)
|
||
at := h.now().Add(2 * time.Hour)
|
||
|
||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder,
|
||
router.Slots{Time: at, HasTime: true}, "напомни в 11"))
|
||
if !asked || question != "О чём напомнить?" {
|
||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||
}
|
||
|
||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||
if !handled {
|
||
t.Fatal("the answer to an open question must be consumed as an answer")
|
||
}
|
||
if reply == clarifyGaveUp {
|
||
t.Fatalf("a good answer must not drop the request: %q", reply)
|
||
}
|
||
|
||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||
if err != nil || len(reminders) != 1 {
|
||
t.Fatalf("clarified reminder was not created: reminders=%v err=%v", reminders, err)
|
||
}
|
||
if !strings.Contains(reminders[0].Payload, "маме") {
|
||
t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload)
|
||
}
|
||
// The hour is the fire time, not a word in the body: the body is what she
|
||
// says at the hour, and the time expression is stripped out of it
|
||
// (Vikunja #469). Clobbering the parked request would show up here as a
|
||
// reminder that fires at some other time than the one he asked for.
|
||
if got := reminders[0].FireTs.UTC(); !got.Equal(at.UTC()) {
|
||
t.Fatalf("the answer clobbered the original request: fires at %v, want %v", got, at.UTC())
|
||
}
|
||
}
|
||
|
||
// TestClarifyIsPerConversation — the parked question belongs to the reach that
|
||
// was asked. Before this the clarify store had one global key, so a question
|
||
// asked in the web chat and never answered captured the next utterance from
|
||
// telegram, or from the mic, and answered it against a request the speaker had
|
||
// never made (Vikunja #466).
|
||
func TestClarifyIsPerConversation(t *testing.T) {
|
||
h, _, _ := newClarifyHandler(t)
|
||
web := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||
telegram := withDialogueID(context.Background(), dialogueIDFor(sourceText, "telegram:42"))
|
||
|
||
if _, asked := h.askClarify(web, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||
t.Fatal("expected a question on the web conversation")
|
||
}
|
||
if _, handled := h.resolveClarifyAnswer(telegram, "в 11:00"); handled {
|
||
t.Fatal("a question asked on the web must not eat a telegram utterance")
|
||
}
|
||
if _, handled := h.resolveClarifyAnswer(voiceCtx(), "в 11:00"); handled {
|
||
t.Fatal("a question asked on the web must not eat what he says at the mic")
|
||
}
|
||
if reply, handled := h.resolveClarifyAnswer(web, "в 11:00"); !handled || reply == clarifyGaveUp {
|
||
t.Fatalf("the asker's own answer must land, handled=%v reply=%q", handled, reply)
|
||
}
|
||
}
|
||
|
||
// voiceCtx — the mic's conversation, which carries no id of its own.
|
||
func voiceCtx() context.Context {
|
||
return withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
||
}
|
||
|
||
// TestARestartExpiresTheParkedQuestion pins the Vikunja #385 decision: the
|
||
// question dies with the process, and she does not claim to have let it go —
|
||
// the words that follow are routed as a fresh request. Restarting is modelled
|
||
// the way the daemon does it, by building a second handler over the same store.
|
||
func TestARestartExpiresTheParkedQuestion(t *testing.T) {
|
||
h, _, _ := newClarifyHandler(t)
|
||
ctx := voiceCtx()
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||
t.Fatal("expected a question before the restart")
|
||
}
|
||
|
||
restarted, _, _ := newClarifyHandler(t)
|
||
if _, handled := restarted.resolveClarifyAnswer(ctx, "в 11:00"); handled {
|
||
t.Fatal("a question parked before the restart must not eat the next utterance")
|
||
}
|
||
if notice := restarted.clarifyExpiredNotice(ctx); notice != "" {
|
||
t.Fatalf("notice = %q, want silence: nothing survived to expire", notice)
|
||
}
|
||
}
|