diff --git a/internal/router/fastpath_test.go b/internal/router/fastpath_test.go new file mode 100644 index 0000000..17f3a27 --- /dev/null +++ b/internal/router/fastpath_test.go @@ -0,0 +1,216 @@ +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(), 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) + } +}