Files
Maven/cmd/mavend/reminder_cancel_test.go
T
claude 87a3b163e7 router: introduce typed ingress boundary and route producer observability
First behavior-preserving slice of the Maven redesign. Establishes
explicit ingress/routing boundaries and enough observability to refactor
later without changing current routing, action, clarification, or
execution semantics.

Types introduced:
- NormalizedInput (internal/router/source.go): Text + InputSource,
  the typed ingress boundary replacing raw string at the turn entry.
- InputSource (internal/router/source.go): channel provenance enum
  (tap:voice, tap:text). Reuses the existing turnSource distinction.
- RouteProducer (internal/router/intent.go): which cascade stage
  produced the decision (grammar, heads, llm, classifier).

Changes:
- Decision carries a Producer RouteProducer field, set at each cascade
  stage (grammar, heads, LLM, classifier).
- turnRoute carries NormalizedInput instead of bare text string.
- runTurn takes NormalizedInput instead of (text, src).
- decision.Record carries InputSource and RouteProducer for
  observability; RoutingTrace persists route_producer (migration #27).
- turnSource is now a type alias for router.InputSource.

Behavior preserved:
- Stage-0 grammars unchanged: same order, same matching, same confidence.
- Cascade fallthrough order unchanged (grammar → heads → llm → classifier).
- Clarification behavior unchanged.
- Action dispatch unchanged.
- No new linguistic normalization.
2026-09-05 20:16:40 +04:00

426 lines
19 KiB
Go

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, router.NormalizedInput{Text: "отмени напоминание про врача", Source: 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)
}
}