diff --git a/cmd/mavend/boundary_test.go b/cmd/mavend/boundary_test.go new file mode 100644 index 0000000..7dd7123 --- /dev/null +++ b/cmd/mavend/boundary_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/decision" + "github.com/kami/maven/internal/router" +) + +// TestTextAndVoiceConvergeOnNormalizedInput — both entry points construct a +// NormalizedInput and pass it to runTurn. The same utterance produces the same +// route intent regardless of whether it arrived as text or voice. +func TestTextAndVoiceConvergeOnNormalizedInput(t *testing.T) { + h, _ := newRoutingClarifyHandler(t) + h.decisions = decision.NewRing() + ctx := context.Background() + + utterance := "который час" + voiceCtx := withDialogueID(ctx, dialogueIDFor(sourceVoice, "")) + textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test")) + + voiceReply := h.runTurn(voiceCtx, router.NormalizedInput{Text: utterance, Source: sourceVoice}) + textReply := h.runTurn(textCtx, router.NormalizedInput{Text: utterance, Source: sourceText}) + + // Both paths should produce the same kind of reply (time answer). + for _, pair := range []struct { + label, reply string + }{ + {"voice", voiceReply}, + {"text", textReply}, + } { + if !strings.Contains(pair.reply, "час") && !strings.Contains(pair.reply, "время") { + t.Errorf("%s reply %q does not look like a time answer", pair.label, pair.reply) + } + } +} + +// TestNormalizedInputSourcePreserved — the source survives into the decision +// record so a trace can tell voice from text. +func TestNormalizedInputSourcePreserved(t *testing.T) { + h, _ := newRoutingClarifyHandler(t) + h.decisions = decision.NewRing() + ctx := context.Background() + + textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test")) + h.runTurn(textCtx, router.NormalizedInput{Text: "привет", Source: sourceText}) + + recs := h.decisions.Recent(1) + if len(recs) == 0 { + t.Fatal("no decision record") + } + if recs[0].InputSource != string(sourceText) { + t.Errorf("InputSource = %q, want %q", recs[0].InputSource, sourceText) + } +} + +// TestRouteProducerOnDecisionRecord — the producer is carried from the router +// decision into the decision record for observability. +func TestRouteProducerOnDecisionRecord(t *testing.T) { + h, _ := newRoutingClarifyHandler(t) + h.decisions = decision.NewRing() + ctx := context.Background() + + textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test")) + h.runTurn(textCtx, router.NormalizedInput{Text: "который час", Source: sourceText}) + + recs := h.decisions.Recent(1) + if len(recs) == 0 { + t.Fatal("no decision record") + } + // A time query is a stage-0 grammar match. + if recs[0].RouteProducer != string(router.RouteProducerGrammar) { + t.Errorf("RouteProducer = %q, want %q", recs[0].RouteProducer, router.RouteProducerGrammar) + } +} + +// TestPreRouteClaimHasNoRouteProducer — a turn claimed by a pre-route resolver +// never reaches the router, so the record's RouteProducer must be empty. +func TestPreRouteClaimHasNoRouteProducer(t *testing.T) { + h, _ := newRoutingClarifyHandler(t) + h.decisions = decision.NewRing() + // Park a confirm so the next "да" is consumed before routing. + // newRoutingClarifyHandler uses a fixed clock at 2026-07-31 09:00 UTC. + h.pending = &pendingAct{ + fn: "test", + phrase: "delete everything", + expiry: time.Date(2026, 7, 31, 9, 1, 0, 0, time.UTC), + } + ctx := context.Background() + textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test")) + h.runTurn(textCtx, router.NormalizedInput{Text: "да", Source: sourceText}) + + recs := h.decisions.Recent(1) + if len(recs) == 0 { + t.Fatal("no decision record") + } + if recs[0].RouteProducer != "" { + t.Errorf("RouteProducer = %q, want empty (pre-route claimed the turn)", recs[0].RouteProducer) + } +} + +// TestStage0ProducerUnchanged — grammars still produce the exact same intents +// at confidence 1.0. This pins stage-0 behavior through the new boundary. +func TestStage0ProducerUnchanged(t *testing.T) { + h, _ := newRoutingClarifyHandler(t) + h.decisions = decision.NewRing() + ctx := context.Background() + textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test")) + + cases := []struct { + utterance string + intent router.Intent + }{ + {"напомни позвонить маме завтра", router.IntentReminder}, + {"который час", router.IntentSystem}, + } + for _, c := range cases { + reply := h.runTurn(textCtx, router.NormalizedInput{Text: c.utterance, Source: sourceText}) + _ = reply // behavior unchanged; we test the record, not the reply text. + + recs := h.decisions.Recent(1) + if len(recs) == 0 { + t.Errorf("%s: no decision record", c.utterance) + continue + } + rec := recs[0] + if rec.RouteProducer != string(router.RouteProducerGrammar) { + t.Errorf("%s: RouteProducer = %q, want %q", c.utterance, rec.RouteProducer, router.RouteProducerGrammar) + } + // Clear the ring for the next case. + h.decisions = decision.NewRing() + } +} diff --git a/internal/router/boundary_test.go b/internal/router/boundary_test.go new file mode 100644 index 0000000..d97592f --- /dev/null +++ b/internal/router/boundary_test.go @@ -0,0 +1,157 @@ +package router + +import ( + "context" + "testing" + "time" +) + +// TestNormalizedInputIsMinimalValueObject — the typed ingress boundary carries +// text and source and nothing else. 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: "привет", Source: InputSourceVoice} + if input.Text != "привет" { + t.Errorf("Text = %q, want %q", input.Text, "привет") + } + 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.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(), "напомни позвонить маме завтра", 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(), "как дела", 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(), "привет как дела что нового", 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(), 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) +}