package main import ( "context" "strings" "testing" "time" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/router" ) // TestOwnContentSeparatesAValueFromAQuestion pins the test the whole role // classifier rests on: after the frame, the numbers and the closed time sets // come out, does anything of his own remain? A hedged time leaves nothing. A // question about the weather leaves the weather. func TestOwnContentSeparatesAValueFromAQuestion(t *testing.T) { cases := []struct { text string own bool }{ {"в 11:00", false}, {"в семь вечера", false}, {"нет, в 15:00", false}, {"а что если в 11:00", false}, {"на 9", false}, {"а, да, прости — на 9", false}, {"завтра", false}, {"в половине восьмого", false}, {"какая сейчас погода в Риме?", true}, {"кто изобрёл телефон", true}, {"напомни в 11:00", true}, {"позвонить маме", true}, {"запиши что я пил воду", true}, } for _, tc := range cases { if got := len(ownContent(tc.text)) > 0; got != tc.own { t.Errorf("ownContent(%q) = %v, want own content = %v", tc.text, ownContent(tc.text), tc.own) } } } // TestCancelIsTheWholeUtterance — a call-off calls the request off, and a // sentence that merely contains the word does not. func TestCancelIsTheWholeUtterance(t *testing.T) { for _, yes := range []string{"отмена", "забудь", "неважно", "проехали", "cancel", "ой, отмена"} { if !isCancel(yes) { t.Errorf("isCancel(%q) = false, want true", yes) } } for _, no := range []string{"забудь купить молоко", "в 11:00", "позвонить маме", ""} { if isCancel(no) { t.Errorf("isCancel(%q) = true, want false", no) } } } // TestTurnRoleReadsTheRoutedDecision — the inversion itself. The same utterance // gets a different role depending on what the router made of it, which is the // evidence the old guard never had. func TestTurnRoleReadsTheRoutedDecision(t *testing.T) { q := &dialogue.PendingQuestion{ Intent: dialogue.Intent(router.IntentReminder), Missing: []dialogue.Slot{dialogue.SlotTime}, } dec := func(in router.Intent, s router.Slots) router.Decision { return router.Decision{Intent: in, Slots: s} } cases := []struct { name string text string routed router.Decision ok bool answer dialogue.Slots want turnRole }{ { // The measured defect. The extractor finds "сейчас" and would have // closed the gap with it; the route says this is a question of its // own, and the question wins. name: "a world question mid-flow is a side query", text: "какая сейчас погода в Риме?", routed: dec(router.IntentQuery, router.Slots{Text: "какая сейчас погода в Риме?"}), ok: true, answer: dialogue.Slots{HasTime: true, Time: time.Now()}, want: roleSideQuery, }, { name: "a hedged time is an answer even routed as a query", text: "а что если в 11:00", routed: dec(router.IntentQuery, router.Slots{Text: "а что если в 11:00"}), ok: true, answer: dialogue.Slots{HasTime: true, Time: time.Now()}, want: roleAnswer, }, { name: "a fresh reminder is a new request", text: "напомни завтра позвонить маме", routed: dec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}), ok: true, want: roleNewRequest, }, { name: "a capture is a new request", text: "запиши что я пил воду", routed: dec(router.IntentFact, router.Slots{Key: "water", HasKey: true}), ok: true, want: roleNewRequest, }, { name: "an act that resolved to a capability is a new request", text: "выключи свет в спальне", routed: dec(router.IntentAct, router.Slots{Fn: "light_off", HasFn: true}), ok: true, want: roleNewRequest, }, { // She could not route it. An utterance she did not understand does // not outrank the question in front of it. name: "a clarify decision is not a request of its own", text: "выключи свет", routed: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "light_off", HasFn: true}, Clarify: true}, ok: true, want: roleAnswer, }, { // V-577 shape 1. Every token is frame, so the content gate called // this an answer and the reminder took "сегодня" for its time. It // fills nothing she asked about, so the route decides, and the route // says the calendar answers it. name: "an agenda question of pure frame words is a side query", text: "что у меня сегодня?", routed: dec(router.IntentQuery, router.Slots{}), ok: true, want: roleSideQuery, }, { // V-577 shape 2. Neither a slot value nor a request nor a cancel. // It was dropped in silence; it is an aside, and an aside is stored // and re-asked. name: "a fact stated mid-flow is an aside", text: "у меня новый ноутбук", routed: dec(router.IntentNote, router.Slots{Text: "у меня новый ноутбук"}), ok: true, want: roleAside, }, { // A route she is not sure of is not evidence that he stated // anything, and "позвонить маме" is the answer to the other half of // a reminder. name: "an unsure note is not an aside", text: "позвонить маме", routed: router.Decision{Intent: router.IntentNote, Clarify: true}, ok: true, want: roleAnswer, }, { name: "a bare noun that answers nothing is still an answer", text: "ага", ok: false, want: roleAnswer, }, { name: "no route to read falls back to the shape", text: "кто изобрёл телефон", ok: false, want: roleSideQuery, }, { name: "a call-off needs no route at all", text: "отмена", ok: false, want: roleCancel, }, } for _, tc := range cases { if got := classifyTurnRole(q, tc.text, tc.answer, tc.routed, tc.ok); got != tc.want { t.Errorf("%s: classifyTurnRole(%q) = %s, want %s", tc.name, tc.text, got, tc.want) } } } // TestTurnRoleNamesACorrection — the answer overwrites a slot she was not // asking about. Handled like an answer, named as what it is. func TestTurnRoleNamesACorrection(t *testing.T) { nine := time.Date(2026, 8, 6, 9, 0, 0, 0, time.UTC) q := &dialogue.PendingQuestion{ Intent: dialogue.Intent(router.IntentReminder), Missing: []dialogue.Slot{dialogue.SlotText}, Slots: dialogue.Slots{HasTime: true, Time: nine.Add(2 * time.Hour)}, } got := classifyTurnRole(q, "нет, на 9", dialogue.Slots{HasTime: true, Time: nine}, router.Decision{}, false) if got != roleCorrection { t.Fatalf("role = %s, want %s", got, roleCorrection) } } // TestRomeIsAnsweredAndTheReminderIsNotInvented — the measured failure of // 2026-08-05, end to end through the real cascade. "напомни позвонить маме" // parks the time question; the weather question that follows must not become // its answer and must not create a reminder for a time nobody asked for. // // V-560 got that far by DROPPING the parked request and saying so, and the // owner rejected the notice on sight: he did not ask to lose the reminder. So // the contract here is V-561's — the flow is suspended, this turn's reply ends // with the question coming back, and nothing says anything was let go. func TestRomeIsAnsweredAndTheReminderIsNotInvented(t *testing.T) { ctx := context.Background() h, st := newRoutingClarifyHandler(t) if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") { t.Fatalf("expected the time question, got %q", reply) } reply := h.handleText(ctx, "web", "какая сейчас погода в Риме?") if strings.Contains(reply, "напомню") { t.Fatalf("the question was eaten as the reminder's time again: %q", reply) } if strings.Contains(reply, clarifyDropped) { t.Fatalf("a side query suspends the flow; nothing was dropped, so nothing may say so: %q", reply) } resumed, _ := clarifyResumedFor(dialogue.SlotTime) if !strings.HasSuffix(reply, resumed) { t.Fatalf("the reply must end with the resumed question %q, got %q", resumed, reply) } if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 { t.Fatalf("a reminder was invented for a time nobody asked for: %v err=%v", reminders, err) } // Still parked, and still on its first attempt: he answered the side query, // not this question, so no retry may have been spent on it. q := h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) if q == nil { t.Fatal("the parked question was dropped instead of suspended") } if q.Attempts != 1 { t.Fatalf("the side query spent a clarify attempt: attempts = %d, want 1", q.Attempts) } if !strings.Contains(q.Utterance, "маме") { t.Fatalf("the suspended request lost what it was about: %q", q.Utterance) } } // TestClarifyCancelEndsTheExchange — "отмена" while she is waiting calls the // half-built request off, out loud, and creates nothing. func TestClarifyCancelEndsTheExchange(t *testing.T) { ctx := context.Background() h, st := newRoutingClarifyHandler(t) if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") { t.Fatalf("expected the time question, got %q", reply) } if reply := h.handleText(ctx, "web", "отмена"); reply != clarifyCancelled { t.Fatalf("reply = %q, want %q", reply, clarifyCancelled) } if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 { t.Fatalf("a cancelled request still landed: %v err=%v", reminders, err) } if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) != nil { t.Fatal("a cancelled exchange must leave nothing parked") } } // TestTheTurnIsRoutedOnce — the cost bound. A turn with a question parked pays // for one extra route and not two: the clarify resolver and the pipeline read // the same memo. func TestTheTurnIsRoutedOnce(t *testing.T) { h, _ := newRoutingClarifyHandler(t) rt := h.newTurnRoute("какая сейчас погода в Риме?", h.now()) ctx := withTurnRoute(withDialogueID(context.Background(), voiceDialogueID), rt) first, ok := h.routeForRole(ctx, rt.text) if !ok { t.Fatal("the cascade must produce a decision to classify against") } second, _, _, err := rt.resolve(ctx) if err != nil { t.Fatalf("resolve: %v", err) } if second.Intent != first.Intent || second.Utterance != first.Utterance { t.Fatalf("the pipeline routed again and got something else: %+v vs %+v", second, first) } }