router: add boundary tests for typed ingress and route producer
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
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestNormalizedInputIsMinimalValueObject — the typed ingress boundary carries
|
||||
// text and source and nothing else. This test pins the shape so a future slice
|
||||
// cannot add fields without updating every construction site.
|
||||
func TestNormalizedInputIsMinimalValueObject(t *testing.T) {
|
||||
input := NormalizedInput{Text: "привет", Source: InputSourceVoice}
|
||||
if input.Text != "привет" {
|
||||
t.Errorf("Text = %q, want %q", input.Text, "привет")
|
||||
}
|
||||
if input.Source != InputSourceVoice {
|
||||
t.Errorf("Source = %q, want %q", input.Source, InputSourceVoice)
|
||||
}
|
||||
// Empty zero value is usable.
|
||||
var zero NormalizedInput
|
||||
if zero.Text != "" || zero.Source != "" {
|
||||
t.Errorf("zero value is not empty: %+v", zero)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInputSourceConstants — the two channel values the daemon uses.
|
||||
func TestInputSourceConstants(t *testing.T) {
|
||||
if InputSourceVoice != "tap:voice" {
|
||||
t.Errorf("InputSourceVoice = %q, want %q", InputSourceVoice, "tap:voice")
|
||||
}
|
||||
if InputSourceText != "tap:text" {
|
||||
t.Errorf("InputSourceText = %q, want %q", InputSourceText, "tap:text")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouteProducerConstants — the four cascade stages that produce a decision.
|
||||
func TestRouteProducerConstants(t *testing.T) {
|
||||
wanted := map[RouteProducer]string{
|
||||
RouteProducerGrammar: "grammar",
|
||||
RouteProducerHeads: "heads",
|
||||
RouteProducerLLM: "llm",
|
||||
RouteProducerClassifier: "classifier",
|
||||
}
|
||||
for got, want := range wanted {
|
||||
if string(got) != want {
|
||||
t.Errorf("RouteProducer(%q) = %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStage0SetsGrammarProducer — a stage-0 grammar win carries the grammar
|
||||
// producer, not one of the statistical stages.
|
||||
func TestStage0SetsGrammarProducer(t *testing.T) {
|
||||
r := buildTestRouter(t)
|
||||
now := time.Now()
|
||||
// "напомни позвонить маме завтра" — a reminder grammar match.
|
||||
d, err := r.Route(context.Background(), "напомни позвонить маме завтра", now)
|
||||
if err != nil {
|
||||
t.Fatalf("Route: %v", err)
|
||||
}
|
||||
if d.Producer != RouteProducerGrammar {
|
||||
t.Errorf("Producer = %q, want %q (stage 0 grammar)", d.Producer, RouteProducerGrammar)
|
||||
}
|
||||
if d.Stage != 0 {
|
||||
t.Errorf("Stage = %d, want 0", d.Stage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifierSetsProducer — when no grammar matches and no model is wired,
|
||||
// the classifier is the floor and its producer is recorded.
|
||||
func TestClassifierSetsProducer(t *testing.T) {
|
||||
r := buildTestRouterNoModel(t)
|
||||
now := time.Now()
|
||||
// "как дела" — a free-form chat utterance that no grammar matches.
|
||||
d, err := r.Route(context.Background(), "как дела", now)
|
||||
if err != nil {
|
||||
t.Fatalf("Route: %v", err)
|
||||
}
|
||||
if d.Producer != RouteProducerClassifier {
|
||||
t.Errorf("Producer = %q, want %q (classifier floor)", d.Producer, RouteProducerClassifier)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyProducerIsClassifier — a clarification below threshold still
|
||||
// carries the classifier as the producer, because the classifier produced
|
||||
// the decision that was then gated.
|
||||
func TestClarifyProducerIsClassifier(t *testing.T) {
|
||||
r := buildTestRouterNoModel(t)
|
||||
now := time.Now()
|
||||
// "привет как дела что нового" — a long ambiguous utterance that no
|
||||
// grammar matches and the classifier scores below the clarify threshold.
|
||||
d, err := r.Route(context.Background(), "привет как дела что нового", now)
|
||||
if err != nil {
|
||||
t.Fatalf("Route: %v", err)
|
||||
}
|
||||
if d.Producer != RouteProducerClassifier {
|
||||
t.Errorf("Producer = %q, want %q", d.Producer, RouteProducerClassifier)
|
||||
}
|
||||
// Whether it clarifies or not, the producer is the classifier.
|
||||
_ = d.Clarify
|
||||
}
|
||||
|
||||
// TestStage0ProducerOnEveryGrammar — every grammar win must set
|
||||
// RouteProducerGrammar. This is a property test over the stage-0 set rather
|
||||
// than a test of one utterance.
|
||||
func TestStage0ProducerOnEveryGrammar(t *testing.T) {
|
||||
r := buildTestRouter(t)
|
||||
now := time.Now()
|
||||
// One utterance per grammar that we know matches at stage 0.
|
||||
utterances := []struct {
|
||||
text string
|
||||
name string
|
||||
}{
|
||||
{"напомни позвонить маме", "reminder"},
|
||||
{"который час", "system-time"},
|
||||
}
|
||||
for _, u := range utterances {
|
||||
d, err := r.Route(context.Background(), u.text, now)
|
||||
if err != nil {
|
||||
t.Errorf("%s: Route: %v", u.name, err)
|
||||
continue
|
||||
}
|
||||
if d.Stage != 0 {
|
||||
t.Errorf("%s: Stage = %d, want 0 (grammar should win)", u.name, d.Stage)
|
||||
continue
|
||||
}
|
||||
if d.Producer != RouteProducerGrammar {
|
||||
t.Errorf("%s: Producer = %q, want %q", u.name, d.Producer, RouteProducerGrammar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildTestRouter creates a minimal router with stage-0 grammars and a seeded
|
||||
// classifier, matching the daemon's cascade without the LLM or heads.
|
||||
func buildTestRouter(t *testing.T) *Router {
|
||||
t.Helper()
|
||||
emb := NewHashEmbedder(1024)
|
||||
cls := NewClassifier(emb)
|
||||
// Seed with enough examples so the classifier can answer.
|
||||
for _, intent := range []Intent{IntentChat, IntentQuery, IntentFact} {
|
||||
_ = cls.AddExample(context.Background(), intent, string(intent)+" example")
|
||||
}
|
||||
return New(Config{
|
||||
Grammars: StageZeroGrammars(DefaultActMatcher{Fns: []string{"перезапусти"}}),
|
||||
Classifier: cls,
|
||||
Extractor: Extractor{Time: StubDateTimeParser{}, Facts: DefaultFactParser{}},
|
||||
Threshold: 0.55,
|
||||
})
|
||||
}
|
||||
|
||||
// buildTestRouterNoModel creates a router with no LLM and no heads, so only
|
||||
// the grammar and classifier floors are available.
|
||||
func buildTestRouterNoModel(t *testing.T) *Router {
|
||||
t.Helper()
|
||||
return buildTestRouter(t)
|
||||
}
|
||||
Reference in New Issue
Block a user