package router import ( "context" "testing" "time" ) // TestNormalizedInputIsMinimalValueObject — the typed ingress boundary carries // text, match text and source. 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: "привет", MatchText: "привет", Source: InputSourceVoice} if input.Text != "привет" { t.Errorf("Text = %q, want %q", input.Text, "привет") } if input.MatchText != "привет" { t.Errorf("MatchText = %q, want %q", input.MatchText, "привет") } 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.MatchText != "" || 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(), NormalizedInput{Text: "напомни позвонить маме завтра"}, 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(), NormalizedInput{Text: "как дела"}, 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(), NormalizedInput{Text: "привет как дела что нового"}, 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(), NormalizedInput{Text: 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) }