66f06796cb
Pin each path: grammar_fixed, grammar_matcher, extractor_raw, extractor_llm_text, fallback_matcher. Verify unresolved has empty ResolvedBy. Verify fn/args remain byte-for-byte identical.
533 lines
16 KiB
Go
533 lines
16 KiB
Go
package router
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
// TestResolveActionCandidate_RouteSource pins that an act with HasFn=true
|
|
// produces a candidate from the route, not the matcher.
|
|
func TestResolveActionCandidate_RouteSource(t *testing.T) {
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
|
}
|
|
c := ResolveActionCandidate(dec, nil)
|
|
if !c.ActionResolved() {
|
|
t.Fatal("expected resolved candidate")
|
|
}
|
|
if c.Fn != "restart" {
|
|
t.Errorf("Fn = %q, want restart", c.Fn)
|
|
}
|
|
if len(c.Args) != 1 || c.Args[0] != "nginx" {
|
|
t.Errorf("Args = %v, want [nginx]", c.Args)
|
|
}
|
|
if c.Source != ActionSourceRoute {
|
|
t.Errorf("Source = %q, want route", c.Source)
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_MatcherSource pins that an act without Fn
|
|
// invokes the matcher and produces a candidate from it.
|
|
func TestResolveActionCandidate_MatcherSource(t *testing.T) {
|
|
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{Text: "restart nginx"},
|
|
}
|
|
c := ResolveActionCandidate(dec, m)
|
|
if !c.ActionResolved() {
|
|
t.Fatal("expected resolved candidate")
|
|
}
|
|
if c.Fn != "restart" {
|
|
t.Errorf("Fn = %q, want restart", c.Fn)
|
|
}
|
|
if c.Source != ActionSourceMatcher {
|
|
t.Errorf("Source = %q, want matcher", c.Source)
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_MatcherMiss pins that a matcher miss produces
|
|
// an unresolved candidate.
|
|
func TestResolveActionCandidate_MatcherMiss(t *testing.T) {
|
|
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{Text: "deploy the thing"},
|
|
}
|
|
c := ResolveActionCandidate(dec, m)
|
|
if c.ActionResolved() {
|
|
t.Fatal("expected unresolved candidate")
|
|
}
|
|
if c.Fn != "" {
|
|
t.Errorf("Fn = %q, want empty", c.Fn)
|
|
}
|
|
if c.Source != "" {
|
|
t.Errorf("Source = %q, want empty", c.Source)
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_NonAct pins that a non-act decision produces
|
|
// an empty candidate.
|
|
func TestResolveActionCandidate_NonAct(t *testing.T) {
|
|
dec := Decision{
|
|
Intent: IntentFact,
|
|
Slots: Slots{Key: "water", Value: "drank", HasKey: true},
|
|
}
|
|
c := ResolveActionCandidate(dec, nil)
|
|
if c.ActionResolved() {
|
|
t.Fatal("expected unresolved candidate for non-act")
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_Stage0Match pins that a stage-0 act (which
|
|
// sets HasFn=true) produces a route-sourced candidate.
|
|
func TestResolveActionCandidate_Stage0Match(t *testing.T) {
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Stage: 0,
|
|
Confidence: 1.0,
|
|
Slots: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
|
Producer: RouteProducerGrammar,
|
|
}
|
|
c := ResolveActionCandidate(dec, nil)
|
|
if !c.ActionResolved() {
|
|
t.Fatal("expected resolved candidate")
|
|
}
|
|
if c.Source != ActionSourceRoute {
|
|
t.Errorf("Source = %q, want route", c.Source)
|
|
}
|
|
if c.Producer != RouteProducerGrammar {
|
|
t.Errorf("Producer = %q, want grammar", c.Producer)
|
|
}
|
|
if c.Confidence != 1.0 {
|
|
t.Errorf("Confidence = %f, want 1.0", c.Confidence)
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_LearnedRouterNoFn pins that a learned-router
|
|
// act without Fn falls through to the matcher.
|
|
func TestResolveActionCandidate_LearnedRouterNoFn(t *testing.T) {
|
|
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Stage: 1,
|
|
Confidence: 0.85,
|
|
Slots: Slots{Text: "restart the server"},
|
|
Producer: RouteProducerLLM,
|
|
}
|
|
c := ResolveActionCandidate(dec, m)
|
|
if !c.ActionResolved() {
|
|
t.Fatal("expected resolved candidate from matcher fallback")
|
|
}
|
|
if c.Fn != "restart" {
|
|
t.Errorf("Fn = %q, want restart", c.Fn)
|
|
}
|
|
if c.Source != ActionSourceMatcher {
|
|
t.Errorf("Source = %q, want matcher", c.Source)
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_AliasMatch pins that aliases resolve through
|
|
// the matcher path.
|
|
func TestResolveActionCandidate_AliasMatch(t *testing.T) {
|
|
m := DefaultActMatcher{
|
|
Fns: []string{"restart"},
|
|
Aliases: map[string][]string{"restart": {"перезагрузи"}},
|
|
}
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{Text: "перезагрузи роутер"},
|
|
}
|
|
c := ResolveActionCandidate(dec, m)
|
|
if !c.ActionResolved() {
|
|
t.Fatal("expected resolved candidate from alias match")
|
|
}
|
|
if c.Fn != "restart" {
|
|
t.Errorf("Fn = %q, want restart", c.Fn)
|
|
}
|
|
if len(c.Args) != 1 || c.Args[0] != "роутер" {
|
|
t.Errorf("Args = %v, want [роутер]", c.Args)
|
|
}
|
|
}
|
|
|
|
// --- ValidateActionCandidate tests ---
|
|
|
|
// TestValidateActionCandidate_UnresolvedEmptyFn pins that an empty Fn
|
|
// produces an unresolved result (not invalid).
|
|
func TestValidateActionCandidate_UnresolvedEmptyFn(t *testing.T) {
|
|
c := ActionCandidate{}
|
|
v := ValidateActionCandidate(c)
|
|
if v.Status != ActionUnresolved {
|
|
t.Errorf("Status = %q, want unresolved", v.Status)
|
|
}
|
|
if v.Valid() {
|
|
t.Error("unresolved must not be valid")
|
|
}
|
|
}
|
|
|
|
// TestValidateActionCandidate_UnresolvedMatcherMiss pins that a matcher-miss
|
|
// candidate (Fn empty, source empty) is unresolved.
|
|
func TestValidateActionCandidate_UnresolvedMatcherMiss(t *testing.T) {
|
|
c := ActionCandidate{
|
|
Producer: RouteProducerLLM,
|
|
Confidence: 0.5,
|
|
}
|
|
v := ValidateActionCandidate(c)
|
|
if v.Status != ActionUnresolved {
|
|
t.Errorf("Status = %q, want unresolved", v.Status)
|
|
}
|
|
}
|
|
|
|
// TestValidateActionCandidate_ValidRoute pins that a route-resolved candidate
|
|
// with a non-empty Fn is valid.
|
|
func TestValidateActionCandidate_ValidRoute(t *testing.T) {
|
|
c := ActionCandidate{
|
|
Fn: "restart",
|
|
Args: []string{"nginx"},
|
|
Source: ActionSourceRoute,
|
|
}
|
|
v := ValidateActionCandidate(c)
|
|
if v.Unresolved() {
|
|
t.Error("expected resolved, not unresolved")
|
|
}
|
|
if v.Status != ActionValid {
|
|
t.Errorf("Status = %q, want valid; issues: %v", v.Status, v.Issues)
|
|
}
|
|
}
|
|
|
|
// TestValidateActionCandidate_ValidMatcher pins that a matcher-resolved
|
|
// candidate with a non-empty Fn is valid.
|
|
func TestValidateActionCandidate_ValidMatcher(t *testing.T) {
|
|
c := ActionCandidate{
|
|
Fn: "status",
|
|
Source: ActionSourceMatcher,
|
|
}
|
|
v := ValidateActionCandidate(c)
|
|
if v.Unresolved() {
|
|
t.Error("expected resolved, not unresolved")
|
|
}
|
|
if v.Status != ActionValid {
|
|
t.Errorf("Status = %q, want valid; issues: %v", v.Status, v.Issues)
|
|
}
|
|
}
|
|
|
|
// TestValidateActionCandidate_ValidNoArgs pins that a zero-arg tool is valid.
|
|
func TestValidateActionCandidate_ValidNoArgs(t *testing.T) {
|
|
c := ActionCandidate{
|
|
Fn: "status",
|
|
Source: ActionSourceRoute,
|
|
}
|
|
v := ValidateActionCandidate(c)
|
|
if v.Unresolved() {
|
|
t.Error("expected resolved, not unresolved")
|
|
}
|
|
if v.Status != ActionValid {
|
|
t.Errorf("Status = %q, want valid; issues: %v", v.Status, v.Issues)
|
|
}
|
|
}
|
|
|
|
// TestValidateActionCandidate_BlankFn pins that a whitespace-only Fn is
|
|
// structurally invalid (not unresolved).
|
|
func TestValidateActionCandidate_BlankFn(t *testing.T) {
|
|
c := ActionCandidate{
|
|
Fn: " ",
|
|
Source: ActionSourceRoute,
|
|
}
|
|
v := ValidateActionCandidate(c)
|
|
if v.Unresolved() {
|
|
t.Error("blank Fn should be invalid, not unresolved")
|
|
}
|
|
if v.Valid() {
|
|
t.Error("blank Fn should be invalid")
|
|
}
|
|
if v.Status != ActionInvalidArgument {
|
|
t.Errorf("Status = %q, want invalid_argument", v.Status)
|
|
}
|
|
if len(v.Issues) != 1 {
|
|
t.Fatalf("expected 1 issue, got %d", len(v.Issues))
|
|
}
|
|
if v.Issues[0].Field != FieldFn {
|
|
t.Errorf("issue field = %q, want fn", v.Issues[0].Field)
|
|
}
|
|
}
|
|
|
|
// --- additional typed status tests ---
|
|
|
|
// TestValidateActionCandidate_StatusConstants pins that the five status
|
|
// constants are distinct and non-empty.
|
|
func TestValidateActionCandidate_StatusConstants(t *testing.T) {
|
|
statuses := []ActionValidationStatus{
|
|
ActionValid,
|
|
ActionUnresolved,
|
|
ActionMissingArgument,
|
|
ActionInvalidArgument,
|
|
ActionAmbiguousTarget,
|
|
}
|
|
seen := make(map[ActionValidationStatus]bool)
|
|
for _, s := range statuses {
|
|
if s == "" {
|
|
t.Error("status constant is empty")
|
|
}
|
|
if seen[s] {
|
|
t.Errorf("status %q appears twice", s)
|
|
}
|
|
seen[s] = true
|
|
}
|
|
}
|
|
|
|
// TestValidateActionCandidate_UnresolvedBackwardCompat pins that the
|
|
// Unresolved() method returns true only for ActionUnresolved status.
|
|
func TestValidateActionCandidate_UnresolvedBackwardCompat(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status ActionValidationStatus
|
|
want bool
|
|
}{
|
|
{"unresolved", ActionUnresolved, true},
|
|
{"valid", ActionValid, false},
|
|
{"missing_arg", ActionMissingArgument, false},
|
|
{"invalid_arg", ActionInvalidArgument, false},
|
|
{"ambiguous", ActionAmbiguousTarget, false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
r := ActionValidationResult{Status: tt.status}
|
|
if got := r.Unresolved(); got != tt.want {
|
|
t.Errorf("Unresolved() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidateActionCandidate_ValidBackwardCompat pins that the Valid()
|
|
// method returns true only for ActionValid status.
|
|
func TestValidateActionCandidate_ValidBackwardCompat(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status ActionValidationStatus
|
|
want bool
|
|
}{
|
|
{"unresolved", ActionUnresolved, false},
|
|
{"valid", ActionValid, true},
|
|
{"missing_arg", ActionMissingArgument, false},
|
|
{"invalid_arg", ActionInvalidArgument, false},
|
|
{"ambiguous", ActionAmbiguousTarget, false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
r := ActionValidationResult{Status: tt.status}
|
|
if got := r.Valid(); got != tt.want {
|
|
t.Errorf("Valid() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidateActionCandidate_IssuesNilOnValid pins that Valid results have
|
|
// nil Issues.
|
|
func TestValidateActionCandidate_IssuesNilOnValid(t *testing.T) {
|
|
c := ActionCandidate{Fn: "restart", Args: []string{"nginx"}}
|
|
v := ValidateActionCandidate(c)
|
|
if v.Issues != nil {
|
|
t.Errorf("valid result has issues: %v", v.Issues)
|
|
}
|
|
}
|
|
|
|
// TestValidateActionCandidate_IssuesNilOnUnresolved pins that Unresolved
|
|
// results have nil Issues.
|
|
func TestValidateActionCandidate_IssuesNilOnUnresolved(t *testing.T) {
|
|
c := ActionCandidate{}
|
|
v := ValidateActionCandidate(c)
|
|
if v.Issues != nil {
|
|
t.Errorf("unresolved result has issues: %v", v.Issues)
|
|
}
|
|
}
|
|
|
|
// TestValidateActionCandidate_IssuesPopulatedOnInvalid pins that Invalid
|
|
// results carry populated Issues.
|
|
func TestValidateActionCandidate_IssuesPopulatedOnInvalid(t *testing.T) {
|
|
c := ActionCandidate{Fn: "\t\n"}
|
|
v := ValidateActionCandidate(c)
|
|
if len(v.Issues) == 0 {
|
|
t.Error("invalid result has no issues")
|
|
}
|
|
}
|
|
|
|
// --- ResolutionMethod provenance tests ---
|
|
|
|
// TestResolveActionCandidate_GrammarFixed pins that a stage-0 grammar that
|
|
// hardcodes fn (praxis/task-status) carries grammar_fixed provenance.
|
|
func TestResolveActionCandidate_GrammarFixed(t *testing.T) {
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{
|
|
Fn: "resolve_item", HasFn: true,
|
|
ResolvedBy: ActionResolutionGrammarFixed,
|
|
},
|
|
}
|
|
c := ResolveActionCandidate(dec, nil)
|
|
if !c.ActionResolved() {
|
|
t.Fatal("expected resolved candidate")
|
|
}
|
|
if c.ResolvedBy != ActionResolutionGrammarFixed {
|
|
t.Errorf("ResolvedBy = %q, want grammar_fixed", c.ResolvedBy)
|
|
}
|
|
if c.Fn != "resolve_item" {
|
|
t.Errorf("Fn = %q, want resolve_item", c.Fn)
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_GrammarMatcher pins that a stage-0 grammar that
|
|
// invokes the ActMatcher (wakeword-act) carries grammar_matcher provenance.
|
|
func TestResolveActionCandidate_GrammarMatcher(t *testing.T) {
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{
|
|
Fn: "restart", Args: []string{"nginx"}, HasFn: true,
|
|
ResolvedBy: ActionResolutionGrammarMatcher,
|
|
},
|
|
}
|
|
c := ResolveActionCandidate(dec, nil)
|
|
if !c.ActionResolved() {
|
|
t.Fatal("expected resolved candidate")
|
|
}
|
|
if c.ResolvedBy != ActionResolutionGrammarMatcher {
|
|
t.Errorf("ResolvedBy = %q, want grammar_matcher", c.ResolvedBy)
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_ExtractorRaw pins that a classifier/heads-routed
|
|
// act whose extractor matched carries extractor_raw provenance.
|
|
func TestResolveActionCandidate_ExtractorRaw(t *testing.T) {
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{
|
|
Fn: "restart", HasFn: true,
|
|
ResolvedBy: ActionResolutionExtractorRaw,
|
|
},
|
|
Producer: RouteProducerClassifier,
|
|
}
|
|
c := ResolveActionCandidate(dec, nil)
|
|
if !c.ActionResolved() {
|
|
t.Fatal("expected resolved candidate")
|
|
}
|
|
if c.ResolvedBy != ActionResolutionExtractorRaw {
|
|
t.Errorf("ResolvedBy = %q, want extractor_raw", c.ResolvedBy)
|
|
}
|
|
if c.Producer != RouteProducerClassifier {
|
|
t.Errorf("Producer = %q, want classifier", c.Producer)
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_ExtractorLLMText pins that an LLM-routed act
|
|
// whose function was resolved from the LLM's cleaned text carries
|
|
// extractor_llm_text provenance.
|
|
func TestResolveActionCandidate_ExtractorLLMText(t *testing.T) {
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{
|
|
Fn: "restart", HasFn: true,
|
|
ResolvedBy: ActionResolutionExtractorLLMText,
|
|
},
|
|
Producer: RouteProducerLLM,
|
|
}
|
|
c := ResolveActionCandidate(dec, nil)
|
|
if !c.ActionResolved() {
|
|
t.Fatal("expected resolved candidate")
|
|
}
|
|
if c.ResolvedBy != ActionResolutionExtractorLLMText {
|
|
t.Errorf("ResolvedBy = %q, want extractor_llm_text", c.ResolvedBy)
|
|
}
|
|
if c.Producer != RouteProducerLLM {
|
|
t.Errorf("Producer = %q, want llm", c.Producer)
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_FallbackMatcher pins that a matcher fallback
|
|
// carry fallback_matcher provenance.
|
|
func TestResolveActionCandidate_FallbackMatcher(t *testing.T) {
|
|
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{Text: "restart nginx"},
|
|
}
|
|
c := ResolveActionCandidate(dec, m)
|
|
if !c.ActionResolved() {
|
|
t.Fatal("expected resolved candidate")
|
|
}
|
|
if c.ResolvedBy != ActionResolutionFallbackMatcher {
|
|
t.Errorf("ResolvedBy = %q, want fallback_matcher", c.ResolvedBy)
|
|
}
|
|
if c.Source != ActionSourceMatcher {
|
|
t.Errorf("Source = %q, want matcher", c.Source)
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_UnresolvedNoFalseMethod pins that an unresolved
|
|
// candidate (matcher miss) has empty ResolvedBy.
|
|
func TestResolveActionCandidate_UnresolvedNoFalseMethod(t *testing.T) {
|
|
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{Text: "deploy the thing"},
|
|
}
|
|
c := ResolveActionCandidate(dec, m)
|
|
if c.ActionResolved() {
|
|
t.Fatal("expected unresolved candidate")
|
|
}
|
|
if c.ResolvedBy != "" {
|
|
t.Errorf("ResolvedBy = %q, want empty for unresolved", c.ResolvedBy)
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_PropagatesResolvedBy pins that ResolvedBy
|
|
// travels from Slots through to ActionCandidate for every route-sourced case.
|
|
func TestResolveActionCandidate_PropagatesResolvedBy(t *testing.T) {
|
|
methods := []ActionResolutionMethod{
|
|
ActionResolutionGrammarFixed,
|
|
ActionResolutionGrammarMatcher,
|
|
ActionResolutionExtractorRaw,
|
|
ActionResolutionExtractorLLMText,
|
|
}
|
|
for _, m := range methods {
|
|
t.Run(string(m), func(t *testing.T) {
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{Fn: "restart", HasFn: true, ResolvedBy: m},
|
|
}
|
|
c := ResolveActionCandidate(dec, nil)
|
|
if c.ResolvedBy != m {
|
|
t.Errorf("ResolvedBy = %q, want %q", c.ResolvedBy, m)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_FnArgsIdentical pins that adding ResolvedBy
|
|
// does not change the selected fn or args for any path.
|
|
func TestResolveActionCandidate_FnArgsIdentical(t *testing.T) {
|
|
// Route-sourced.
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{
|
|
Fn: "restart", Args: []string{"nginx"}, HasFn: true,
|
|
ResolvedBy: ActionResolutionGrammarMatcher,
|
|
},
|
|
}
|
|
c := ResolveActionCandidate(dec, nil)
|
|
if c.Fn != "restart" || len(c.Args) != 1 || c.Args[0] != "nginx" {
|
|
t.Errorf("route fn/args changed: Fn=%q Args=%v", c.Fn, c.Args)
|
|
}
|
|
|
|
// Matcher-sourced.
|
|
m := DefaultActMatcher{Fns: []string{"restart"}}
|
|
dec2 := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{Text: "restart nginx"},
|
|
}
|
|
c2 := ResolveActionCandidate(dec2, m)
|
|
if c2.Fn != "restart" || len(c2.Args) != 1 || c2.Args[0] != "nginx" {
|
|
t.Errorf("matcher fn/args changed: Fn=%q Args=%v", c2.Fn, c2.Args)
|
|
}
|
|
}
|