Files
Maven/internal/router/fastpath_test.go
T
claude 494c719a5d router: update all Route callers for NormalizedInput + add invariant tests (slice 11)
Migrate 54 test call sites to construct NormalizedInput{Text: ...}.
Add invariant tests:
- TestNormalizedInputReachesRouteIntact: ingress NormalizedInput reaches Route
- TestTryFastPathReceivesMatchText: TryFastPath gets the same input
- TestMatchTextDoesNotChangeRouting: same Text + different MatchText → same Decision
- TestDecisionUtteranceEqualsInputText: Decision.Utterance == input.Text
2026-09-07 01:32:49 +04:00

351 lines
11 KiB
Go

package router
import (
"context"
"testing"
)
func TestTryFastPathMatchesReminderGrammar(t *testing.T) {
r := newTestRouter(t, 0.0)
r.grammars = append(r.grammars, ReminderGrammar())
now := refNow()
fast, err := r.TryFastPath(context.Background(), NormalizedInput{Text: "напомни позвонить маме завтра"}, now)
if err != nil {
t.Fatalf("TryFastPath: %v", err)
}
if !fast.Matched {
t.Fatal("expected Matched=true")
}
d := fast.Decision
if d.Intent != IntentReminder {
t.Errorf("Intent = %q, want %q", d.Intent, IntentReminder)
}
if d.Stage != 0 {
t.Errorf("Stage = %d, want 0", d.Stage)
}
if d.Producer != RouteProducerGrammar {
t.Errorf("Producer = %q, want %q", d.Producer, RouteProducerGrammar)
}
if d.Confidence != 1.0 {
t.Errorf("Confidence = %f, want 1.0", d.Confidence)
}
}
func TestTryFastPathMatchesWithWakeToken(t *testing.T) {
r := newTestRouter(t, 0.0)
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
now := refNow()
fast, err := r.TryFastPath(context.Background(), NormalizedInput{Text: "Мэйвен который час"}, now)
if err != nil {
t.Fatalf("TryFastPath: %v", err)
}
if !fast.Matched {
t.Fatal("expected Matched=true")
}
d := fast.Decision
if d.Intent != IntentSystem {
t.Errorf("Intent = %q, want %q", d.Intent, IntentSystem)
}
if d.Stage != 0 {
t.Errorf("Stage = %d, want 0", d.Stage)
}
}
func TestTryFastPathMissFallsThrough(t *testing.T) {
r := newTestRouter(t, 0.0)
now := refNow()
fast, err := r.TryFastPath(context.Background(), NormalizedInput{Text: "как дела"}, now)
if err != nil {
t.Fatalf("TryFastPath: %v", err)
}
if fast.Matched {
t.Fatal("expected Matched=false for unmatched utterance")
}
}
func TestTryFastPathGrammarOrderPreserved(t *testing.T) {
acts := DefaultActMatcher{Fns: []string{"restart"}}
grammars := StageZeroGrammars(acts)
if len(grammars) == 0 {
t.Fatal("StageZeroGrammars returned empty list")
}
r := New(Config{
Grammars: grammars,
Classifier: nil,
Extractor: Extractor{Time: StubDateTimeParser{}, Acts: acts},
Threshold: 0.55,
})
// Verify TryFastPath uses the same ordered list by checking grammar names.
// We can't read r.grammars directly from outside the package, but we can
// verify the count matches.
if len(r.grammars) != len(grammars) {
t.Errorf("Router.grammars length = %d, want %d", len(r.grammars), len(grammars))
}
}
func TestTryFastPathCapabilitySelection(t *testing.T) {
r := newTestRouter(t, 0.0)
now := refNow()
fast, err := r.TryFastPath(context.Background(), NormalizedInput{Text: "maven, restart nginx"}, now)
if err != nil {
t.Fatalf("TryFastPath: %v", err)
}
if !fast.Matched {
t.Fatal("expected Matched=true")
}
d := fast.Decision
if d.Intent != IntentAct {
t.Errorf("Intent = %q, want %q", d.Intent, IntentAct)
}
if !d.CapabilitySelection.Resolved {
t.Error("CapabilitySelection.Resolved = false, want true")
}
if d.CapabilitySelection.Fn != "restart" {
t.Errorf("CapabilitySelection.Fn = %q, want %q", d.CapabilitySelection.Fn, "restart")
}
if d.CapabilitySelection.Method != ActionResolutionGrammarMatcher {
t.Errorf("CapabilitySelection.Method = %q, want %q", d.CapabilitySelection.Method, ActionResolutionGrammarMatcher)
}
}
func TestTryFastPathDoesNotReadMatchText(t *testing.T) {
r := newTestRouter(t, 0.0)
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
now := refNow()
// Pass an input where MatchText differs from Text. If any grammar
// consumed MatchText, the result would differ from using Text alone.
input := NormalizedInput{
Text: "который час",
MatchText: "totally different text that should not be used",
}
fast, err := r.TryFastPath(context.Background(), input, now)
if err != nil {
t.Fatalf("TryFastPath: %v", err)
}
if !fast.Matched {
t.Fatal("expected Matched=true for time query")
}
if fast.Decision.Intent != IntentSystem {
t.Errorf("Intent = %q, want %q (grammar should use Text, not MatchText)", fast.Decision.Intent, IntentSystem)
}
}
func TestRouteIdenticalBeforeAfter(t *testing.T) {
r := newTestRouter(t, 0.0)
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
r.grammars = append(r.grammars, ReminderGrammar())
now := refNow()
utterances := []struct {
text string
intent Intent
stage int
}{
{"напомни позвонить маме завтра", IntentReminder, 0},
{"который час", IntentSystem, 0},
{"как дела", IntentChat, 2}, // falls through to classifier (stage 2)
}
for _, u := range utterances {
d, err := r.Route(context.Background(), NormalizedInput{Text: u.text}, now)
if err != nil {
t.Errorf("%s: Route: %v", u.text, err)
continue
}
if d.Intent != u.intent {
t.Errorf("%s: Intent = %q, want %q", u.text, d.Intent, u.intent)
}
if d.Stage != u.stage {
t.Errorf("%s: Stage = %d, want %d", u.text, d.Stage, u.stage)
}
if u.stage == 0 && d.Producer != RouteProducerGrammar {
t.Errorf("%s: Producer = %q, want %q", u.text, d.Producer, RouteProducerGrammar)
}
}
}
func TestTryFastPathSourceAnchored(t *testing.T) {
r := newTestRouter(t, 0.0)
r.grammars = append(r.grammars, AgendaQueryGrammars()...)
now := refNow()
// "что в календаре на завтра" — calendar-query names SourceCalendar.
fast, err := r.TryFastPath(context.Background(), NormalizedInput{Text: "что в календаре на завтра"}, now)
if err != nil {
t.Fatalf("TryFastPath: %v", err)
}
if !fast.Matched {
t.Fatal("expected Matched=true")
}
d := fast.Decision
if d.Source != SourceCalendar {
t.Errorf("Source = %q, want %q", d.Source, SourceCalendar)
}
if !d.SourceAnchored {
t.Error("SourceAnchored = false, want true (grammar named the destination)")
}
}
func TestTryFastPathFillMatchedSlots(t *testing.T) {
r := newTestRouter(t, 0.0)
r.grammars = append(r.grammars, ReminderGrammar())
now := refNow()
// "напомни в 11:00 позвонить маме" — grammar captures text, extractor fills time.
fast, err := r.TryFastPath(context.Background(), NormalizedInput{Text: "напомни в 11:00 позвонить маме"}, now)
if err != nil {
t.Fatalf("TryFastPath: %v", err)
}
if !fast.Matched {
t.Fatal("expected Matched=true")
}
d := fast.Decision
if !d.Slots.HasTime {
t.Error("HasTime = false, want true (fillMatchedSlots should fill time)")
}
if got, want := d.Slots.Time.Format("15:04"), "11:00"; got != want {
t.Errorf("Time = %s, want %s", got, want)
}
}
// TestNormalizedInputReachesRouteIntact pins that the NormalizedInput
// constructed at ingress arrives at Router.Route without reconstruction.
func TestNormalizedInputReachesRouteIntact(t *testing.T) {
r := newTestRouter(t, 0.0)
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
now := refNow()
input := NormalizedInput{
Text: "который час",
MatchText: "который час",
Source: InputSourceText,
}
d, err := r.Route(context.Background(), input, now)
if err != nil {
t.Fatalf("Route: %v", err)
}
if d.Intent != IntentSystem {
t.Errorf("Intent = %q, want %q", d.Intent, IntentSystem)
}
if d.Utterance != input.Text {
t.Errorf("Utterance = %q, want %q (Decision.Utterance must equal input.Text)", d.Utterance, input.Text)
}
}
// TestTryFastPathReceivesMatchText pins that TryFastPath receives the
// same NormalizedInput that Route was given (including MatchText).
// Today no grammar reads MatchText, so the result must be identical
// whether MatchText is set or empty — this freezes the dark-data contract.
func TestTryFastPathReceivesMatchText(t *testing.T) {
r := newTestRouter(t, 0.0)
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
now := refNow()
without := NormalizedInput{Text: "который час"}
with := NormalizedInput{Text: "который час", MatchText: "который час"}
fastWithout, err := r.TryFastPath(context.Background(), without, now)
if err != nil {
t.Fatalf("TryFastPath (without): %v", err)
}
fastWith, err := r.TryFastPath(context.Background(), with, now)
if err != nil {
t.Fatalf("TryFastPath (with): %v", err)
}
if fastWithout.Matched != fastWith.Matched {
t.Errorf("Matched: without=%v, with=%v", fastWithout.Matched, fastWith.Matched)
}
if fastWithout.Matched && fastWith.Matched {
if fastWithout.Decision.Intent != fastWith.Decision.Intent {
t.Errorf("Intent: without=%q, with=%q", fastWithout.Decision.Intent, fastWith.Decision.Intent)
}
if fastWithout.Decision.Confidence != fastWith.Decision.Confidence {
t.Errorf("Confidence: without=%f, with=%f", fastWithout.Decision.Confidence, fastWith.Decision.Confidence)
}
}
}
// TestMatchTextDoesNotChangeRouting pins the dark-data invariant:
// same Text, different MatchText → same Decision. This must hold until
// an explicit later slice opts a consumer into MatchText.
func TestMatchTextDoesNotChangeRouting(t *testing.T) {
r := newTestRouter(t, 0.0)
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
r.grammars = append(r.grammars, ReminderGrammar())
now := refNow()
utterances := []struct {
text string
want Intent
}{
{"который час", IntentSystem},
{"напомни позвонить маме завтра", IntentReminder},
}
for _, u := range utterances {
without := NormalizedInput{Text: u.text}
with := NormalizedInput{Text: u.text, MatchText: NormalizeMatchText(u.text)}
dWithout, err := r.Route(context.Background(), without, now)
if err != nil {
t.Errorf("%s (without MatchText): Route: %v", u.text, err)
continue
}
dWith, err := r.Route(context.Background(), with, now)
if err != nil {
t.Errorf("%s (with MatchText): Route: %v", u.text, err)
continue
}
if dWithout.Intent != dWith.Intent {
t.Errorf("%s: Intent changed: without=%q, with=%q", u.text, dWithout.Intent, dWith.Intent)
}
if dWithout.Confidence != dWith.Confidence {
t.Errorf("%s: Confidence changed: without=%f, with=%f", u.text, dWithout.Confidence, dWith.Confidence)
}
if dWithout.Stage != dWith.Stage {
t.Errorf("%s: Stage changed: without=%d, with=%d", u.text, dWithout.Stage, dWith.Stage)
}
}
}
// TestDecisionUtteranceEqualsInputText pins that Decision.Utterance is
// always input.Text, regardless of which cascade path was taken.
func TestDecisionUtteranceEqualsInputText(t *testing.T) {
r := newTestRouter(t, 0.0)
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
r.grammars = append(r.grammars, ReminderGrammar())
now := refNow()
utterances := []struct {
text string
want Intent
}{
{"который час", IntentSystem},
{"напомни позвонить маме завтра", IntentReminder},
{"как дела", IntentChat},
}
for _, u := range utterances {
input := NormalizedInput{Text: u.text}
d, err := r.Route(context.Background(), input, now)
if err != nil {
t.Errorf("%s: Route: %v", u.text, err)
continue
}
if d.Intent != u.want {
t.Errorf("%s: Intent = %q, want %q", u.text, d.Intent, u.want)
continue
}
if d.Utterance != u.text {
t.Errorf("%s: Decision.Utterance = %q, want %q", u.text, d.Utterance, u.text)
}
}
}