package main import ( "context" "errors" "fmt" "strings" "testing" "time" "github.com/kami/maven/internal/decision" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" "github.com/kami/maven/internal/tts" ) func TestParseReminderCancelRequest(t *testing.T) { for _, tc := range []struct { text string ok bool }{ {"отмени напоминание про врача", true}, {"убери моё напоминание о визите", true}, {"удали будильник на девять", true}, {"пожалуйста, Maven, cancel the reminder about doctor", true}, {"отмени напоминание, которое стоит на завтра", true}, {"напоминание про врача", false}, {"отмени задачу про врача", false}, {"я отменил напоминание про врача", false}, {"как отменить напоминание про врача?", false}, {"можно отменить напоминание про врача?", false}, {"он сказал: отмени напоминание про врача", false}, {"how to cancel the reminder about doctor?", false}, {"can you cancel the reminder about doctor?", false}, {"убери со стола", false}, {"отмена", false}, } { _, ok := parseReminderCancelRequest(tc.text) if ok != tc.ok { t.Errorf("parseReminderCancelRequest(%q) ok = %v, want %v", tc.text, ok, tc.ok) } } } func TestReminderCancellationTermsPreserveIdentity(t *testing.T) { for _, tc := range []struct { text string hasTime bool want []string }{ {"отмени напоминание про врача", false, []string{"врача"}}, {"отмени напоминание не звонить врачу", false, []string{"не", "звонить", "врачу"}}, {"отмени напоминание принять две таблетки", false, []string{"принять", "две", "таблетки"}}, {"отмени напоминание принять две таблетки на девять", true, []string{"принять", "две", "таблетки"}}, {"cancel the reminder to take 2 pills at 21:30", true, []string{"take", "2", "pills"}}, } { got := reminderCancellationTerms(tc.text, tc.hasTime) if strings.Join(got, "|") != strings.Join(tc.want, "|") { t.Errorf("reminderCancellationTerms(%q) = %v, want %v", tc.text, got, tc.want) } } } func seedVoiceReminder(t *testing.T, st *store.Store, fire time.Time, text string) int64 { t.Helper() id, err := st.CreateReminder(context.Background(), fire, `{"text":"`+text+`"}`, "") if err != nil { t.Fatalf("create reminder: %v", err) } return id } func reminderStatuses(t *testing.T, st *store.Store) map[int64]string { t.Helper() rows, err := st.ListReminders(context.Background(), 100) if err != nil { t.Fatalf("list reminders: %v", err) } out := make(map[int64]string, len(rows)) for _, row := range rows { out[row.ID] = row.Status } return out } func TestReminderCancellationResolvesSubjectByMorphology(t *testing.T) { h, st, now := newClarifyHandler(t) h.timeParser = router.StubDateTimeParser{} doctor := seedVoiceReminder(t, st, now.Add(3*time.Hour), "позвонить врачу") bread := seedVoiceReminder(t, st, now.Add(4*time.Hour), "купить хлеб") reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание про врача") if !handled || !strings.Contains(reply, "отменила") || !strings.Contains(reply, "позвонить врачу") { t.Fatalf("reply = %q, handled=%v", reply, handled) } statuses := reminderStatuses(t, st) if statuses[doctor] != store.ReminderCancelled || statuses[bread] != store.ReminderPending { t.Fatalf("statuses = %+v, want doctor cancelled and bread pending", statuses) } } func TestReminderCancellationKeepsNegationAndQuantityDistinct(t *testing.T) { t.Run("negation", func(t *testing.T) { h, st, now := newClarifyHandler(t) positive := seedVoiceReminder(t, st, now.Add(time.Hour), "звонить врачу") negative := seedVoiceReminder(t, st, now.Add(2*time.Hour), "не звонить врачу") reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание не звонить врачу") if !handled || !strings.Contains(reply, "не звонить врачу") { t.Fatalf("reply = %q, handled=%v", reply, handled) } statuses := reminderStatuses(t, st) if statuses[positive] != store.ReminderPending || statuses[negative] != store.ReminderCancelled { t.Fatalf("negation selected the wrong row: %+v", statuses) } }) t.Run("quantity", func(t *testing.T) { h, st, now := newClarifyHandler(t) one := seedVoiceReminder(t, st, now.Add(time.Hour), "принять одну таблетку") two := seedVoiceReminder(t, st, now.Add(2*time.Hour), "принять две таблетки") reply, handled := h.resolveReminderCancellation(context.Background(), "удали напоминание принять две таблетки") if !handled || !strings.Contains(reply, "две таблетки") { t.Fatalf("reply = %q, handled=%v", reply, handled) } statuses := reminderStatuses(t, st) if statuses[one] != store.ReminderPending || statuses[two] != store.ReminderCancelled { t.Fatalf("quantity selected the wrong row: %+v", statuses) } }) } func TestReminderCancellationQuestionNeverMutates(t *testing.T) { h, st, now := newClarifyHandler(t) id := seedVoiceReminder(t, st, now.Add(time.Hour), "позвонить врачу") for _, text := range []string{ "как отменить напоминание про врача?", "можно отменить напоминание про врача?", "он сказал: отмени напоминание про врача", } { if reply, handled := h.resolveReminderCancellation(context.Background(), text); handled || reply != "" { t.Fatalf("non-command %q was claimed: reply=%q handled=%v", text, reply, handled) } if got := reminderStatuses(t, st)[id]; got != store.ReminderPending { t.Fatalf("non-command %q changed reminder to %q", text, got) } } } func TestReminderCancellationUsesClockAndAsksWhenStateIsAmbiguous(t *testing.T) { t.Run("one matching half of day is enough", func(t *testing.T) { h, st, now := newClarifyHandler(t) h.timeParser = router.StubDateTimeParser{} evening := seedVoiceReminder(t, st, time.Date(now.Year(), now.Month(), now.Day(), 21, 0, 0, 0, now.Location()), "вечернее лекарство") seedVoiceReminder(t, st, now.Add(2*time.Hour), "купить хлеб") reply, handled := h.resolveReminderCancellation(context.Background(), "убери напоминание на девять") if !handled || !strings.Contains(reply, "отменила") { t.Fatalf("reply = %q, handled=%v", reply, handled) } if got := reminderStatuses(t, st)[evening]; got != store.ReminderCancelled { t.Fatalf("21:00 status = %q, want cancelled", got) } }) t.Run("two matching halves are offered and ordinal is bound", func(t *testing.T) { h, st, now := newClarifyHandler(t) h.timeParser = router.StubDateTimeParser{} evening := seedVoiceReminder(t, st, time.Date(now.Year(), now.Month(), now.Day(), 21, 0, 0, 0, now.Location()), "вечернее лекарство") morning := seedVoiceReminder(t, st, time.Date(now.Year(), now.Month(), now.Day()+1, 9, 0, 0, 0, now.Location()), "утреннее лекарство") reply, handled := h.resolveReminderCancellation(context.Background(), "убери напоминание на девять") if !handled || !strings.Contains(reply, "порядковым словом") { t.Fatalf("ambiguous reply = %q, handled=%v", reply, handled) } statuses := reminderStatuses(t, st) if statuses[evening] != store.ReminderPending || statuses[morning] != store.ReminderPending { t.Fatalf("ambiguous command mutated rows: %+v", statuses) } sess := h.dialogueSessions.Get(dialogueIDOf(context.Background()), h.now()) if sess == nil || len(sess.Candidates) != 2 || sess.Candidates[1].Ref != morning { t.Fatalf("bound candidates = %+v", sess) } reply, handled = h.resolveCandidate(context.Background(), "второе", sourceVoice) if !handled || !strings.Contains(reply, "утреннее лекарство") { t.Fatalf("ordinal reply = %q, handled=%v", reply, handled) } statuses = reminderStatuses(t, st) if statuses[evening] != store.ReminderPending || statuses[morning] != store.ReminderCancelled { t.Fatalf("ordinal cancelled the wrong row: %+v", statuses) } if sess := h.dialogueSessions.Get(dialogueIDOf(context.Background()), h.now()); sess == nil || len(sess.Candidates) != 0 { t.Fatalf("spent candidates survived: %+v", sess) } }) } func TestReminderCancellationChoiceRequiresAWholeAffirmativeOrdinal(t *testing.T) { unsafe := []string{ "почему второе?", "не второе", "второе не отменяй", "первое и второе", "напомни мне первого сентября оплатить счёт", } for _, answer := range unsafe { t.Run(answer, func(t *testing.T) { h, st, now := newClarifyHandler(t) h.timeParser = router.StubDateTimeParser{} first := seedVoiceReminder(t, st, now.Add(time.Hour), "первое лекарство") second := seedVoiceReminder(t, st, now.Add(2*time.Hour), "второе лекарство") if _, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание"); !handled { t.Fatal("ambiguous cancellation was not offered") } if reply, handled := h.resolveCandidate(context.Background(), answer, sourceVoice); handled || reply != "" { t.Fatalf("unsafe answer was claimed: reply=%q handled=%v", reply, handled) } statuses := reminderStatuses(t, st) if statuses[first] != store.ReminderPending || statuses[second] != store.ReminderPending { t.Fatalf("unsafe answer mutated rows: %+v", statuses) } }) } } func TestReminderCancellationChoiceCanBeAbandoned(t *testing.T) { for _, answer := range []string{"отмена", "не надо", "no"} { t.Run(answer, func(t *testing.T) { h, st, now := newClarifyHandler(t) first := seedVoiceReminder(t, st, now.Add(time.Hour), "первое") second := seedVoiceReminder(t, st, now.Add(2*time.Hour), "второе") if _, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание"); !handled { t.Fatal("ambiguous cancellation was not offered") } reply, handled := h.resolveCandidate(context.Background(), answer, sourceVoice) if !handled || !strings.Contains(reply, "ничего не отменяю") { t.Fatalf("cancel answer = %q handled=%v", reply, handled) } statuses := reminderStatuses(t, st) if statuses[first] != store.ReminderPending || statuses[second] != store.ReminderPending { t.Fatalf("abandoning the choice mutated rows: %+v", statuses) } if sess := h.dialogueSessions.Get(dialogueIDOf(context.Background()), h.now()); sess == nil || len(sess.Candidates) != 0 { t.Fatalf("abandoned candidates survived: %+v", sess) } }) } } func TestReminderCancellationOfferStartsFreshAndNamesTruncation(t *testing.T) { h, st, now := newClarifyHandler(t) id := dialogueIDOf(context.Background()) h.dialogueSessions.Put(id, &dialogue.Session{ Intent: dialogue.IntentReminder, Slots: dialogue.Slots{Text: "stale subject", HasTime: true, Time: now.Add(time.Hour)}, Timestamp: now.Add(-time.Minute), }) for i := 0; i < 6; i++ { seedVoiceReminder(t, st, now.Add(time.Duration(i+1)*time.Hour), fmt.Sprintf("row %d", i+1)) } reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание") if !handled || !strings.Contains(reply, "первые пять") || !strings.Contains(reply, "уточни текст или время") { t.Fatalf("truncated offer = %q handled=%v", reply, handled) } sess := h.dialogueSessions.Get(id, h.now()) if sess == nil || sess.Intent != dialogue.IntentSystem || sess.Slots.Text != "" || len(sess.Candidates) != 5 || sess.Utterance != "отмени напоминание" { t.Fatalf("offer reused stale dialogue state: %+v", sess) } } func TestReminderCancellationNeverGuesses(t *testing.T) { t.Run("bare command over several rows", func(t *testing.T) { h, st, now := newClarifyHandler(t) first := seedVoiceReminder(t, st, now.Add(time.Hour), "первое") second := seedVoiceReminder(t, st, now.Add(2*time.Hour), "второе") reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание") if !handled || !strings.Contains(reply, "порядковым словом") { t.Fatalf("reply = %q, handled=%v", reply, handled) } statuses := reminderStatuses(t, st) if statuses[first] != store.ReminderPending || statuses[second] != store.ReminderPending { t.Fatalf("bare ambiguous command mutated rows: %+v", statuses) } }) t.Run("unread time", func(t *testing.T) { h, st, now := newClarifyHandler(t) h.timeParser = router.StubDateTimeParser{} id := seedVoiceReminder(t, st, now.Add(time.Hour), "позвонить врачу") reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание через вечность") if !handled || !strings.Contains(reply, "не смогла разобрать время") { t.Fatalf("reply = %q, handled=%v", reply, handled) } if got := reminderStatuses(t, st)[id]; got != store.ReminderPending { t.Fatalf("unread time cancelled reminder: %q", got) } }) } type cancelReminderAPI struct { ipc.UnimplementedCoreAPI rows []ipc.Reminder listErr error cancelErr error calls []int64 } func (a *cancelReminderAPI) ListPendingReminders(context.Context, int) ([]ipc.Reminder, error) { return a.rows, a.listErr } func (a *cancelReminderAPI) CancelReminder(_ context.Context, id int64) error { a.calls = append(a.calls, id) return a.cancelErr } func cancelHandler(api ipc.CoreAPI) *reactiveHandler { now := time.Date(2026, 8, 15, 9, 0, 0, 0, time.UTC) parser := router.StubDateTimeParser{} return &reactiveHandler{ api: api, now: func() time.Time { return now }, timeParser: parser, extractor: router.Extractor{Time: parser}, dialogueSessions: dialogue.NewSessionStore(2 * time.Minute), } } func TestReminderCancellationReportsStoreOutcomes(t *testing.T) { row := ipc.Reminder{ ID: 7, FireTs: time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC), NextFireTs: time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC), Payload: `{"text":"позвонить врачу"}`, Status: store.ReminderPending, } for _, tc := range []struct { name string err error want string }{ {"already terminal", ipc.ErrReminderState, "уже не ожидает"}, {"delivery in flight", ipc.ErrReminderInFlight, "уже начала отправлять"}, {"transport", errors.New("socket closed"), "не получилось отменить"}, } { t.Run(tc.name, func(t *testing.T) { api := &cancelReminderAPI{rows: []ipc.Reminder{row}, cancelErr: tc.err} reply, handled := cancelHandler(api).resolveReminderCancellation(context.Background(), "отмени напоминание про врача") if !handled || !strings.Contains(reply, tc.want) || len(api.calls) != 1 || api.calls[0] != 7 { t.Fatalf("reply=%q handled=%v calls=%v", reply, handled, api.calls) } }) } t.Run("list failure", func(t *testing.T) { api := &cancelReminderAPI{listErr: errors.New("offline")} reply, handled := cancelHandler(api).resolveReminderCancellation(context.Background(), "отмени напоминание") if !handled || !strings.Contains(reply, "не получилось посмотреть") || len(api.calls) != 0 { t.Fatalf("reply=%q handled=%v calls=%v", reply, handled, api.calls) } }) t.Run("nothing pending", func(t *testing.T) { api := &cancelReminderAPI{} reply, handled := cancelHandler(api).resolveReminderCancellation(context.Background(), "отмени напоминание") if !handled || !strings.Contains(reply, "ожидающих напоминаний нет") || len(api.calls) != 0 { t.Fatalf("reply=%q handled=%v calls=%v", reply, handled, api.calls) } }) } func TestReminderCancellationIsAPreRouteTurnAndDoesNotGetSwallowedByClarify(t *testing.T) { h, st, now := newClarifyHandler(t) h.timeParser = router.StubDateTimeParser{} h.decisions = decision.NewRing() id := seedVoiceReminder(t, st, now.Add(time.Hour), "позвонить врачу") ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web")) h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{ Intent: dialogue.IntentReminder, Missing: []dialogue.Slot{dialogue.SlotTime}, Utterance: "напомни позвонить маме", Asked: h.now(), TTL: clarifyTTL, }) reply := h.runTurn(ctx, "отмени напоминание про врача", sourceText) if !strings.Contains(reply, clarifyDropped) || !strings.Contains(reply, "отменила напоминание") { t.Fatalf("reply = %q, want dropped clarify notice and cancellation", reply) } if h.clarifyStore.Get(dialogueIDOf(ctx), h.now()) != nil { t.Fatal("the superseded clarify question survived the cancellation request") } if got := reminderStatuses(t, st)[id]; got != store.ReminderCancelled { t.Fatalf("status = %q, want cancelled", got) } recs := h.decisions.Recent(1) if len(recs) != 1 { t.Fatalf("decision records = %d, want 1", len(recs)) } claim := findClaim(recs[0], "reminder-cancel") if claim == nil || claim.Outcome != decision.Won { t.Fatalf("reminder-cancel claim = %+v, want pre-route winner", claim) } } func TestReminderCancellationThroughPushToTalk(t *testing.T) { h, st, now := newClarifyHandler(t) h.stt = simTranscriber{text: "отмени напоминание про врача"} h.tts = tts.NewStub() h.timeParser = router.StubDateTimeParser{} h.router = buildRouter(router.NewHashEmbedder(64), h.matcher, 0.55, nil, nil) doctor := seedVoiceReminder(t, st, now.Add(time.Hour), "позвонить врачу") bread := seedVoiceReminder(t, st, now.Add(2*time.Hour), "купить хлеб") resp, err := h.HandlePushToTalk(context.Background(), voicePTT(), 0) if err != nil { t.Fatal(err) } if !strings.Contains(resp.ReplyText, "отменила напоминание") || len(resp.ReplyAudio.Bytes) == 0 { t.Fatalf("PTT response = text %q audio=%d bytes", resp.ReplyText, len(resp.ReplyAudio.Bytes)) } statuses := reminderStatuses(t, st) if statuses[doctor] != store.ReminderCancelled || statuses[bread] != store.ReminderPending { t.Fatalf("PTT cancellation changed the wrong rows: %+v", statuses) } }