package main import ( "context" "strings" "testing" "time" "github.com/kami/maven/internal/decision" "github.com/kami/maven/internal/router" ) // TestTextAndVoiceConvergeOnNormalizedInput — both entry points construct a // NormalizedInput and pass it to runTurn. The same utterance produces the same // route intent regardless of whether it arrived as text or voice. func TestTextAndVoiceConvergeOnNormalizedInput(t *testing.T) { h, _ := newRoutingClarifyHandler(t) h.decisions = decision.NewRing() ctx := context.Background() utterance := "который час" voiceCtx := withDialogueID(ctx, dialogueIDFor(sourceVoice, "")) textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test")) voiceReply := h.runTurn(voiceCtx, router.NormalizedInput{Text: utterance, Source: sourceVoice}) textReply := h.runTurn(textCtx, router.NormalizedInput{Text: utterance, Source: sourceText}) // Both paths should produce the same kind of reply (time answer). for _, pair := range []struct { label, reply string }{ {"voice", voiceReply}, {"text", textReply}, } { if !strings.Contains(pair.reply, "час") && !strings.Contains(pair.reply, "время") { t.Errorf("%s reply %q does not look like a time answer", pair.label, pair.reply) } } } // TestNormalizedInputSourcePreserved — the source survives into the decision // record so a trace can tell voice from text. func TestNormalizedInputSourcePreserved(t *testing.T) { h, _ := newRoutingClarifyHandler(t) h.decisions = decision.NewRing() ctx := context.Background() textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test")) h.runTurn(textCtx, router.NormalizedInput{Text: "привет", Source: sourceText}) recs := h.decisions.Recent(1) if len(recs) == 0 { t.Fatal("no decision record") } if recs[0].InputSource != string(sourceText) { t.Errorf("InputSource = %q, want %q", recs[0].InputSource, sourceText) } } // TestRouteProducerOnDecisionRecord — the producer is carried from the router // decision into the decision record for observability. func TestRouteProducerOnDecisionRecord(t *testing.T) { h, _ := newRoutingClarifyHandler(t) h.decisions = decision.NewRing() ctx := context.Background() textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test")) h.runTurn(textCtx, router.NormalizedInput{Text: "который час", Source: sourceText}) recs := h.decisions.Recent(1) if len(recs) == 0 { t.Fatal("no decision record") } // A time query is a stage-0 grammar match. if recs[0].RouteProducer != string(router.RouteProducerGrammar) { t.Errorf("RouteProducer = %q, want %q", recs[0].RouteProducer, router.RouteProducerGrammar) } } // TestPreRouteClaimHasNoRouteProducer — a turn claimed by a pre-route resolver // never reaches the router, so the record's RouteProducer must be empty. func TestPreRouteClaimHasNoRouteProducer(t *testing.T) { h, _ := newRoutingClarifyHandler(t) h.decisions = decision.NewRing() // Park a confirm so the next "да" is consumed before routing. // newRoutingClarifyHandler uses a fixed clock at 2026-07-31 09:00 UTC. h.pending = &pendingAct{ fn: "test", phrase: "delete everything", expiry: time.Date(2026, 7, 31, 9, 1, 0, 0, time.UTC), } ctx := context.Background() textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test")) h.runTurn(textCtx, router.NormalizedInput{Text: "да", Source: sourceText}) recs := h.decisions.Recent(1) if len(recs) == 0 { t.Fatal("no decision record") } if recs[0].RouteProducer != "" { t.Errorf("RouteProducer = %q, want empty (pre-route claimed the turn)", recs[0].RouteProducer) } } // TestStage0ProducerUnchanged — grammars still produce the exact same intents // at confidence 1.0. This pins stage-0 behavior through the new boundary. func TestStage0ProducerUnchanged(t *testing.T) { h, _ := newRoutingClarifyHandler(t) h.decisions = decision.NewRing() ctx := context.Background() textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test")) cases := []struct { utterance string intent router.Intent }{ {"напомни позвонить маме завтра", router.IntentReminder}, {"который час", router.IntentSystem}, } for _, c := range cases { reply := h.runTurn(textCtx, router.NormalizedInput{Text: c.utterance, Source: sourceText}) _ = reply // behavior unchanged; we test the record, not the reply text. recs := h.decisions.Recent(1) if len(recs) == 0 { t.Errorf("%s: no decision record", c.utterance) continue } rec := recs[0] if rec.RouteProducer != string(router.RouteProducerGrammar) { t.Errorf("%s: RouteProducer = %q, want %q", c.utterance, rec.RouteProducer, router.RouteProducerGrammar) } // Clear the ring for the next case. h.decisions = decision.NewRing() } }