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