Pulse/internal/ai/patrol_ai_eval_additional_test.go
rcourtman 526fb21076 Add tests for guest intelligence and reachability signals
Cover gatherGuestIntelligence (discovery matching, instance fallback,
reachability via mock prober, edge cases), parsePingOutput parsing,
DetectReachabilitySignals, enriched seed context (Service/Reachable
columns, quiet mode variants, health issues fallback), and extend
signal helper tests for SignalGuestUnreachable.
2026-02-04 14:12:50 +00:00

149 lines
5.7 KiB
Go

package ai
import (
"context"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
func TestIsInvestigationTool(t *testing.T) {
if !isInvestigationTool("pulse_query") || !isInvestigationTool("pulse_metrics") || !isInvestigationTool("pulse_storage") || !isInvestigationTool("pulse_read") {
t.Fatal("expected investigation tools to be recognized")
}
if isInvestigationTool("pulse_control") {
t.Fatal("expected non-investigation tool to be false")
}
}
func TestFormatToolResult(t *testing.T) {
result := tools.CallToolResult{
Content: []tools.Content{
{Type: "text", Text: "first"},
{Type: "resource", URI: "file://ignored"},
{Type: "text", Text: "second"},
},
}
if got := formatToolResult(result); got != "first\nsecond" {
t.Fatalf("formatToolResult returned %q", got)
}
if got := formatToolResult(tools.CallToolResult{}); got != "" {
t.Fatalf("expected empty result for no content, got %q", got)
}
}
func TestEvalPromptBuilders(t *testing.T) {
systemPrompt := buildEvalSystemPrompt()
if !strings.Contains(systemPrompt, "patrol_report_finding") || !strings.Contains(systemPrompt, "patrol_get_findings") {
t.Fatalf("expected eval system prompt to include tool instructions")
}
signals := []DetectedSignal{
{
SignalType: SignalHighCPU,
ResourceID: "node-1",
ResourceName: "node-1",
ResourceType: "node",
SuggestedSeverity: "warning",
Category: "performance",
Summary: "CPU high",
Evidence: "cpu=99%",
},
}
userPrompt := buildEvalUserPrompt(signals)
if !strings.Contains(userPrompt, "Signal 1") || !strings.Contains(userPrompt, "CPU high") || !strings.Contains(userPrompt, "cpu=99%") {
t.Fatalf("unexpected eval user prompt: %s", userPrompt)
}
}
func TestSignalHelpersAndFindingsFromSignals(t *testing.T) {
cases := []struct {
signal DetectedSignal
wantKey string
wantTitle string
recSubstring string
}{
{signal: DetectedSignal{SignalType: SignalSMARTFailure}, wantKey: "smart-failure", wantTitle: "SMART health check failed", recSubstring: "disk"},
{signal: DetectedSignal{SignalType: SignalHighCPU}, wantKey: "cpu-high", wantTitle: "High CPU usage detected", recSubstring: "CPU"},
{signal: DetectedSignal{SignalType: SignalHighMemory}, wantKey: "memory-high", wantTitle: "High memory usage detected", recSubstring: "memory"},
{signal: DetectedSignal{SignalType: SignalHighDisk}, wantKey: "disk-high", wantTitle: "Storage usage is high", recSubstring: "storage"},
{signal: DetectedSignal{SignalType: SignalBackupFailed}, wantKey: "backup-failed", wantTitle: "Backup failed", recSubstring: "backup"},
{signal: DetectedSignal{SignalType: SignalBackupStale}, wantKey: "backup-stale", wantTitle: "Backup is stale", recSubstring: "backup"},
{signal: DetectedSignal{SignalType: SignalActiveAlert}, wantKey: "active-alert", wantTitle: "Active alert detected", recSubstring: "alert"},
{signal: DetectedSignal{SignalType: SignalGuestUnreachable, ResourceName: "db-server"}, wantKey: "guest-unreachable", wantTitle: "Guest unreachable: db-server", recSubstring: "ping"},
{signal: DetectedSignal{SignalType: SignalType("unknown")}, wantKey: "deterministic-signal", wantTitle: "Infrastructure signal detected", recSubstring: "Investigate"},
}
for _, c := range cases {
if got := signalKey(c.signal); got != c.wantKey {
t.Fatalf("signalKey(%s) = %s, want %s", c.signal.SignalType, got, c.wantKey)
}
if got := signalTitle(c.signal); got != c.wantTitle {
t.Fatalf("signalTitle(%s) = %s, want %s", c.signal.SignalType, got, c.wantTitle)
}
if !strings.Contains(defaultRecommendationForSignal(c.signal), c.recSubstring) {
t.Fatalf("unexpected recommendation for %s: %s", c.signal.SignalType, defaultRecommendationForSignal(c.signal))
}
}
ps := NewPatrolService(nil, nil)
adapter := newPatrolFindingCreatorAdapter(ps, models.StateSnapshot{})
signals := []DetectedSignal{
{
SignalType: SignalHighCPU,
ResourceID: "node-1",
ResourceName: "",
ResourceType: "",
SuggestedSeverity: "",
Category: "",
Summary: "CPU high",
Evidence: "cpu=99%",
},
{
SignalType: SignalBackupFailed,
ResourceID: "vm-101",
ResourceName: "vm-101",
ResourceType: "vm",
SuggestedSeverity: "warning",
Category: "backup",
Summary: "Backup failed",
Evidence: "job failed",
},
}
created := ps.createFindingsFromSignals(adapter, signals)
if created != len(signals) {
t.Fatalf("expected %d findings created, got %d", len(signals), created)
}
}
func TestRunEvaluationPass(t *testing.T) {
ps := NewPatrolService(&Service{}, nil)
_, err := ps.runEvaluationPass(context.Background(), nil, []DetectedSignal{{SignalType: SignalHighCPU}})
if err == nil {
t.Fatal("expected error when chat service is unavailable")
}
persistence := config.NewConfigPersistence(t.TempDir())
svc := NewService(persistence, nil)
mockCS := &patrolMockChatService{
executePatrolStreamFunc: func(ctx context.Context, req PatrolExecuteRequest, callback ChatStreamCallback) (*PatrolStreamResponse, error) {
return &PatrolStreamResponse{Content: "ok", InputTokens: 10, OutputTokens: 20}, nil
},
}
svc.SetChatService(mockCS)
ps.aiService = svc
resp, err := ps.runEvaluationPass(context.Background(), nil, []DetectedSignal{{SignalType: SignalHighCPU}})
if err != nil {
t.Fatalf("expected evaluation pass to succeed, got %v", err)
}
if resp == nil || resp.InputTokens != 10 || resp.OutputTokens != 20 {
t.Fatalf("unexpected evaluation response: %+v", resp)
}
}