mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Add branch-coverage tests for AI patrol, qualification, tools and agentcapability helpers
New white-box table tests over previously-untested pure functions in internal/ai (patrol run recency, patrol findings JSON validation), internal/ai/tools (read-only violation hints, VM config parsing, node target matching), internal/ai/qualification (compare predicate, percentile and model summary, runner helpers), internal/ai/chat (investigation run error, session compaction formatting) and internal/agentcapabilities (path parameter substitution, markdown helpers). Twelve files, tests only, no source changes. All gates green with go test, gofmt and go vet, plus an adversarial GLM review scoring twelve KEEP and zero reject.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: "<root>not json</root>",
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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: "<nil>"},
|
||||
},
|
||||
},
|
||||
{
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user