a55d90954a
12 focused tests proving the first slice properties: - text and voice enter equivalent typed turn input after stt - stage-0 outputs remain identical with grammar producer - classifier floor sets its producer - clarification carries the classifier producer - pre-route claims produce no route producer - route producer appears on the decision record - input source is preserved on the decision record
137 lines
4.7 KiB
Go
137 lines
4.7 KiB
Go
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()
|
|
}
|
|
}
|