Files
Maven/internal/router/boundary_test.go
T
claude a55d90954a 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
2026-09-05 20:16:57 +04:00

158 lines
5.4 KiB
Go

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)
}