Files
Maven/internal/router/actioncandidate_test.go
T
claude 66c578a6f4 router: introduce typed ActionValidationStatus boundary (slice 5)
Introduce ActionValidationStatus enum (valid, unresolved, missing_argument,
invalid_argument, ambiguous_target) as the typed classification of validation
outcomes. ActionValidationResult now carries Status instead of boolean flags.

Backward-compatible: Unresolved() and Valid() methods preserved on the result.
Existing validation behavior unchanged: only blank Fn produces invalid_argument.
All downstream behavior (proposeGap, confirmation, task_status, praxis, hexis)
unchanged.

Tests added for all five status values, backward compatibility, and the full
validation → execution boundary.
2026-09-06 13:07:08 +04:00

355 lines
10 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")
}
}