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
This commit is contained in:
2026-09-07 01:32:49 +04:00
parent dad3cd0738
commit 494c719a5d
12 changed files with 195 additions and 61 deletions
+135 -1
View File
@@ -154,7 +154,7 @@ func TestRouteIdenticalBeforeAfter(t *testing.T) {
}
for _, u := range utterances {
d, err := r.Route(context.Background(), u.text, now)
d, err := r.Route(context.Background(), NormalizedInput{Text: u.text}, now)
if err != nil {
t.Errorf("%s: Route: %v", u.text, err)
continue
@@ -214,3 +214,137 @@ func TestTryFastPathFillMatchedSlots(t *testing.T) {
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)
}
}
}