diff --git a/internal/agentcapabilities/markdown_helpers_test.go b/internal/agentcapabilities/markdown_helpers_test.go new file mode 100644 index 000000000..b2105eca0 --- /dev/null +++ b/internal/agentcapabilities/markdown_helpers_test.go @@ -0,0 +1,173 @@ +package agentcapabilities + +import "testing" + +// This white-box test covers the three pure helper functions in markdown.go: +// - markdownCountWord(count int) string +// - pluralize(word string, count int) string +// - mcpCapabilityCategoryHeading(category string, categories []CapabilityCategory) string +// +// The tests assert the exact returned string for every switch arm, the default +// arm, boundary values, and empty/nil inputs. No source behaviour is changed. + +func TestMarkdownCountWord(t *testing.T) { + tests := []struct { + name string + count int + want string + }{ + // Each named switch arm. + {name: "zero", count: 0, want: "zero"}, + {name: "one", count: 1, want: "one"}, + {name: "two", count: 2, want: "two"}, + {name: "three", count: 3, want: "three"}, + {name: "four", count: 4, want: "four"}, + {name: "five upper boundary of word arms", count: 5, want: "five"}, + // Default arm: decimal formatting. + {name: "six first default value", count: 6, want: "6"}, + {name: "negative default value", count: -1, want: "-1"}, + {name: "two digit default value", count: 42, want: "42"}, + {name: "large default value", count: 1000000, want: "1000000"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := markdownCountWord(tc.count); got != tc.want { + t.Fatalf("markdownCountWord(%d) = %q, want %q", tc.count, got, tc.want) + } + }) + } +} + +func TestPluralize(t *testing.T) { + tests := []struct { + name string + word string + count int + want string + }{ + // count == 1 identity branch. + {name: "count one returns word unchanged", word: "surface", count: 1, want: "surface"}, + {name: "count one with empty word", word: "", count: 1, want: ""}, + // else branch: append "s". + {name: "count zero appends s", word: "surface", count: 0, want: "surfaces"}, + {name: "count two appends s", word: "surface", count: 2, want: "surfaces"}, + {name: "negative count appends s", word: "surface", count: -3, want: "surfaces"}, + {name: "empty word appends s", word: "", count: 5, want: "s"}, + // Confirms the implementation is a naive append: no "es"/"ies" rules. + {name: "naive append does not special case box", word: "box", count: 3, want: "boxs"}, + {name: "count one does not special case box", word: "box", count: 1, want: "box"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := pluralize(tc.word, tc.count); got != tc.want { + t.Fatalf("pluralize(%q, %d) = %q, want %q", tc.word, tc.count, got, tc.want) + } + }) + } +} + +func TestMCPCapabilityCategoryHeading(t *testing.T) { + tests := []struct { + name string + category string + categories []CapabilityCategory + want string + }{ + // Matched descriptor with a non-empty label returns the label. + { + name: "matched descriptor non-empty label returns label", + category: "context", + categories: []CapabilityCategory{{ID: "context", Label: "Context (read-only)"}}, + want: "Context (read-only)", + }, + // Matched descriptor with an empty label falls back to the raw id. + { + name: "matched descriptor empty label returns raw category", + category: "context", + categories: []CapabilityCategory{{ID: "context", Label: ""}}, + want: "context", + }, + // Matched descriptor with a whitespace-only label is treated as empty. + { + name: "matched descriptor whitespace label returns raw category", + category: "context", + categories: []CapabilityCategory{{ID: "context", Label: " "}}, + want: "context", + }, + // The descriptor ID is trimmed before comparison. + { + name: "matched descriptor id is trimmed before comparison", + category: "context", + categories: []CapabilityCategory{{ID: " context ", Label: "Context"}}, + want: "Context", + }, + // First matching descriptor wins: an empty label on the first match + // short-circuits and returns the raw id even if a later descriptor + // carries a label. + { + name: "first match with empty label wins over later label", + category: "context", + categories: []CapabilityCategory{{ID: "context", Label: ""}, {ID: "context", Label: "Context"}}, + want: "context", + }, + // No match: the "uncategorized" sentinel maps to the title-cased fallback. + { + name: "no match uncategorized sentinel returns title cased fallback", + category: "uncategorized", + categories: []CapabilityCategory{{ID: "context", Label: "Context"}}, + want: "Uncategorized", + }, + // No match: any other id is returned verbatim. + { + name: "no match unknown category returns raw category", + category: "custom", + categories: []CapabilityCategory{{ID: "context", Label: "Context"}}, + want: "custom", + }, + // No match against an empty descriptor slice returns the raw category. + { + name: "no match empty descriptor slice returns raw category", + category: "custom", + categories: []CapabilityCategory{}, + want: "custom", + }, + // No match against a nil descriptor slice: the uncategorized fallback. + { + name: "nil descriptor slice uncategorized sentinel", + category: "uncategorized", + categories: nil, + want: "Uncategorized", + }, + // An explicit "uncategorized" descriptor with an empty label returns the + // lowercase raw id, NOT the title-cased "Uncategorized" fallback, + // because the descriptor loop takes precedence. + { + name: "explicit uncategorized descriptor empty label returns raw id", + category: "uncategorized", + categories: []CapabilityCategory{{ID: "uncategorized", Label: ""}}, + want: "uncategorized", + }, + // An explicit "uncategorized" descriptor with a label returns the label. + { + name: "explicit uncategorized descriptor with label returns label", + category: "uncategorized", + categories: []CapabilityCategory{{ID: "uncategorized", Label: "Miscellaneous"}}, + want: "Miscellaneous", + }, + // Empty category with no matching descriptor returns the empty string. + { + name: "empty category no match returns empty", + category: "", + categories: []CapabilityCategory{{ID: "context", Label: "Context"}}, + want: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := mcpCapabilityCategoryHeading(tc.category, tc.categories); got != tc.want { + t.Fatalf("mcpCapabilityCategoryHeading(%q, %+v) = %q, want %q", + tc.category, tc.categories, got, tc.want) + } + }) + } +} diff --git a/internal/agentcapabilities/projection_pathparams_test.go b/internal/agentcapabilities/projection_pathparams_test.go new file mode 100644 index 000000000..a6044a004 --- /dev/null +++ b/internal/agentcapabilities/projection_pathparams_test.go @@ -0,0 +1,227 @@ +package agentcapabilities + +import ( + "strings" + "testing" +) + +// TestSubstitutePathParametersTable exercises every branch of +// SubstitutePathParameters: the no-placeholder passthrough, the happy +// substitution path (single, multiple, and repeated placeholders), the +// missing-argument error path, the non-string-argument error path, and the +// combined missing + non-string collection path (whose order is deterministic +// because ReplaceAllStringFunc walks placeholder matches left-to-right rather +// than iterating the args map). It also pins regex edge cases (placeholder +// names that the {a-zA-Z][a-zA-Z0-9]*} grammar rejects are left literal) and +// the end-to-end percent-escaping behavior surfaced through +// EscapePathSegmentParameter. +func TestSubstitutePathParametersTable(t *testing.T) { + tests := []struct { + name string + path string + args map[string]any + wantPath string + wantErr bool + errSubstr string + }{ + { + name: "no placeholders returns path unchanged", + path: "/api/agent/patrol-control/status", + args: map[string]any{"resourceId": "ignored"}, + wantPath: "/api/agent/patrol-control/status", + }, + { + name: "empty path returns empty string with no error", + path: "", + args: nil, + wantPath: "", + }, + { + name: "nil args with no placeholders returns path unchanged", + path: "/static/route", + args: nil, + wantPath: "/static/route", + }, + { + name: "nil args map with placeholder reported missing", + path: "/api/resources/{resourceId}", + args: nil, + wantErr: true, + errSubstr: "missing path argument(s): resourceId", + }, + { + name: "empty args map with placeholder reported missing", + path: "/api/resources/{resourceId}/operator-state", + args: map[string]any{}, + wantErr: true, + errSubstr: "missing path argument(s): resourceId", + }, + { + name: "single placeholder substituted and escaped", + path: "/api/resources/{resourceId}/operator-state", + args: map[string]any{"resourceId": "vm:101"}, + wantPath: "/api/resources/vm%3A101/operator-state", + }, + { + name: "multiple placeholders substituted in match order", + path: "/api/config/nodes/{nodeId}/test/{resourceId}", + args: map[string]any{"nodeId": "pve/lab node", "resourceId": "vm:101"}, + wantPath: "/api/config/nodes/pve%2Flab%20node/test/vm%3A101", + }, + { + name: "repeated placeholder substituted at every occurrence", + path: "/{a}/and/{a}", + args: map[string]any{"a": "x:y"}, + wantPath: "/x%3Ay/and/x%3Ay", + }, + { + name: "integer arg reported as not a string", + path: "/api/actions/{actionId}/decision", + args: map[string]any{"actionId": 42}, + wantErr: true, + errSubstr: "actionId (not a string)", + }, + { + name: "bool arg reported as not a string", + path: "/api/actions/{actionId}/decision", + args: map[string]any{"actionId": true}, + wantErr: true, + errSubstr: "actionId (not a string)", + }, + { + name: "map arg reported as not a string", + path: "/api/actions/{actionId}/decision", + args: map[string]any{"actionId": map[string]any{"nested": 1}}, + wantErr: true, + errSubstr: "actionId (not a string)", + }, + { + name: "slice arg reported as not a string", + path: "/api/actions/{actionId}/decision", + args: map[string]any{"actionId": []string{"a", "b"}}, + wantErr: true, + errSubstr: "actionId (not a string)", + }, + { + name: "explicit nil arg value reported as not a string", + path: "/api/actions/{actionId}/decision", + args: map[string]any{"actionId": nil}, + wantErr: true, + errSubstr: "actionId (not a string)", + }, + { + name: "mixed missing and non-string collected in placeholder order", + path: "/{first}/{second}/{third}", + args: map[string]any{"second": 42}, + wantErr: true, + errSubstr: "missing path argument(s): first, second (not a string), third", + }, + { + name: "empty string value is a valid segment", + path: "/api/{a}/end", + args: map[string]any{"a": ""}, + wantPath: "/api//end", + }, + { + name: "extra unknown string args are ignored", + path: "/api/{a}", + args: map[string]any{"a": "x", "extra": "y"}, + wantPath: "/api/x", + }, + { + name: "extra non-string arg unreferenced by path is ignored", + path: "/api/{a}", + args: map[string]any{"a": "x", "ignored": 42}, + wantPath: "/api/x", + }, + { + name: "placeholder name with leading underscore is not matched stays literal", + path: "/api/{_name}", + args: map[string]any{"_name": "x"}, + wantPath: "/api/{_name}", + }, + { + name: "placeholder name containing dash is not matched stays literal", + path: "/api/{action-id}", + args: map[string]any{"action-id": "x"}, + wantPath: "/api/{action-id}", + }, + { + name: "placeholder name containing underscore is not matched stays literal", + path: "/api/{a_b}", + args: map[string]any{"a_b": "x"}, + wantPath: "/api/{a_b}", + }, + { + name: "placeholder name with trailing digits is matched", + path: "/api/{node1}", + args: map[string]any{"node1": "a/b"}, + wantPath: "/api/a%2Fb", + }, + { + name: "value of only unreserved bytes stays literal", + path: "/{a}", + args: map[string]any{"a": "Aa0-._~"}, + wantPath: "/Aa0-._~", + }, + { + name: "multibyte value escapes each UTF-8 byte as uppercase hex", + path: "/{a}", + args: map[string]any{"a": "é"}, + wantPath: "/%C3%A9", + }, + { + name: "control byte tab escaped as percent hex", + path: "/{a}", + args: map[string]any{"a": "a\tb"}, + wantPath: "/a%09b", + }, + { + name: "slash space and colon all escaped in a single value", + path: "/{a}", + args: map[string]any{"a": "a b/c:d"}, + wantPath: "/a%20b%2Fc%3Ad", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotPath, err := SubstitutePathParameters(tc.path, tc.args) + if tc.wantErr { + if err == nil { + t.Fatalf("SubstitutePathParameters err = nil, want non-nil; path=%q args=%#v got=%q", tc.path, tc.args, gotPath) + } + if tc.errSubstr != "" && !strings.Contains(err.Error(), tc.errSubstr) { + t.Fatalf("SubstitutePathParameters err = %q, want substring %q", err.Error(), tc.errSubstr) + } + if gotPath != "" { + t.Fatalf("SubstitutePathParameters path = %q on error, want empty string", gotPath) + } + return + } + if err != nil { + t.Fatalf("SubstitutePathParameters err = %v, want nil; path=%q args=%#v", err, tc.path, tc.args) + } + if gotPath != tc.wantPath { + t.Fatalf("SubstitutePathParameters path = %q, want %q", gotPath, tc.wantPath) + } + }) + } +} + +// TestSubstitutePathParametersErrorIsNonTyped confirms the error returned by +// SubstitutePathParameters is a plain fmt.Errorf-wrapped string (not a typed +// sentinel like CapabilityLookupError), pinning the error-shape contract that +// adapters rely on for path-argument validation. +func TestSubstitutePathParametersErrorIsNonTyped(t *testing.T) { + _, err := SubstitutePathParameters("/{missing}", map[string]any{}) + if err == nil { + t.Fatal("SubstitutePathParameters err = nil, want non-nil for missing argument") + } + if _, ok := err.(CapabilityLookupError); ok { + t.Fatalf("SubstitutePathParameters must not return CapabilityLookupError, got %T: %v", err, err) + } + if !strings.HasPrefix(err.Error(), "missing path argument(s):") { + t.Fatalf("SubstitutePathParameters err = %q, want 'missing path argument(s):' prefix", err.Error()) + } +} diff --git a/internal/ai/chat/service_investigation_error_test.go b/internal/ai/chat/service_investigation_error_test.go new file mode 100644 index 000000000..ee4c58832 --- /dev/null +++ b/internal/ai/chat/service_investigation_error_test.go @@ -0,0 +1,379 @@ +package chat + +import ( + "errors" + "fmt" + "testing" +) + +// chatinvesterrorCustom is a concrete error type used to exercise +// errors.As across both failure channels of InvestigationRunError. +type chatinvesterrorCustom struct{ msg string } + +func (e *chatinvesterrorCustom) Error() string { return e.msg } + +// chatinvesterrorWrap is a second distinct concrete type so that an +// errors.As match on one channel does not accidentally match the other. +type chatinvesterrorWrap struct{ inner error } + +func (e *chatinvesterrorWrap) Error() string { return "wrap:" + e.inner.Error() } + +func (e *chatinvesterrorWrap) Unwrap() error { return e.inner } + +func TestNewInvestigationRunError(t *testing.T) { + runErr := errors.New("runtime exploded") + proposalErr := errors.New("proposal invalid") + + cases := []struct { + name string + runErr error + proposalErr error + wantNil bool + wantRunFailure error + wantPropFail error + }{ + { + name: "both nil returns nil", + runErr: nil, + proposalErr: nil, + wantNil: true, + wantRunFailure: nil, + wantPropFail: nil, + }, + { + name: "run only populates run channel", + runErr: runErr, + proposalErr: nil, + wantNil: false, + wantRunFailure: runErr, + wantPropFail: nil, + }, + { + name: "proposal only populates proposal channel", + runErr: nil, + proposalErr: proposalErr, + wantNil: false, + wantRunFailure: nil, + wantPropFail: proposalErr, + }, + { + name: "both populated preserves both channels", + runErr: runErr, + proposalErr: proposalErr, + wantNil: false, + wantRunFailure: runErr, + wantPropFail: proposalErr, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := NewInvestigationRunError(tc.runErr, tc.proposalErr) + if tc.wantNil { + if got != nil { + t.Fatalf("expected nil error, got %+v", got) + } + return + } + if got == nil { + t.Fatalf("expected non-nil error, got nil") + } + if run := got.RunFailure(); run != tc.wantRunFailure { + t.Errorf("RunFailure() = %v, want %v", run, tc.wantRunFailure) + } + if prop := got.ProposalFailure(); prop != tc.wantPropFail { + t.Errorf("ProposalFailure() = %v, want %v", prop, tc.wantPropFail) + } + }) + } +} + +func TestInvestigationRunError_Error(t *testing.T) { + runErr := errors.New("run failed") + proposalErr := errors.New("proposal failed") + + cases := []struct { + name string + err *InvestigationRunError + wantStr string + }{ + { + name: "nil receiver returns empty string", + err: nil, + wantStr: "", + }, + { + name: "run channel only renders run message", + err: NewInvestigationRunError(runErr, nil), + wantStr: "run failed", + }, + { + name: "proposal channel only renders proposal message", + err: NewInvestigationRunError(nil, proposalErr), + wantStr: "proposal failed", + }, + { + name: "both channels join with newline", + err: NewInvestigationRunError(runErr, proposalErr), + wantStr: "run failed\nproposal failed", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := tc.err.Error() + if got != tc.wantStr { + t.Errorf("Error() = %q, want %q", got, tc.wantStr) + } + }) + } +} + +func TestInvestigationRunError_Unwrap(t *testing.T) { + runSentinel := errors.New("sentinel-run") + proposalSentinel := errors.New("sentinel-proposal") + runTyped := &chatinvesterrorCustom{msg: "typed-run"} + proposalTyped := &chatinvesterrorWrap{inner: errors.New("inner-proposal")} + + t.Run("nil receiver returns nil slice", func(t *testing.T) { + var err *InvestigationRunError + if got := err.Unwrap(); got != nil { + t.Fatalf("nil receiver Unwrap() = %v, want nil", got) + } + }) + + t.Run("both channels surface in slice preserving order and nil entries", func(t *testing.T) { + err := NewInvestigationRunError(runSentinel, proposalSentinel) + got := err.Unwrap() + if len(got) != 2 { + t.Fatalf("Unwrap() length = %d, want 2", len(got)) + } + if got[0] != runSentinel { + t.Errorf("Unwrap()[0] = %v, want %v", got[0], runSentinel) + } + if got[1] != proposalSentinel { + t.Errorf("Unwrap()[1] = %v, want %v", got[1], proposalSentinel) + } + }) + + t.Run("run-only slice keeps proposal slot nil", func(t *testing.T) { + err := NewInvestigationRunError(runSentinel, nil) + got := err.Unwrap() + if len(got) != 2 { + t.Fatalf("Unwrap() length = %d, want 2", len(got)) + } + if got[0] != runSentinel { + t.Errorf("Unwrap()[0] = %v, want %v", got[0], runSentinel) + } + if got[1] != nil { + t.Errorf("Unwrap()[1] = %v, want nil", got[1]) + } + }) + + t.Run("proposal-only slice keeps run slot nil", func(t *testing.T) { + err := NewInvestigationRunError(nil, proposalSentinel) + got := err.Unwrap() + if len(got) != 2 { + t.Fatalf("Unwrap() length = %d, want 2", len(got)) + } + if got[0] != nil { + t.Errorf("Unwrap()[0] = %v, want nil", got[0]) + } + if got[1] != proposalSentinel { + t.Errorf("Unwrap()[1] = %v, want %v", got[1], proposalSentinel) + } + }) + + t.Run("errors.Is matches run sentinel", func(t *testing.T) { + err := NewInvestigationRunError(runSentinel, proposalSentinel) + if !errors.Is(err, runSentinel) { + t.Errorf("errors.Is(err, runSentinel) = false, want true") + } + }) + + t.Run("errors.Is matches proposal sentinel", func(t *testing.T) { + err := NewInvestigationRunError(runSentinel, proposalSentinel) + if !errors.Is(err, proposalSentinel) { + t.Errorf("errors.Is(err, proposalSentinel) = false, want true") + } + }) + + t.Run("errors.Is false for unrelated error", func(t *testing.T) { + err := NewInvestigationRunError(runSentinel, proposalSentinel) + other := errors.New("unrelated") + if errors.Is(err, other) { + t.Errorf("errors.Is(err, unrelated) = true, want false") + } + }) + + t.Run("errors.Is walks wrapped inner chain via proposal channel", func(t *testing.T) { + inner := errors.New("inner-proposal") + err := NewInvestigationRunError(nil, &chatinvesterrorWrap{inner: inner}) + if !errors.Is(err, inner) { + t.Errorf("errors.Is(err, inner) = false, want true") + } + }) + + t.Run("errors.As extracts run channel typed error", func(t *testing.T) { + err := NewInvestigationRunError(runTyped, nil) + var target *chatinvesterrorCustom + if !errors.As(err, &target) { + t.Fatalf("errors.As for run typed error = false, want true") + } + if target != runTyped { + t.Errorf("errors.As target = %p, want %p", target, runTyped) + } + }) + + t.Run("errors.As extracts proposal channel typed error", func(t *testing.T) { + err := NewInvestigationRunError(nil, proposalTyped) + var target *chatinvesterrorWrap + if !errors.As(err, &target) { + t.Fatalf("errors.As for proposal typed error = false, want true") + } + if target != proposalTyped { + t.Errorf("errors.As target = %p, want %p", target, proposalTyped) + } + }) + + t.Run("errors.As does not match unrelated type", func(t *testing.T) { + err := NewInvestigationRunError(runTyped, proposalTyped) + var target *chatinvesterrorCustom + _ = target + var wrap *chatinvesterrorWrap + if !errors.As(err, &wrap) { + t.Errorf("errors.As for wrap type = false, want true") + } + }) +} + +func TestInvestigationRunError_RunFailure(t *testing.T) { + runErr := errors.New("runtime failure") + + cases := []struct { + name string + err *InvestigationRunError + want error + }{ + { + name: "nil receiver returns nil", + err: nil, + want: nil, + }, + { + name: "run set returns run error", + err: NewInvestigationRunError(runErr, nil), + want: runErr, + }, + { + name: "only proposal set returns nil for run channel", + err: NewInvestigationRunError(nil, errors.New("proposal")), + want: nil, + }, + { + name: "both set returns run error only", + err: NewInvestigationRunError(runErr, errors.New("proposal")), + want: runErr, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := tc.err.RunFailure() + if got != tc.want { + t.Errorf("RunFailure() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestInvestigationRunError_ProposalFailure(t *testing.T) { + proposalErr := errors.New("proposal failure") + + cases := []struct { + name string + err *InvestigationRunError + want error + }{ + { + name: "nil receiver returns nil", + err: nil, + want: nil, + }, + { + name: "proposal set returns proposal error", + err: NewInvestigationRunError(nil, proposalErr), + want: proposalErr, + }, + { + name: "only run set returns nil for proposal channel", + err: NewInvestigationRunError(errors.New("run"), nil), + want: nil, + }, + { + name: "both set returns proposal error only", + err: NewInvestigationRunError(errors.New("run"), proposalErr), + want: proposalErr, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := tc.err.ProposalFailure() + if got != tc.want { + t.Errorf("ProposalFailure() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestInvestigationRunError_NilReceiverChainedMethodSafety(t *testing.T) { + // A typed-nil *InvestigationRunError must be safe to call every + // method on without panicking; this guards the nil-receiver arms of + // Error, Unwrap, RunFailure and ProposalFailure. + var err *InvestigationRunError + ensure := func(name string, fn func()) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("%s panicked on nil receiver: %v", name, r) + } + }() + fn() + } + ensure("Error", func() { + if got := err.Error(); got != "" { + t.Errorf("nil Error() = %q, want %q", got, "") + } + }) + ensure("Unwrap", func() { + if got := err.Unwrap(); got != nil { + t.Errorf("nil Unwrap() = %v, want nil", got) + } + }) + ensure("RunFailure", func() { + if got := err.RunFailure(); got != nil { + t.Errorf("nil RunFailure() = %v, want nil", got) + } + }) + ensure("ProposalFailure", func() { + if got := err.ProposalFailure(); got != nil { + t.Errorf("nil ProposalFailure() = %v, want nil", got) + } + }) +} + +func TestInvestigationRunError_FmtFormatting(t *testing.T) { + // Guards the implicit fmt-handling: InvestigationRunError has an + // Error() method, so %s/%v render its joined message and %v on the + // value (not pointer) still resolves through Error(). + runErr := errors.New("fmt-run") + proposalErr := fmt.Errorf("fmt-proposal-%d", 7) + err := NewInvestigationRunError(runErr, proposalErr) + wantStr := "fmt-run\nfmt-proposal-7" + if got := fmt.Sprintf("%v", err); got != wantStr { + t.Errorf("fmt %%v = %q, want %q", got, wantStr) + } + if got := fmt.Sprintf("%s", err); got != wantStr { + t.Errorf("fmt %%s = %q, want %q", got, wantStr) + } +} diff --git a/internal/ai/chat/session_compaction_format_test.go b/internal/ai/chat/session_compaction_format_test.go new file mode 100644 index 000000000..ee8448cee --- /dev/null +++ b/internal/ai/chat/session_compaction_format_test.go @@ -0,0 +1,187 @@ +package chat + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestFormatSessionCompactionToolCall exercises every branch of +// formatSessionCompactionToolCall: empty/nil input maps, the JSON marshal +// success and failure paths, secret redaction on both Input and Output, name +// trimming, output sanitization (including whitespace-only and emoji-stripped +// outputs), and the tool-output truncation boundary at +// sessionCompactionToolOutputMaxChars. +func TestFormatSessionCompactionToolCall(t *testing.T) { + const toolOutputBudget = sessionCompactionToolOutputMaxChars // 1200 + longOutput := strings.Repeat("a", toolOutputBudget+100) + boundaryOutput := strings.Repeat("a", toolOutputBudget) + truncatedTail := "\n[truncated]" + + cases := []struct { + name string + toolCall ToolCall + want string + }{ + { + name: "zero value renders empty name and default input object literal", + toolCall: ToolCall{}, + want: "Tool call: {}", + }, + { + name: "name is trimmed and default input object literal is kept", + toolCall: ToolCall{ + Name: " list_pods ", + }, + want: "Tool call: list_pods {}", + }, + { + name: "nil input leaves default object literal", + toolCall: ToolCall{ + Name: "ping", + Input: nil, + }, + want: "Tool call: ping {}", + }, + { + name: "empty input map leaves default object literal", + toolCall: ToolCall{ + Name: "ping", + Input: map[string]interface{}{}, + }, + want: "Tool call: ping {}", + }, + { + name: "single key input is marshaled to compact json", + toolCall: ToolCall{ + Name: "read_file", + Input: map[string]interface{}{"path": "/etc/hosts"}, + }, + want: `Tool call: read_file {"path":"/etc/hosts"}`, + }, + { + name: "multiple input keys are emitted in sorted json key order", + toolCall: ToolCall{ + Name: "provision", + Input: map[string]interface{}{ + "zebra": "1", + "apple": "2", + "mango": "3", + }, + }, + want: `Tool call: provision {"apple":"2","mango":"3","zebra":"1"}`, + }, + { + name: "secret bearing input value is redacted after marshal", + toolCall: ToolCall{ + Name: "login", + Input: map[string]interface{}{ + "api_key": "sk-live-secret-value", + }, + }, + want: `Tool call: login {"api_key":"[REDACTED]"}`, + }, + { + name: "input that fails to marshal falls back to default object literal", + toolCall: ToolCall{ + Name: "broken", + Input: map[string]interface{}{"bad": make(chan int)}, + }, + want: "Tool call: broken {}", + }, + { + name: "output only is sanitized and rendered on its own line", + toolCall: ToolCall{ + Output: "pod-1234 running", + }, + want: "Tool call: {}\nTool output: pod-1234 running", + }, + { + name: "whitespace only output produces no output line", + toolCall: ToolCall{ + Name: "noop", + Output: " \n\t ", + }, + want: "Tool call: noop {}", + }, + { + name: "empty output produces no output line", + toolCall: ToolCall{ + Name: "noop", + Output: "", + }, + want: "Tool call: noop {}", + }, + { + name: "secret bearing output value is redacted", + toolCall: ToolCall{ + Name: "fetch", + Output: "api_key: sk-live-secret-value", + }, + want: "Tool call: fetch {}\nTool output: api_key: [REDACTED]", + }, + { + name: "decorative emoji prefix in output is stripped by sanitization", + toolCall: ToolCall{ + Output: "🟢 All good", + }, + want: "Tool call: {}\nTool output: All good", + }, + { + name: "output surrounding whitespace is trimmed", + toolCall: ToolCall{ + Output: "\n health: ok \n", + }, + want: "Tool call: {}\nTool output: health: ok", + }, + { + name: "output exceeding the char budget is truncated", + toolCall: ToolCall{ + Output: longOutput, + }, + want: "Tool call: {}\nTool output: " + boundaryOutput + truncatedTail, + }, + { + name: "output exactly at the char budget is not truncated", + toolCall: ToolCall{ + Output: boundaryOutput, + }, + want: "Tool call: {}\nTool output: " + boundaryOutput, + }, + { + name: "name input and output are all rendered together", + toolCall: ToolCall{ + Name: "run_check", + Input: map[string]interface{}{"host": "vault.lan"}, + Output: "ok", + }, + want: `Tool call: run_check {"host":"vault.lan"}` + "\nTool output: ok", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := formatSessionCompactionToolCall(tc.toolCall) + require.Equal(t, tc.want, got) + }) + } +} + +// TestFormatSessionCompactionToolCallDeterminism asserts that repeated calls +// with the same input produce byte-identical output (json.Marshal key sorting +// and redaction are stable). +func TestFormatSessionCompactionToolCallDeterminism(t *testing.T) { + toolCall := ToolCall{ + Name: "deploy", + Input: map[string]interface{}{"image": "nginx:1.25", "replicas": 3}, + Output: "deployments/apps Deployment created", + } + first := formatSessionCompactionToolCall(toolCall) + for i := 0; i < 10; i++ { + require.Equal(t, first, formatSessionCompactionToolCall(toolCall), + "iteration %d produced non-deterministic output", i) + } + want := `Tool call: deploy {"image":"nginx:1.25","replicas":3}` + "\nTool output: deployments/apps Deployment created" + require.Equal(t, want, first) +} diff --git a/internal/ai/patrol_findings_json_test.go b/internal/ai/patrol_findings_json_test.go new file mode 100644 index 000000000..7f163b1ac --- /dev/null +++ b/internal/ai/patrol_findings_json_test.go @@ -0,0 +1,214 @@ +package ai + +import "testing" + +// TestIsValidJSON exercises every branch of isValidJSON: +// - the empty-after-trim arm (returns false), +// - the fast-reject arm where the first rune is neither '{' nor '[' (returns false), +// - the json.Unmarshal success arm (returns true), +// - the json.Unmarshal failure arm for inputs that pass the prefix check but +// are not well-formed JSON (returns false). +// +// It also pins the deliberate (but perhaps surprising) scalar-rejection +// behavior: top-level JSON scalars such as "123", "true", "null" and "\"s\"" +// are syntactically valid JSON, yet isValidJSON returns false for them because +// the fast-reject only admits object/array roots. See GLM_REPORT.md. +func TestIsValidJSON(t *testing.T) { + tests := []struct { + name string + in string + want bool + }{ + // --- empty-after-trim branch (trimmed == "") --- + { + name: "empty string yields false", + in: "", + want: false, + }, + { + name: "whitespace only yields false", + in: " ", + want: false, + }, + { + name: "tab only yields false", + in: "\t", + want: false, + }, + { + name: "newline only yields false", + in: "\n", + want: false, + }, + { + name: "mixed whitespace only yields false", + in: " \t\n\r ", + want: false, + }, + + // --- fast-reject branch: first rune is neither '{' nor '[' --- + { + name: "plain text rejected by prefix", + in: "hello world", + want: false, + }, + { + name: "json scalar number rejected by prefix even though valid JSON", + in: "123", + want: false, + }, + { + name: "json scalar true rejected by prefix even though valid JSON", + in: "true", + want: false, + }, + { + name: "json scalar false rejected by prefix even though valid JSON", + in: "false", + want: false, + }, + { + name: "json scalar null rejected by prefix even though valid JSON", + in: "null", + want: false, + }, + { + name: "json scalar string rejected by prefix even though valid JSON", + in: `"a string"`, + want: false, + }, + { + name: "xml-looking content rejected by prefix", + in: "not json", + want: false, + }, + { + name: "leading paren rejected by prefix", + in: "(not json)", + want: false, + }, + { + name: "whitespace then non-json prefix still rejected", + in: " not-json-at-all", + want: false, + }, + + // --- json.Unmarshal success branch (valid object/array roots) --- + { + name: "empty object is valid", + in: "{}", + want: true, + }, + { + name: "empty array is valid", + in: "[]", + want: true, + }, + { + name: "simple object is valid", + in: `{"key": "value"}`, + want: true, + }, + { + name: "simple array is valid", + in: "[1, 2, 3]", + want: true, + }, + { + name: "nested object is valid", + in: `{"a": {"b": {"c": [1, 2, {"d": true}]}}}`, + want: true, + }, + { + name: "object with assorted scalar types is valid", + in: `{"n": 42, "s": "hi", "b": true, "z": null, "arr": [1, 2]}`, + want: true, + }, + { + name: "unicode in object values is valid", + in: `{"msg": "héllo wörld 日本語"}`, + want: true, + }, + { + name: "leading whitespace before object is trimmed and valid", + in: " {\"k\": 1}", + want: true, + }, + { + name: "trailing whitespace after object is trimmed and valid", + in: "{\"k\": 1}\n\n", + want: true, + }, + { + name: "leading and trailing whitespace with newlines and tabs trimmed and valid", + in: "\n\t {\"k\": 1}\t\n", + want: true, + }, + { + name: "leading whitespace before array is trimmed and valid", + in: "\t[1, 2]", + want: true, + }, + { + name: "array of objects is valid", + in: `[{"id": 1}, {"id": 2}]`, + want: true, + }, + + // --- json.Unmarshal failure branch: prefix ok but body malformed --- + { + name: "lone open brace fails unmarshal", + in: "{", + want: false, + }, + { + name: "lone open bracket fails unmarshal", + in: "[", + want: false, + }, + { + name: "object missing closing brace fails unmarshal", + in: `{"k": "v"`, + want: false, + }, + { + name: "array missing closing bracket fails unmarshal", + in: `[1, 2, 3`, + want: false, + }, + { + name: "object with trailing comma fails unmarshal", + in: `{"a": 1,}`, + want: false, + }, + { + name: "unquoted key fails unmarshal", + in: `{key: "value"}`, + want: false, + }, + { + name: "single quotes fail unmarshal", + in: `{'key': 'value'}`, + want: false, + }, + { + name: "trailing garbage after valid object fails unmarshal", + in: `{"a": 1} garbage`, + want: false, + }, + { + name: "leading whitespace then malformed object fails unmarshal", + in: " {bad}", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isValidJSON(tt.in) + if got != tt.want { + t.Errorf("isValidJSON(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/ai/patrol_run_recency_test.go b/internal/ai/patrol_run_recency_test.go new file mode 100644 index 000000000..cb4fb2186 --- /dev/null +++ b/internal/ai/patrol_run_recency_test.go @@ -0,0 +1,179 @@ +package ai + +import ( + "testing" + "time" +) + +// patrolrecencyRun builds a PatrolRunRecord with only the fields that +// patrolRecencyFromHistory inspects (CompletedAt and Type) populated, so table +// cases stay readable. The patrolrecency prefix avoids collisions with helpers +// defined in sibling test files in this package. +func patrolrecencyRun(runType string, completedAt time.Time) PatrolRunRecord { + return PatrolRunRecord{ + ID: "rec-" + runType, + Type: runType, + CompletedAt: completedAt, + } +} + +// TestPatrolRecencyFromHistory exercises patrolRecencyFromHistory across every +// branch of its input space: nil/empty history, zero CompletedAt records (the +// skip branch), each arm of the isFullPatrolRun switch (via normalizePatrolRun +// Type: "", "full", "patrol", case/whitespace normalization, and the default +// fallthrough for scoped/verification/unknown types), and the latest-timestamp +// selection for both lastActivity (any run) and lastFullPatrol (full runs only). +// +// Note: patrolRecencyFromHistory uses isFullPatrolRun (not +// isSuccessfulFullPatrolRun), so a full patrol that finished with errors still +// counts toward lastFullPatrol. This is asserted below and called out in +// GLM_REPORT.md as a behavioral observation (it differs from +// shouldSkipInitialFullPatrol, which does gate on success). +func TestPatrolRecencyFromHistory(t *testing.T) { + t1 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + t2 := t1.Add(1 * time.Hour) + t3 := t1.Add(2 * time.Hour) + zero := time.Time{} + + tests := []struct { + name string + history []PatrolRunRecord + wantActivity time.Time + wantActivityZero bool + wantFullPatrol time.Time + wantFullPatrolZero bool + }{ + { + name: "nil history returns zero times", + history: nil, + wantActivityZero: true, + wantFullPatrolZero: true, + }, + { + name: "empty history returns zero times", + history: []PatrolRunRecord{}, + wantActivityZero: true, + wantFullPatrolZero: true, + }, + { + name: "all zero CompletedAt records are skipped", + history: []PatrolRunRecord{patrolrecencyRun("patrol", zero), patrolrecencyRun("scoped", zero)}, + wantActivityZero: true, + wantFullPatrolZero: true, + }, + { + name: "single patrol-type full run populates both", + history: []PatrolRunRecord{patrolrecencyRun("patrol", t1)}, + wantActivity: t1, + wantFullPatrol: t1, + }, + { + name: "empty Type falls through to full patrol arm", + history: []PatrolRunRecord{patrolrecencyRun("", t1)}, + wantActivity: t1, + wantFullPatrol: t1, + }, + { + name: "full Type matches full patrol arm", + history: []PatrolRunRecord{patrolrecencyRun("full", t1)}, + wantActivity: t1, + wantFullPatrol: t1, + }, + { + name: "case-insensitive and whitespace-trimmed Type normalizes to full patrol", + history: []PatrolRunRecord{patrolrecencyRun(" PaTrOl ", t1)}, + wantActivity: t1, + wantFullPatrol: t1, + }, + { + name: "scoped run populates lastActivity only", + history: []PatrolRunRecord{patrolrecencyRun("scoped", t1)}, + wantActivity: t1, + wantFullPatrolZero: true, + }, + { + name: "verification run populates lastActivity only", + history: []PatrolRunRecord{patrolrecencyRun("verification", t1)}, + wantActivity: t1, + wantFullPatrolZero: true, + }, + { + name: "unknown run Type hits default arm and populates lastActivity only", + history: []PatrolRunRecord{patrolrecencyRun("custom-type", t1)}, + wantActivity: t1, + wantFullPatrolZero: true, + }, + { + name: "mixed scoped older and full newer picks the full run for both timestamps", + history: []PatrolRunRecord{patrolrecencyRun("scoped", t1), patrolrecencyRun("patrol", t2)}, + wantActivity: t2, + wantFullPatrol: t2, + }, + { + name: "mixed full older and scoped newer splits lastActivity and lastFullPatrol", + history: []PatrolRunRecord{patrolrecencyRun("patrol", t1), patrolrecencyRun("scoped", t2)}, + wantActivity: t2, + wantFullPatrol: t1, + }, + { + name: "multiple full patrols selects the latest CompletedAt", + history: []PatrolRunRecord{patrolrecencyRun("patrol", t1), patrolrecencyRun("patrol", t3), patrolrecencyRun("patrol", t2)}, + wantActivity: t3, + wantFullPatrol: t3, + }, + { + name: "unsorted mixed-type history selects correct latest for each timestamp", + history: []PatrolRunRecord{patrolrecencyRun("scoped", t3), patrolrecencyRun("patrol", t1), patrolrecencyRun("scoped", t2)}, + wantActivity: t3, + wantFullPatrol: t1, + }, + { + name: "zero CompletedAt mixed with a valid scoped record leaves lastFullPatrol zero", + history: []PatrolRunRecord{patrolrecencyRun("patrol", zero), patrolrecencyRun("scoped", t1), patrolrecencyRun("patrol", zero)}, + wantActivity: t1, + wantFullPatrolZero: true, + }, + { + name: "equal CompletedAt values resolve to that timestamp (After is strict, first-seen wins)", + history: []PatrolRunRecord{patrolrecencyRun("patrol", t1), patrolrecencyRun("patrol", t1)}, + wantActivity: t1, + wantFullPatrol: t1, + }, + { + name: "full patrol with errors still counts toward lastFullPatrol", + history: []PatrolRunRecord{ + { + ID: "errored-full", + Type: "patrol", + CompletedAt: t1, + ErrorCount: 2, + Status: "error", + }, + }, + wantActivity: t1, + wantFullPatrol: t1, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotActivity, gotFullPatrol := patrolRecencyFromHistory(tc.history) + + if tc.wantActivityZero { + if !gotActivity.IsZero() { + t.Fatalf("lastActivity: expected zero time, got %v", gotActivity) + } + } else if !gotActivity.Equal(tc.wantActivity) { + t.Fatalf("lastActivity: expected %v, got %v", tc.wantActivity, gotActivity) + } + + if tc.wantFullPatrolZero { + if !gotFullPatrol.IsZero() { + t.Fatalf("lastFullPatrol: expected zero time, got %v", gotFullPatrol) + } + } else if !gotFullPatrol.Equal(tc.wantFullPatrol) { + t.Fatalf("lastFullPatrol: expected %v, got %v", tc.wantFullPatrol, gotFullPatrol) + } + }) + } +} diff --git a/internal/ai/qualification/lab_predicate_test.go b/internal/ai/qualification/lab_predicate_test.go new file mode 100644 index 000000000..d884fd69f --- /dev/null +++ b/internal/ai/qualification/lab_predicate_test.go @@ -0,0 +1,280 @@ +package qualification + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestComparePredicate(t *testing.T) { + cases := []struct { + name string + observed any + operator string + raw json.RawMessage + wantBool bool + wantErr bool + errSubstr string + }{ + // len(raw) == 0 arm: required-value error. + { + name: "empty raw returns required error", + observed: "anything", + operator: "eq", + raw: json.RawMessage(""), + wantErr: true, + errSubstr: "predicate value is required", + }, + { + name: "nil raw returns required error", + observed: 5, + operator: "eq", + raw: nil, + wantErr: true, + errSubstr: "predicate value is required", + }, + // json.Unmarshal failure arm. + { + name: "invalid json returns unmarshal error", + observed: 1, + operator: "eq", + raw: json.RawMessage("{bad"), + wantErr: true, + }, + // eq operator: fmt.Sprint equality. + { + name: "eq matching strings true", + observed: "running", + operator: "eq", + raw: json.RawMessage(`"running"`), + wantBool: true, + }, + { + name: "eq mismatching strings false", + observed: "running", + operator: "eq", + raw: json.RawMessage(`"stopped"`), + wantBool: false, + }, + { + name: "eq int and json number sprint equal true", + observed: 5, + operator: "eq", + raw: json.RawMessage(`5`), + wantBool: true, + }, + { + name: "eq float with decimal true", + observed: 5.5, + operator: "eq", + raw: json.RawMessage(`5.5`), + wantBool: true, + }, + { + name: "eq bool true", + observed: true, + operator: "eq", + raw: json.RawMessage(`true`), + wantBool: true, + }, + { + name: "eq nil observed and json null sprint equal true", + observed: nil, + operator: "eq", + raw: json.RawMessage(`null`), + wantBool: true, + }, + { + name: "eq int and numeric string coerce equal true", + observed: 5, + operator: "eq", + raw: json.RawMessage(`"5"`), + wantBool: true, + }, + // not_eq operator: fmt.Sprint inequality. + { + name: "not_eq equal values false", + observed: "running", + operator: "not_eq", + raw: json.RawMessage(`"running"`), + wantBool: false, + }, + { + name: "not_eq different values true", + observed: "running", + operator: "not_eq", + raw: json.RawMessage(`"stopped"`), + wantBool: true, + }, + // numeric operators gte/lte/gt/lt. + { + name: "gte greater true", + observed: 10, + operator: "gte", + raw: json.RawMessage(`5`), + wantBool: true, + }, + { + name: "gte equal boundary true", + observed: 5, + operator: "gte", + raw: json.RawMessage(`5`), + wantBool: true, + }, + { + name: "gte less false", + observed: 1, + operator: "gte", + raw: json.RawMessage(`5`), + wantBool: false, + }, + { + name: "lte less true", + observed: 1, + operator: "lte", + raw: json.RawMessage(`5`), + wantBool: true, + }, + { + name: "lte equal boundary true", + observed: 5, + operator: "lte", + raw: json.RawMessage(`5`), + wantBool: true, + }, + { + name: "lte greater false", + observed: 10, + operator: "lte", + raw: json.RawMessage(`5`), + wantBool: false, + }, + { + name: "gt greater true", + observed: 10, + operator: "gt", + raw: json.RawMessage(`5`), + wantBool: true, + }, + { + name: "gt equal boundary false", + observed: 5, + operator: "gt", + raw: json.RawMessage(`5`), + wantBool: false, + }, + { + name: "lt less true", + observed: 1, + operator: "lt", + raw: json.RawMessage(`5`), + wantBool: true, + }, + { + name: "lt equal boundary false", + observed: 5, + operator: "lt", + raw: json.RawMessage(`5`), + wantBool: false, + }, + { + name: "lt greater false", + observed: 10, + operator: "lt", + raw: json.RawMessage(`5`), + wantBool: false, + }, + { + name: "numeric op observed non numeric parse error", + observed: "not-a-number", + operator: "gte", + raw: json.RawMessage(`5`), + wantErr: true, + }, + { + name: "numeric op expected non numeric parse error", + observed: 5, + operator: "gte", + raw: json.RawMessage(`"abc"`), + wantErr: true, + }, + // in operator: array membership via fmt.Sprint. + { + name: "in match true", + observed: "running", + operator: "in", + raw: json.RawMessage(`["running","stopped"]`), + wantBool: true, + }, + { + name: "in no match false", + observed: "paused", + operator: "in", + raw: json.RawMessage(`["running","stopped"]`), + wantBool: false, + }, + { + name: "in empty array false", + observed: "running", + operator: "in", + raw: json.RawMessage(`[]`), + wantBool: false, + }, + { + name: "in numeric coercion match true", + observed: 5, + operator: "in", + raw: json.RawMessage(`[5,"x"]`), + wantBool: true, + }, + { + name: "in string scalar value error", + observed: "running", + operator: "in", + raw: json.RawMessage(`"running"`), + wantErr: true, + errSubstr: "in predicate requires array value", + }, + { + name: "in object value error", + observed: "running", + operator: "in", + raw: json.RawMessage(`{"a":1}`), + wantErr: true, + errSubstr: "in predicate requires array value", + }, + // default arm: unsupported operator. + { + name: "unsupported operator error", + observed: "running", + operator: "weird", + raw: json.RawMessage(`"running"`), + wantErr: true, + errSubstr: "unsupported predicate operator", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := comparePredicate(tc.observed, tc.operator, tc.raw) + if tc.wantErr { + if err == nil { + t.Fatalf("comparePredicate err = nil, want non-nil") + } + if tc.errSubstr != "" && !strings.Contains(err.Error(), tc.errSubstr) { + t.Fatalf("comparePredicate err = %q, want substring %q", err.Error(), tc.errSubstr) + } + if got != false { + t.Fatalf("comparePredicate bool = %v, want false on error path", got) + } + return + } + if err != nil { + t.Fatalf("comparePredicate err = %v, want nil", err) + } + if got != tc.wantBool { + t.Fatalf("comparePredicate bool = %v, want %v", got, tc.wantBool) + } + }) + } +} diff --git a/internal/ai/qualification/report_coverage_test.go b/internal/ai/qualification/report_coverage_test.go new file mode 100644 index 000000000..0e07dcf00 --- /dev/null +++ b/internal/ai/qualification/report_coverage_test.go @@ -0,0 +1,327 @@ +package qualification + +import ( + "reflect" + "testing" + "time" +) + +func TestPercentileIndexBoundaries(t *testing.T) { + cases := []struct { + name string + length int + p float64 + want int + }{ + {"zero length ninetyfive clamps to zero", 0, 0.95, 0}, + {"zero length zero p clamps to zero", 0, 0.0, 0}, + {"zero length huge p clamps to zero", 0, 5.0, 0}, + {"length one p zero clamps to zero", 1, 0.0, 0}, + {"length one p midpoint", 1, 0.5, 0}, + {"length one p ninetyfive", 1, 0.95, 0}, + {"length one p one", 1, 1.0, 0}, + {"length one p over one clamps to last", 1, 1.5, 0}, + {"length two p zero clamps to zero", 2, 0.0, 0}, + {"length two p midpoint picks lower", 2, 0.5, 0}, + {"length two p ninetyfive picks upper", 2, 0.95, 1}, + {"length ten p zero clamps to zero", 10, 0.0, 0}, + {"length ten p five percent", 10, 0.05, 0}, + {"length ten p fifteen percent", 10, 0.15, 1}, + {"length ten p midpoint", 10, 0.5, 4}, + {"length ten p ninetyfive last index", 10, 0.95, 9}, + {"length ten p one last index", 10, 1.0, 9}, + {"length ten p over one clamps to last", 10, 2.0, 9}, + {"length twenty p ninetyfive", 20, 0.95, 18}, + {"length four p zero clamps to zero", 4, 0.0, 0}, + {"length four p ninetyfive", 4, 0.95, 3}, + {"length five p midpoint", 5, 0.5, 2}, + {"length three p ninetyfive last index", 3, 0.95, 2}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := percentileIndex(tc.length, tc.p); got != tc.want { + t.Fatalf("percentileIndex(%d, %v) = %d, want %d", tc.length, tc.p, got, tc.want) + } + }) + } +} + +func TestPercentileInt64Aggregation(t *testing.T) { + cases := []struct { + name string + values []int64 + p float64 + want int64 + }{ + {"nil slice returns zero", nil, 0.95, 0}, + {"empty slice returns zero", []int64{}, 0.95, 0}, + {"single element", []int64{42}, 0.95, 42}, + {"single element p zero", []int64{42}, 0.0, 42}, + {"single element p one", []int64{42}, 1.0, 42}, + {"unsorted five p ninetyfive picks max", []int64{50, 10, 40, 20, 30}, 0.95, 50}, + {"unsorted five p midpoint", []int64{50, 10, 40, 20, 30}, 0.5, 30}, + {"two elements p ninetyfive picks upper", []int64{100, 1}, 0.95, 100}, + {"duplicates collapse", []int64{5, 5, 5, 5}, 0.95, 5}, + {"negatives sort ascending", []int64{-10, 5, -3, 0}, 0.95, 5}, + {"p zero returns minimum", []int64{9, 3, 7, 1}, 0.0, 1}, + {"two elements p midpoint picks lower", []int64{8, 2}, 0.5, 2}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := percentileInt64(tc.values, tc.p); got != tc.want { + t.Fatalf("percentileInt64(%v, %v) = %d, want %d", tc.values, tc.p, got, tc.want) + } + }) + } +} + +func TestPercentileIntAggregation(t *testing.T) { + cases := []struct { + name string + values []int + p float64 + want int + }{ + {"nil slice returns zero", nil, 0.95, 0}, + {"empty slice returns zero", []int{}, 0.95, 0}, + {"single element", []int{7}, 0.95, 7}, + {"single element p zero", []int{7}, 0.0, 7}, + {"unsorted p ninetyfive picks max", []int{4, 2, 5, 1, 3}, 0.95, 5}, + {"unsorted p midpoint", []int{4, 2, 5, 1, 3}, 0.5, 3}, + {"two elements p ninetyfive", []int{8, 2}, 0.95, 8}, + {"p zero returns minimum", []int{9, 1, 5}, 0.0, 1}, + {"duplicates", []int{2, 2, 2}, 0.95, 2}, + {"two elements p midpoint picks lower", []int{8, 2}, 0.5, 2}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := percentileInt(tc.values, tc.p); got != tc.want { + t.Fatalf("percentileInt(%v, %v) = %d, want %d", tc.values, tc.p, got, tc.want) + } + }) + } +} + +func TestPercentileFloatAggregation(t *testing.T) { + cases := []struct { + name string + values []float64 + p float64 + want float64 + }{ + {"nil slice returns zero", nil, 0.95, 0}, + {"empty slice returns zero", []float64{}, 0.95, 0}, + {"single element", []float64{1.5}, 0.95, 1.5}, + {"single element p zero", []float64{1.5}, 0.0, 1.5}, + {"unsorted p ninetyfive picks max", []float64{0.1, 3.3, 2.2, 1.1}, 0.95, 3.3}, + {"unsorted p zero picks min", []float64{0.1, 3.3, 2.2, 1.1}, 0.0, 0.1}, + {"two elements p ninetyfive", []float64{2.5, 0.5}, 0.95, 2.5}, + {"duplicates", []float64{1.0, 1.0, 1.0}, 0.95, 1.0}, + {"negatives sort ascending", []float64{-1.5, 2.0, -0.5}, 0.95, 2.0}, + {"two elements p midpoint picks lower", []float64{2.5, 0.5}, 0.5, 0.5}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := percentileFloat(tc.values, tc.p); got != tc.want { + t.Fatalf("percentileFloat(%v, %v) = %v, want %v", tc.values, tc.p, got, tc.want) + } + }) + } +} + +func TestSortedMapKeys(t *testing.T) { + cases := []struct { + name string + in map[string]bool + want []string + }{ + {"nil map returns empty non-nil", nil, []string{}}, + {"empty map returns empty non-nil", map[string]bool{}, []string{}}, + {"single key", map[string]bool{"a": true}, []string{"a"}}, + {"multiple keys sorted ascending", map[string]bool{"c": true, "a": true, "b": true}, []string{"a", "b", "c"}}, + {"false values still included", map[string]bool{"a": false, "b": true}, []string{"a", "b"}}, + {"ascii ordering uppercase before lowercase", map[string]bool{"z": true, "A": true, "a1": true}, []string{"A", "a1", "z"}}, + {"whitespace keys preserved and sorted by byte", map[string]bool{" b": true, "a": true}, []string{" b", "a"}}, + {"deduplicates identical keys", map[string]bool{"x": true}, []string{"x"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := sortedMapKeys(tc.in) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("sortedMapKeys(%v) = %#v, want %#v", tc.in, got, tc.want) + } + }) + } +} + +func TestSummarizeModelAggregation(t *testing.T) { + // makeReport builds a RunReport populated only with the fields summarizeModel + // consumes; it is a table-local closure to avoid package-level collisions. + makeReport := func(passed bool, gitSHA string, dirty bool, pulseVersion string, + truePos, faults, falsePos, inTok, outTok int, + collectionMs, e2eMs int64, costKnown bool, costUSD float64, hardFailures []string) RunReport { + return RunReport{ + Passed: passed, + Environment: Environment{GitSHA: gitSHA, GitDirty: dirty, PulseVersion: pulseVersion}, + Score: Score{ + TruePositives: truePos, + Faults: faults, + FalsePositives: falsePos, + InputTokens: inTok, + OutputTokens: outTok, + CollectionLatency: time.Duration(collectionMs) * time.Millisecond, + EndToEndLatency: time.Duration(e2eMs) * time.Millisecond, + Cost: CostEstimate{Known: costKnown, USD: costUSD}, + HardFailures: hardFailures, + }, + } + } + + t.Run("nil reports yields zeroed summary with degenerate intervals", func(t *testing.T) { + got := summarizeModel("provider:none", nil) + want := ModelSummary{ + Model: "provider:none", + PassRate: WilsonInterval(0, 0), + FaultRecall: WilsonInterval(0, 0), + GitSHAs: []string{}, + PulseVersions: []string{}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("summarizeModel(nil) = %+v, want %+v", got, want) + } + if got.PassRate.Estimate != 1 || got.PassRate.Lower != 0 || got.PassRate.Upper != 1 { + t.Fatalf("degenerate PassRate interval = %+v", got.PassRate) + } + if got.FaultRecall.Estimate != 1 || got.FaultRecall.Lower != 0 || got.FaultRecall.Upper != 1 { + t.Fatalf("degenerate FaultRecall interval = %+v", got.FaultRecall) + } + }) + + t.Run("single passed run with known cost", func(t *testing.T) { + report := makeReport(true, "abc123", false, "6.0.0", 2, 3, 1, 100, 50, 500, 1000, true, 0.05, nil) + got := summarizeModel("provider:m", []RunReport{report}) + want := ModelSummary{ + Model: "provider:m", + Runs: 1, + Passed: 1, + PassRate: WilsonInterval(1, 1), + FaultRecall: WilsonInterval(2, 3), + FalsePositives: 1, + P95CollectionLatencyMs: 500, + P95LatencyMs: 1000, + P95InputTokens: 100, + P95OutputTokens: 50, + P95CostUSD: 0.05, + KnownCostRuns: 1, + GitSHAs: []string{"abc123"}, + PulseVersions: []string{"6.0.0"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("summarizeModel(single) = %+v, want %+v", got, want) + } + }) + + t.Run("multiple runs aggregate pass recall latency tokens and cost", func(t *testing.T) { + reports := []RunReport{ + makeReport(true, "sha1 ", false, "v1", 1, 2, 0, 10, 5, 100, 200, true, 0.01, nil), + makeReport(false, "", true, " v2 ", 0, 1, 2, 20, 10, 300, 400, false, 0, []string{"hard"}), + makeReport(true, "sha1", false, "", 2, 2, 0, 30, 15, 200, 600, true, 0.03, nil), + } + got := summarizeModel("provider:multi", reports) + want := ModelSummary{ + Model: "provider:multi", + Runs: 3, + Passed: 2, + PassRate: WilsonInterval(2, 3), + FaultRecall: WilsonInterval(3, 5), + FalsePositives: 2, + P95CollectionLatencyMs: 300, + P95LatencyMs: 600, + P95InputTokens: 30, + P95OutputTokens: 15, + P95CostUSD: 0.03, + KnownCostRuns: 2, + HardFailureRuns: 1, + DirtyRuns: 1, + GitSHAs: []string{"sha1"}, + PulseVersions: []string{"v1", "v2"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("summarizeModel(multi) = %+v, want %+v", got, want) + } + if got.PassRate.Success != 2 || got.PassRate.Total != 3 { + t.Fatalf("PassRate inputs = success %d total %d, want 2/3", got.PassRate.Success, got.PassRate.Total) + } + if got.FaultRecall.Success != 3 || got.FaultRecall.Total != 5 { + t.Fatalf("FaultRecall inputs = success %d total %d, want 3/5", got.FaultRecall.Success, got.FaultRecall.Total) + } + }) + + t.Run("all unknown cost yields zero p95 cost", func(t *testing.T) { + report := makeReport(true, "abc", false, "v1", 1, 1, 0, 10, 5, 100, 200, false, 0, nil) + got := summarizeModel("provider:uncosted", []RunReport{report}) + want := ModelSummary{ + Model: "provider:uncosted", + Runs: 1, + Passed: 1, + PassRate: WilsonInterval(1, 1), + FaultRecall: WilsonInterval(1, 1), + P95CollectionLatencyMs: 100, + P95LatencyMs: 200, + P95InputTokens: 10, + P95OutputTokens: 5, + P95CostUSD: 0, + GitSHAs: []string{"abc"}, + PulseVersions: []string{"v1"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("summarizeModel(uncosted) = %+v, want %+v", got, want) + } + if got.KnownCostRuns != 0 { + t.Fatalf("KnownCostRuns = %d, want 0", got.KnownCostRuns) + } + }) + + t.Run("whitespace only sha and version are dropped", func(t *testing.T) { + report := makeReport(true, " ", false, " ", 0, 0, 0, 1, 1, 1, 1, false, 0, nil) + got := summarizeModel("provider:ws", []RunReport{report}) + if len(got.GitSHAs) != 0 { + t.Fatalf("GitSHAs = %#v, want empty", got.GitSHAs) + } + if len(got.PulseVersions) != 0 { + t.Fatalf("PulseVersions = %#v, want empty", got.PulseVersions) + } + if got.GitSHAs == nil || got.PulseVersions == nil { + t.Fatalf("expected non-nil empty slices; GitSHAs=%#v PulseVersions=%#v", got.GitSHAs, got.PulseVersions) + } + }) + + t.Run("distinct shas and versions deduplicated and sorted", func(t *testing.T) { + reports := []RunReport{ + makeReport(true, "shaB", false, "v2", 0, 0, 0, 1, 1, 1, 1, false, 0, nil), + makeReport(true, "shaA", false, "v1", 0, 0, 0, 1, 1, 1, 1, false, 0, nil), + makeReport(true, "shaA", false, "v2", 0, 0, 0, 1, 1, 1, 1, false, 0, nil), + } + got := summarizeModel("provider:dedup", reports) + wantSHA := []string{"shaA", "shaB"} + wantVer := []string{"v1", "v2"} + if !reflect.DeepEqual(got.GitSHAs, wantSHA) { + t.Fatalf("GitSHAs = %#v, want %#v", got.GitSHAs, wantSHA) + } + if !reflect.DeepEqual(got.PulseVersions, wantVer) { + t.Fatalf("PulseVersions = %#v, want %#v", got.PulseVersions, wantVer) + } + }) + + t.Run("hard failure runs counted only when slice non-empty", func(t *testing.T) { + reports := []RunReport{ + makeReport(true, "", false, "", 0, 0, 0, 1, 1, 1, 1, false, 0, nil), + makeReport(true, "", false, "", 0, 0, 0, 1, 1, 1, 1, false, 0, []string{}), + makeReport(true, "", false, "", 0, 0, 0, 1, 1, 1, 1, false, 0, []string{"boom"}), + } + got := summarizeModel("provider:hard", reports) + if got.HardFailureRuns != 1 { + t.Fatalf("HardFailureRuns = %d, want 1", got.HardFailureRuns) + } + }) +} diff --git a/internal/ai/qualification/runner_helpers_test.go b/internal/ai/qualification/runner_helpers_test.go new file mode 100644 index 000000000..3b9553695 --- /dev/null +++ b/internal/ai/qualification/runner_helpers_test.go @@ -0,0 +1,221 @@ +package qualification + +import ( + "testing" + "time" +) + +// This file is a white-box table-test suite for the small pure helpers in +// runner.go and lab.go: phaseDuration, dockerTargetLabel, and commandSummary. +// Every helper and shared var introduced here is prefixed with `qualrunner` to +// avoid collisions with sibling test files in package qualification. + +// qualrunnerPhase is a small constructor keeping table rows terse while letting +// each row spell out its own duration explicitly. +func qualrunnerPhase(name string, duration time.Duration) PhaseTiming { + return PhaseTiming{Name: name, Duration: duration} +} + +func TestPhaseDurationLinearScan(t *testing.T) { + cases := []struct { + name string + phases []PhaseTiming + lookup string + want time.Duration + }{ + { + name: "nil slice returns zero", + phases: nil, + lookup: "anything", + want: 0, + }, + { + name: "empty slice returns zero", + phases: []PhaseTiming{}, + lookup: "anything", + want: 0, + }, + { + name: "name absent returns zero", + phases: []PhaseTiming{qualrunnerPhase("preflight", 1*time.Second)}, + lookup: "does-not-exist", + want: 0, + }, + { + name: "first and only element matches", + phases: []PhaseTiming{qualrunnerPhase("preflight", 250*time.Millisecond)}, + lookup: "preflight", + want: 250 * time.Millisecond, + }, + { + name: "match found later in slice exercises linear scan", + phases: []PhaseTiming{ + qualrunnerPhase("preflight", 1*time.Second), + qualrunnerPhase("provision_and_baseline", 2*time.Second), + qualrunnerPhase("real_model_patrol", 3*time.Second), + }, + lookup: "real_model_patrol", + want: 3 * time.Second, + }, + { + name: "first occurrence wins when name duplicated", + phases: []PhaseTiming{ + qualrunnerPhase("revert_and_verify", 5*time.Second), + qualrunnerPhase("revert_and_verify", 999*time.Second), + }, + lookup: "revert_and_verify", + want: 5 * time.Second, + }, + { + name: "matching phase with explicit zero duration returns zero", + phases: []PhaseTiming{qualrunnerPhase("preflight", 0)}, + lookup: "preflight", + want: 0, + }, + { + name: "negative duration is returned verbatim not clamped", + phases: []PhaseTiming{qualrunnerPhase("preflight", -7*time.Second)}, + lookup: "preflight", + want: -7 * time.Second, + }, + { + name: "empty lookup name never matches a populated phase", + phases: []PhaseTiming{qualrunnerPhase("preflight", 1*time.Second)}, + lookup: "", + want: 0, + }, + { + name: "empty name phase can be matched by empty lookup", + phases: []PhaseTiming{qualrunnerPhase("", 42*time.Millisecond)}, + lookup: "", + want: 42 * time.Millisecond, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + got := phaseDuration(tc.phases, tc.lookup) + if got != tc.want { + t.Fatalf("phaseDuration(%v, %q) = %v, want %v", tc.phases, tc.lookup, got, tc.want) + } + }) + } +} + +func TestDockerTargetLabelBranches(t *testing.T) { + cases := []struct { + name string + target DockerTarget + want string + }{ + { + name: "ssh host wins when set", + target: DockerTarget{SSHHost: "lab.example.com", Context: "ignored"}, + want: "ssh:lab.example.com", + }, + { + name: "ssh host only", + target: DockerTarget{SSHHost: "user@host"}, + want: "ssh:user@host", + }, + { + name: "context used when ssh host empty", + target: DockerTarget{Context: "colima"}, + want: "context:colima", + }, + { + name: "empty target yields bare context prefix", + target: DockerTarget{}, + want: "context:", + }, + { + name: "context empty but ssh host set still uses ssh", + target: DockerTarget{SSHHost: "bridge"}, + want: "ssh:bridge", + }, + { + name: "allow shared host flag does not affect label", + target: DockerTarget{Context: "default", AllowSharedHost: true}, + want: "context:default", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + got := dockerTargetLabel(tc.target) + if got != tc.want { + t.Fatalf("dockerTargetLabel(%+v) = %q, want %q", tc.target, got, tc.want) + } + }) + } +} + +func TestCommandSummaryTruncation(t *testing.T) { + cases := []struct { + name string + in string + args []string + want string + }{ + { + name: "nil args yields name only", + in: "docker", + args: nil, + want: "docker", + }, + { + name: "empty args yields name only", + in: "docker", + args: []string{}, + want: "docker", + }, + { + name: "empty name joined with args", + in: "", + args: []string{"ps"}, + want: " ps", + }, + { + name: "single arg joined", + in: "git", + args: []string{"status"}, + want: "git status", + }, + { + name: "exactly six args not truncated", + in: "docker", + args: []string{"run", "-d", "--name", "svc", "--network", "net"}, + want: "docker run -d --name svc --network net", + }, + { + name: "seven args truncated to first six", + in: "docker", + args: []string{"run", "-d", "--name", "svc", "--network", "net", "image"}, + want: "docker run -d --name svc --network net", + }, + { + name: "many args truncated to first six preserving order", + in: "ssh", + args: []string{"-o", "BatchMode=yes", "-o", "ConnectTimeout=15", "host", "docker", "ps", "-aq", "--no-trunc"}, + want: "ssh -o BatchMode=yes -o ConnectTimeout=15 host docker", + }, + { + name: "five args under boundary passed through", + in: "docker", + args: []string{"network", "create", "--label", "k=v"}, + want: "docker network create --label k=v", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + got := commandSummary(tc.in, tc.args) + if got != tc.want { + t.Fatalf("commandSummary(%q, %v) = %q, want %q", tc.in, tc.args, got, tc.want) + } + }) + } +} diff --git a/internal/ai/tools/tools_discovery_match_test.go b/internal/ai/tools/tools_discovery_match_test.go new file mode 100644 index 000000000..69efa9e31 --- /dev/null +++ b/internal/ai/tools/tools_discovery_match_test.go @@ -0,0 +1,168 @@ +package tools + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestNodeMatchesTargetID exercises nodeMatchesTargetID(nodeName, targetID string) bool +// across every branch: case-insensitive equality, composite instance-node suffix +// match, and the default fallthrough to false. Edge cases (empty inputs, case +// folding, dash-boundary semantics) are asserted against the actual current +// behavior of the function in tools_discovery.go. +func TestNodeMatchesTargetID(t *testing.T) { + tests := []struct { + name string + nodeName string + targetID string + want bool + }{ + // --- Branch 1: case-insensitive equality (strings.EqualFold) -> true --- + { + name: "exact match lowercase", + nodeName: "pve-node", + targetID: "pve-node", + want: true, + }, + { + name: "case-insensitive equality node upper target lower", + nodeName: "PVE-NODE", + targetID: "pve-node", + want: true, + }, + { + name: "case-insensitive equality node lower target upper", + nodeName: "pve-node", + targetID: "PVE-NODE", + want: true, + }, + { + name: "case-insensitive equality mixed case both sides", + nodeName: "HoMeLaB-pVe-NoDe", + targetID: "homelab-pve-node", + want: true, + }, + { + name: "equality single character nodes", + nodeName: "a", + targetID: "A", + want: true, + }, + // Both empty strings are EqualFold-equal, so this short-circuits to true. + { + name: "both empty equality true", + nodeName: "", + targetID: "", + want: true, + }, + + // --- Branch 2: composite instance-node suffix match -> true --- + // targetID ends with "-"+nodeName (case-insensitive). + { + name: "composite instance node suffix match", + nodeName: "pve-node", + targetID: "homelab-pve-node", + want: true, + }, + { + name: "composite suffix case-insensitive node upper", + nodeName: "PVE-NODE", + targetID: "homelab-pve-node", + want: true, + }, + { + name: "composite suffix case-insensitive target upper", + nodeName: "pve-node", + targetID: "HOMELAB-PVE-NODE", + want: true, + }, + { + name: "composite suffix with multi-segment instance prefix", + nodeName: "node1", + targetID: "cluster-a-instance-node1", + want: true, + }, + // Note on actual behavior: suffix match requires only that targetID ends + // with "-"+nodeName, so any nodeName that is itself the trailing dash + // segment of targetID matches, even when nodeName is a generic word. + { + name: "generic suffix segment matches", + nodeName: "node", + targetID: "homelab-pve-node", + want: true, + }, + // Documented quirk: with nodeName empty, suffix becomes just "-", so any + // targetID ending in "-" matches. See GLM_REPORT.md. + { + name: "empty nodeName matches targetID ending in dash", + nodeName: "", + targetID: "abc-", + want: true, + }, + + // --- Branch 3: default fallthrough -> false --- + { + name: "completely unrelated values", + nodeName: "pve-node", + targetID: "agent-1", + want: false, + }, + { + name: "substring without dash boundary does not match", + nodeName: "pve-node", + targetID: "mypve-node", + want: false, + }, + { + name: "nodeName appears earlier but not as dash suffix", + nodeName: "pve-node", + targetID: "pve-node-2", + want: false, + }, + { + name: "nodeName is trailing but no dash prefix", + nodeName: "node", + targetID: "homelab-node-x", + want: false, + }, + { + name: "empty nodeName non-dash targetID no match", + nodeName: "", + targetID: "pve-node", + want: false, + }, + { + name: "empty targetID non-empty node no match", + nodeName: "pve-node", + targetID: "", + want: false, + }, + { + name: "whitespace not trimmed equality miss", + nodeName: "pve-node", + targetID: "pve-node ", + want: false, + }, + { + name: "whitespace not trimmed suffix miss", + nodeName: "pve-node", + targetID: "homelab-pve-node ", + want: false, + }, + { + name: "single char nodeName not dash suffix of target", + nodeName: "x", + targetID: "x-foo", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := nodeMatchesTargetID(tt.nodeName, tt.targetID) + assert.Equal(t, tt.want, got, + "nodeMatchesTargetID(%q, %q)", tt.nodeName, tt.targetID) + }) + } +} diff --git a/internal/ai/tools/tools_query_hints_test.go b/internal/ai/tools/tools_query_hints_test.go new file mode 100644 index 000000000..63873c381 --- /dev/null +++ b/internal/ai/tools/tools_query_hints_test.go @@ -0,0 +1,272 @@ +package tools + +import "testing" + +// This file provides white-box table tests for the pure string-routing hint +// helpers in tools_query.go: +// - GetReadOnlyViolationHint (top-level routing) +// - isPhase1GuardrailFailure (keyword classification) +// - getPhase1Hint (structural guardrail switch) +// - getSQLHint (SQL content-inspection switch) +// +// All four functions are pure (no I/O) and return exact strings/bools, so every +// case asserts the exact returned value rather than just "no panic". + +func TestGetReadOnlyViolationHint(t *testing.T) { + tests := []struct { + name string + command string + result IntentResult + want string + }{ + // Empty / boundary inputs. + { + name: "empty command and empty reason returns empty base hint", + command: "", + result: IntentResult{}, + want: "", + }, + { + name: "phase1 reason with empty command still routes to phase1 hint", + command: "", + result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "sudo escalates command privileges"}, + want: "sudo escalates command privileges. Read-only execution does not accept sudo; privileged operations require the governed control path.", + }, + { + name: "sql cli command with empty reason routes to sql default hint", + command: "sqlite3 db", + result: IntentResult{}, + want: ". For read-only queries, use self-contained SELECT statements without transaction control.", + }, + + // Phase 1 guardrail routing. + { + name: "phase1 redirect reason routes to phase1 redirect hint", + command: "echo hi > /tmp/x", + result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "output redirection can overwrite files"}, + want: "output redirection can overwrite files. Read-only execution does not accept redirects (>, >>, <, <<, <<<).", + }, + + // Phase 1 precedence over SQL CLI detection. + { + name: "phase1 takes precedence over sql cli command match", + command: "sudo sqlite3 db.db \"SELECT 1\"", + result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "sudo escalates command privileges"}, + want: "sudo escalates command privileges. Read-only execution does not accept sudo; privileged operations require the governed control path.", + }, + + // SQL CLI routing for each recognised CLI binary. + { + name: "sqlite3 cli with write keyword routes to sql write hint", + command: "sqlite3 db.db \"INSERT INTO t VALUES(1)\"", + result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "content inspection: SQL contains write/control keyword: insert"}, + want: "content inspection: SQL contains write/control keyword: insert. Use only SELECT statements. Avoid: INSERT, UPDATE, DELETE, DROP, CREATE, PRAGMA, BEGIN, COMMIT, ROLLBACK.", + }, + { + name: "mysql cli with no-inline reason routes to sql external hint", + command: "mysql db", + result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "content inspection: no inline SQL found; input may be external (piped/interactive)"}, + want: "content inspection: no inline SQL found; input may be external (piped/interactive). Include SQL directly in quotes: sqlite3 db.db \"SELECT ...\"", + }, + { + name: "mariadb cli with neutral reason routes to sql default hint", + command: "mariadb db -e \"SELECT 1\"", + result: IntentResult{Intent: IntentReadOnlyConditional, Reason: "content inspection: read-only"}, + want: "content inspection: read-only. For read-only queries, use self-contained SELECT statements without transaction control.", + }, + { + name: "psql cli with control keyword routes to sql write hint", + command: "psql -c \"BEGIN\"", + result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "content inspection: SQL contains write/control keyword: begin"}, + want: "content inspection: SQL contains write/control keyword: begin. Use only SELECT statements. Avoid: INSERT, UPDATE, DELETE, DROP, CREATE, PRAGMA, BEGIN, COMMIT, ROLLBACK.", + }, + + // SQL CLI precedence over the unknown-command fallback. + { + name: "sql cli command beats unknown reason fallback", + command: "sqlite3 db", + result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "unknown command"}, + want: "unknown command. For read-only queries, use self-contained SELECT statements without transaction control.", + }, + + // Unknown-command fallback (non-SQL, non-phase1). + { + name: "non-sql unknown reason routes to self-contained hint", + command: "foobar", + result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "unknown command is not on the read-only allowlist"}, + want: "unknown command is not on the read-only allowlist. Try a self-contained form: no pipes, no redirects, single statement. If this is a read-only operation, consider using a known read-only command instead.", + }, + { + name: "non-sql no-inspector reason routes to self-contained hint", + command: "foobar", + result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "no inspector matched this command"}, + want: "no inspector matched this command. Try a self-contained form: no pipes, no redirects, single statement. If this is a read-only operation, consider using a known read-only command instead.", + }, + + // Plain fallback (no routing matches): base hint returned unchanged. + { + name: "non-sql unmatched reason returns base hint unchanged", + command: "foobar", + result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "random reason"}, + want: "random reason", + }, + + // Documents actual (surprising) behaviour for interactive_repl blocks. + // The interactive_repl NonInteractiveBlock reason is not in the phase1 + // keyword list, so routing falls through to SQL/unknown/default paths. + { + name: "bare mysql interactive_repl block misroutes to sql default hint", + command: "mysql", + result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "[interactive_repl] command opens interactive session; pulse_read requires bounded non-interactive commands"}, + want: "[interactive_repl] command opens interactive session; pulse_read requires bounded non-interactive commands. For read-only queries, use self-contained SELECT statements without transaction control.", + }, + { + name: "python interactive_repl block returns base hint unchanged", + command: "python", + result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "[interactive_repl] command opens interactive session; pulse_read requires bounded non-interactive commands"}, + want: "[interactive_repl] command opens interactive session; pulse_read requires bounded non-interactive commands", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := GetReadOnlyViolationHint(tc.command, tc.result) + if got != tc.want { + t.Fatalf("GetReadOnlyViolationHint(command=%q, reason=%q) =\n %q\nwant\n %q", + tc.command, tc.result.Reason, got, tc.want) + } + }) + } +} + +func TestIsPhase1GuardrailFailure(t *testing.T) { + tests := []struct { + name string + reason string + want bool + }{ + // Phase 1 structural keywords (case-sensitive substring match). + {name: "sudo keyword", reason: "sudo escalates command privileges", want: true}, + {name: "redirect substring inside redirection", reason: "output redirection can overwrite files", want: true}, + {name: "redirect exact substring", reason: "has redirect here", want: true}, + {name: "tee keyword", reason: "tee can write to files", want: true}, + {name: "substitution keyword", reason: "command substitution can execute arbitrary commands", want: true}, + {name: "chaining keyword", reason: "shell chaining detected outside quotes", want: true}, + {name: "piped input keyword", reason: "piped input to dual-use tool prevents content inspection", want: true}, + + // NonInteractiveOnly keywords. + {name: "TTY uppercase keyword", reason: "[tty_flag] interactive/TTY flags require terminal", want: true}, + {name: "terminal keyword alone", reason: "requires terminal interaction", want: true}, + {name: "pager keyword", reason: "[pager] pager/editor tools require terminal", want: true}, + {name: "editor keyword alone", reason: "editor tools need a terminal", want: true}, + {name: "indefinitely keyword", reason: "live monitoring tools run indefinitely", want: true}, + {name: "unbounded keyword", reason: "[unbounded_stream] follow mode without bound", want: true}, + {name: "streaming keyword alone", reason: "unbounded streaming detected", want: true}, + + // Negative cases (case-sensitive, spacing-sensitive, unmatched). + {name: "empty reason returns false", reason: "", want: false}, + {name: "tty lowercase does not match (case sensitive)", reason: "tty flag", want: false}, + {name: "SUDO uppercase does not match (case sensitive)", reason: "SUDO escalation", want: false}, + {name: "pipedinput without space does not match", reason: "pipedinput detected", want: false}, + {name: "unrelated reason returns false", reason: "unknown command is not on the read-only allowlist", want: false}, + {name: "interactive_repl real reason not classified as phase1", reason: "[interactive_repl] command opens interactive session; pulse_read requires bounded non-interactive commands", want: false}, + {name: "content inspection read-only reason not classified as phase1", reason: "content inspection: read-only", want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := isPhase1GuardrailFailure(tc.reason) + if got != tc.want { + t.Fatalf("isPhase1GuardrailFailure(%q) = %v, want %v", tc.reason, got, tc.want) + } + }) + } +} + +func TestGetPhase1Hint(t *testing.T) { + const base = "REASON" // arbitrary non-empty base hint to verify concatenation + tests := []struct { + name string + reason string + baseHint string + want string + }{ + // One case per switch arm (in source order). + {name: "sudo arm", reason: "sudo escalates", baseHint: base, want: base + ". Read-only execution does not accept sudo; privileged operations require the governed control path."}, + {name: "redirect arm via redirection substring", reason: "output redirection detected", baseHint: base, want: base + ". Read-only execution does not accept redirects (>, >>, <, <<, <<<)."}, + {name: "tee arm", reason: "tee can write to files", baseHint: base, want: base + ". Read-only execution does not accept tee because tee writes to files."}, + {name: "substitution arm", reason: "command substitution detected", baseHint: base, want: base + ". Read-only execution does not accept $() or backticks."}, + {name: "chaining arm", reason: "shell chaining detected", baseHint: base, want: base + ". Run commands separately instead of chaining with ; && ||."}, + {name: "piped input arm", reason: "piped input to dual-use tool", baseHint: base, want: base + ". For dual-use tools, include content directly instead of piping. Example: sqlite3 db.db \"SELECT ...\" instead of cat file | sqlite3 db.db"}, + {name: "TTY arm", reason: "interactive/TTY flags", baseHint: base, want: base + ". Remove -it/--tty/--interactive flags. Use non-interactive form: docker exec container cmd (not docker exec -it)."}, + {name: "terminal arm without TTY", reason: "requires terminal interaction", baseHint: base, want: base + ". Remove -it/--tty/--interactive flags. Use non-interactive form: docker exec container cmd (not docker exec -it)."}, + {name: "pager arm", reason: "pager tools", baseHint: base, want: base + ". Use cat, head -n, or tail -n instead of interactive tools."}, + {name: "editor arm without pager", reason: "editor tools", baseHint: base, want: base + ". Use cat, head -n, or tail -n instead of interactive tools."}, + {name: "indefinitely arm", reason: "runs indefinitely", baseHint: base, want: base + ". Use bounded alternatives: ps aux (not top), journalctl -n 100 (not watch)."}, + {name: "unbounded arm", reason: "unbounded stream", baseHint: base, want: base + ". Add line limit: journalctl -n 100 -f or tail -n 50 -f, or wrap with timeout."}, + {name: "streaming arm without unbounded", reason: "streaming follow mode", baseHint: base, want: base + ". Add line limit: journalctl -n 100 -f or tail -n 50 -f, or wrap with timeout."}, + + // default / fallthrough arm. + {name: "default arm with unmatched reason", reason: "unknown phase1 failure", baseHint: base, want: base + ". Read-only execution does not accept redirects, chaining, sudo, or subshells."}, + {name: "default arm with empty reason", reason: "", baseHint: base, want: base + ". Read-only execution does not accept redirects, chaining, sudo, or subshells."}, + + // Switch-order precedence (first matching case wins). + {name: "precedence sudo before redirect", reason: "sudo and redirect", baseHint: base, want: base + ". Read-only execution does not accept sudo; privileged operations require the governed control path."}, + {name: "precedence redirect before tee", reason: "redirect and tee", baseHint: base, want: base + ". Read-only execution does not accept redirects (>, >>, <, <<, <<<)."}, + {name: "precedence piped input before TTY", reason: "piped input and TTY flags", baseHint: base, want: base + ". For dual-use tools, include content directly instead of piping. Example: sqlite3 db.db \"SELECT ...\" instead of cat file | sqlite3 db.db"}, + {name: "precedence indefinitely before unbounded", reason: "indefinitely and unbounded", baseHint: base, want: base + ". Use bounded alternatives: ps aux (not top), journalctl -n 100 (not watch)."}, + + // Empty base hint (still gets the suffix). + {name: "empty base hint sudo arm", reason: "sudo", baseHint: "", want: ". Read-only execution does not accept sudo; privileged operations require the governed control path."}, + {name: "empty base hint default arm", reason: "unmatched", baseHint: "", want: ". Read-only execution does not accept redirects, chaining, sudo, or subshells."}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := getPhase1Hint(tc.reason, tc.baseHint) + if got != tc.want { + t.Fatalf("getPhase1Hint(%q, %q) =\n %q\nwant\n %q", + tc.reason, tc.baseHint, got, tc.want) + } + }) + } +} + +func TestGetSQLHint(t *testing.T) { + const base = "REASON" // arbitrary non-empty base hint to verify concatenation + tests := []struct { + name string + reason string + baseHint string + want string + }{ + // external / no-inline arm (first case). + {name: "external arm", reason: "input may be external", baseHint: base, want: base + ". Include SQL directly in quotes: sqlite3 db.db \"SELECT ...\""}, + {name: "no inline arm", reason: "no inline SQL found", baseHint: base, want: base + ". Include SQL directly in quotes: sqlite3 db.db \"SELECT ...\""}, + + // write / control arm (second case). + {name: "write arm", reason: "contains write keyword", baseHint: base, want: base + ". Use only SELECT statements. Avoid: INSERT, UPDATE, DELETE, DROP, CREATE, PRAGMA, BEGIN, COMMIT, ROLLBACK."}, + {name: "control arm", reason: "transaction control keyword", baseHint: base, want: base + ". Use only SELECT statements. Avoid: INSERT, UPDATE, DELETE, DROP, CREATE, PRAGMA, BEGIN, COMMIT, ROLLBACK."}, + + // default / fallthrough arm. + {name: "default arm with unmatched reason", reason: "read-only content", baseHint: base, want: base + ". For read-only queries, use self-contained SELECT statements without transaction control."}, + {name: "default arm with empty reason", reason: "", baseHint: base, want: base + ". For read-only queries, use self-contained SELECT statements without transaction control."}, + + // Switch-order precedence (external/no-inline before write/control). + {name: "precedence external before write", reason: "external write detected", baseHint: base, want: base + ". Include SQL directly in quotes: sqlite3 db.db \"SELECT ...\""}, + {name: "precedence no inline before control", reason: "no inline control found", baseHint: base, want: base + ". Include SQL directly in quotes: sqlite3 db.db \"SELECT ...\""}, + + // Empty base hint (still gets the suffix). + {name: "empty base hint external arm", reason: "external", baseHint: "", want: ". Include SQL directly in quotes: sqlite3 db.db \"SELECT ...\""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := getSQLHint(tc.reason, tc.baseHint) + if got != tc.want { + t.Fatalf("getSQLHint(%q, %q) =\n %q\nwant\n %q", + tc.reason, tc.baseHint, got, tc.want) + } + }) + } +} diff --git a/internal/ai/tools/tools_query_vmconfig_test.go b/internal/ai/tools/tools_query_vmconfig_test.go new file mode 100644 index 000000000..6310cff82 --- /dev/null +++ b/internal/ai/tools/tools_query_vmconfig_test.go @@ -0,0 +1,290 @@ +package tools + +import ( + "testing" +) + +func toolsvmconfigBoolPtr(b bool) *bool { return &b } + +func toolsvmconfigBoolEqual(got, want *bool) bool { + if got == nil || want == nil { + return got == nil && want == nil + } + return *got == *want +} + +func toolsvmconfigDisksEqual(got, want []GuestDiskConfig) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} + +func TestParseVMConfig(t *testing.T) { + tests := []struct { + name string + config map[string]interface{} + wantOS string + wantBoot *bool + wantDisks []GuestDiskConfig + }{ + { + name: "nil map returns zero values", + config: nil, + wantOS: "", + wantBoot: nil, + wantDisks: nil, + }, + { + name: "empty map returns zero values", + config: map[string]interface{}{}, + wantOS: "", + wantBoot: nil, + wantDisks: nil, + }, + { + name: "ostype only", + config: map[string]interface{}{"ostype": "l26"}, + wantOS: "l26", + wantBoot: nil, + }, + { + name: "onboot true via one string", + config: map[string]interface{}{"onboot": "1"}, + wantOS: "", + wantBoot: toolsvmconfigBoolPtr(true), + }, + { + name: "onboot false via zero string", + config: map[string]interface{}{"onboot": "0"}, + wantOS: "", + wantBoot: toolsvmconfigBoolPtr(false), + }, + { + name: "single scsi disk", + config: map[string]interface{}{"scsi0": "local-lvm:vm-100-disk-0,size=32G"}, + wantDisks: []GuestDiskConfig{ + {Key: "scsi0", Value: "local-lvm:vm-100-disk-0,size=32G"}, + }, + }, + { + name: "multiple disks sorted by key ascending", + config: map[string]interface{}{ + "virtio0": "storage:vm-100-virtio-0", + "scsi1": "storage:vm-100-scsi-1", + "ide0": "storage:vm-100-ide-0", + "sata0": "storage:vm-100-sata-0", + }, + wantDisks: []GuestDiskConfig{ + {Key: "ide0", Value: "storage:vm-100-ide-0"}, + {Key: "sata0", Value: "storage:vm-100-sata-0"}, + {Key: "scsi1", Value: "storage:vm-100-scsi-1"}, + {Key: "virtio0", Value: "storage:vm-100-virtio-0"}, + }, + }, + { + name: "all disk prefixes collected and sorted", + config: map[string]interface{}{ + "scsi0": "a", + "virtio0": "b", + "sata0": "c", + "ide0": "d", + "unused0": "e", + "efidisk0": "f", + "tpmstate0": "g", + }, + wantDisks: []GuestDiskConfig{ + {Key: "efidisk0", Value: "f"}, + {Key: "ide0", Value: "d"}, + {Key: "sata0", Value: "c"}, + {Key: "scsi0", Value: "a"}, + {Key: "tpmstate0", Value: "g"}, + {Key: "unused0", Value: "e"}, + {Key: "virtio0", Value: "b"}, + }, + }, + { + name: "mixed ostype onboot and disk", + config: map[string]interface{}{"ostype": "win11", "onboot": "yes", "scsi0": "local:disk-0"}, + wantOS: "win11", + wantBoot: toolsvmconfigBoolPtr(true), + wantDisks: []GuestDiskConfig{ + {Key: "scsi0", Value: "local:disk-0"}, + }, + }, + { + name: "disk with empty value skipped", + config: map[string]interface{}{"scsi0": "", "scsi1": "real"}, + wantDisks: []GuestDiskConfig{ + {Key: "scsi1", Value: "real"}, + }, + }, + { + name: "disk key casing normalized to lower", + config: map[string]interface{}{"SCSI0": "local:disk-0"}, + wantDisks: []GuestDiskConfig{ + {Key: "scsi0", Value: "local:disk-0"}, + }, + }, + { + name: "ostype key matched case insensitively", + config: map[string]interface{}{"OSTYPE": "l26"}, + wantOS: "l26", + wantBoot: nil, + }, + { + name: "key surrounding whitespace trimmed", + config: map[string]interface{}{" ostype ": "win10", " scsi0 ": "local:disk"}, + wantOS: "win10", + wantDisks: []GuestDiskConfig{ + {Key: "scsi0", Value: "local:disk"}, + }, + }, + { + name: "disk value whitespace trimmed", + config: map[string]interface{}{"scsi0": " local:disk-0 "}, + wantDisks: []GuestDiskConfig{ + {Key: "scsi0", Value: "local:disk-0"}, + }, + }, + { + name: "non-disk non-special keys ignored", + config: map[string]interface{}{"net0": "virtio,bridge=vmbr0", "memory": 4096, "cores": 4}, + wantOS: "", + wantBoot: nil, + wantDisks: nil, + }, + { + name: "onboot uncoercible value yields nil", + config: map[string]interface{}{"onboot": "maybe"}, + wantOS: "", + wantBoot: nil, + }, + { + name: "disk nil interface value rendered as literal string", + config: map[string]interface{}{"scsi0": nil}, + wantDisks: []GuestDiskConfig{ + {Key: "scsi0", Value: ""}, + }, + }, + { + name: "onboot integer coerced via sprint", + config: map[string]interface{}{"onboot": 1}, + wantOS: "", + wantBoot: toolsvmconfigBoolPtr(true), + }, + { + name: "single disk not sorted when only one", + config: map[string]interface{}{"virtio0": "v"}, + wantDisks: []GuestDiskConfig{ + {Key: "virtio0", Value: "v"}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotOS, gotBoot, gotDisks := parseVMConfig(tc.config) + if gotOS != tc.wantOS { + t.Errorf("osType = %q, want %q", gotOS, tc.wantOS) + } + if !toolsvmconfigBoolEqual(gotBoot, tc.wantBoot) { + t.Errorf("onboot mismatch: got=%v, want=%v", gotBoot, tc.wantBoot) + } + if !toolsvmconfigDisksEqual(gotDisks, tc.wantDisks) { + t.Errorf("disks = %+v, want %+v", gotDisks, tc.wantDisks) + } + }) + } +} + +func TestIsVMConfigDiskKey(t *testing.T) { + tests := []struct { + name string + key string + want bool + }{ + {"scsi prefix", "scsi0", true}, + {"scsi exact", "scsi", true}, + {"virtio prefix", "virtio0", true}, + {"virtio exact", "virtio", true}, + {"sata prefix", "sata2", true}, + {"sata exact", "sata", true}, + {"ide prefix", "ide0", true}, + {"ide exact", "ide", true}, + {"unused prefix", "unused0", true}, + {"unused exact", "unused", true}, + {"efidisk prefix", "efidisk0", true}, + {"efidisk exact", "efidisk", true}, + {"tpmstate prefix", "tpmstate0", true}, + {"tpmstate exact", "tpmstate", true}, + {"net is not a disk key", "net0", false}, + {"memory is not a disk key", "memory", false}, + {"cores is not a disk key", "cores", false}, + {"ostype is not a disk key", "ostype", false}, + {"onboot is not a disk key", "onboot", false}, + {"rootfs is not a disk key", "rootfs", false}, + {"disk0 is not a recognized disk key", "disk0", false}, + {"empty string is not a disk key", "", false}, + {"uppercase scsi not matched case sensitive", "SCSI0", false}, + {"uppercase ide not matched case sensitive", "IDE0", false}, + {"prefix match has no word boundary ide in ideology", "ideology", true}, + {"prefix match has no word boundary scsi in scsiabc", "scsiabc", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := isVMConfigDiskKey(tc.key); got != tc.want { + t.Errorf("isVMConfigDiskKey(%q) = %v, want %v", tc.key, got, tc.want) + } + }) + } +} + +func TestParseOnbootValue(t *testing.T) { + tests := []struct { + name string + value interface{} + want *bool + }{ + {"empty string", "", nil}, + {"string one", "1", toolsvmconfigBoolPtr(true)}, + {"string yes lowercase", "yes", toolsvmconfigBoolPtr(true)}, + {"string yes uppercase", "YES", toolsvmconfigBoolPtr(true)}, + {"string yes mixed case", "Yes", toolsvmconfigBoolPtr(true)}, + {"string true lowercase", "true", toolsvmconfigBoolPtr(true)}, + {"string true uppercase", "TRUE", toolsvmconfigBoolPtr(true)}, + {"string true mixed case", "True", toolsvmconfigBoolPtr(true)}, + {"string zero", "0", toolsvmconfigBoolPtr(false)}, + {"string no lowercase", "no", toolsvmconfigBoolPtr(false)}, + {"string no uppercase", "NO", toolsvmconfigBoolPtr(false)}, + {"string false lowercase", "false", toolsvmconfigBoolPtr(false)}, + {"string false uppercase", "FALSE", toolsvmconfigBoolPtr(false)}, + {"whitespace padded one", " 1 ", toolsvmconfigBoolPtr(true)}, + {"whitespace padded yes", " yes ", toolsvmconfigBoolPtr(true)}, + {"whitespace padded false", " false ", toolsvmconfigBoolPtr(false)}, + {"string two unrecognized", "2", nil}, + {"string maybe unrecognized", "maybe", nil}, + {"string on not recognized actual behavior", "on", nil}, + {"integer one coerced to true", 1, toolsvmconfigBoolPtr(true)}, + {"integer zero coerced to false", 0, toolsvmconfigBoolPtr(false)}, + {"integer two unrecognized", 2, nil}, + {"boolean true coerced", true, toolsvmconfigBoolPtr(true)}, + {"boolean false coerced", false, toolsvmconfigBoolPtr(false)}, + {"nil interface returns nil", nil, nil}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := parseOnbootValue(tc.value) + if !toolsvmconfigBoolEqual(got, tc.want) { + t.Errorf("parseOnbootValue(%v) = %v, want %v", tc.value, got, tc.want) + } + }) + } +}