mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-06 16:16:26 +00:00
954 lines
29 KiB
Go
954 lines
29 KiB
Go
package ai
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/baseline"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/correlation"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/knowledge"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/patterns"
|
|
ur "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
)
|
|
|
|
func TestNewIntelligence(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{DataDir: "/tmp/test"})
|
|
if intel == nil {
|
|
t.Fatal("Expected non-nil Intelligence")
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_GetSummary_NoSubsystems(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
summary := intel.GetSummary()
|
|
if summary == nil {
|
|
t.Fatal("Expected non-nil summary")
|
|
}
|
|
|
|
// Should have default healthy state
|
|
if summary.OverallHealth.Score != 100 {
|
|
t.Errorf("Expected health score 100, got %f", summary.OverallHealth.Score)
|
|
}
|
|
if summary.OverallHealth.Grade != HealthGradeA {
|
|
t.Errorf("Expected grade A, got %s", summary.OverallHealth.Grade)
|
|
}
|
|
if summary.Timestamp.IsZero() {
|
|
t.Error("Expected non-zero timestamp")
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_GetResourceIntelligence_NoSubsystems(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
resourceIntel := intel.GetResourceIntelligence("test-resource")
|
|
if resourceIntel == nil {
|
|
t.Fatal("Expected non-nil resource intelligence")
|
|
}
|
|
|
|
if resourceIntel.ResourceID != "test-resource" {
|
|
t.Errorf("Expected resource ID 'test-resource', got %s", resourceIntel.ResourceID)
|
|
}
|
|
|
|
// Should have default healthy state
|
|
if resourceIntel.Health.Score != 100 {
|
|
t.Errorf("Expected health score 100, got %f", resourceIntel.Health.Score)
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_FormatContext_NoSubsystems(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
// With no subsystems, context should be empty
|
|
ctx := intel.FormatContext("test-resource")
|
|
if ctx != "" {
|
|
t.Errorf("Expected empty context with no subsystems, got: %s", ctx)
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_CreatePredictionFinding(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
pred := patterns.FailurePrediction{
|
|
ResourceID: "vm-100",
|
|
EventType: patterns.EventHighCPU, // Use the constant instead of cast
|
|
DaysUntil: 0.5, // Less than 1 day
|
|
Confidence: 0.85, // High confidence
|
|
Basis: "Pattern detected",
|
|
}
|
|
|
|
finding := intel.CreatePredictionFinding(pred)
|
|
if finding == nil {
|
|
t.Fatal("Expected non-nil finding")
|
|
}
|
|
|
|
// High confidence + < 1 day should be critical
|
|
if finding.Severity != FindingSeverityCritical {
|
|
t.Errorf("Expected critical severity for imminent high-confidence prediction, got %s", finding.Severity)
|
|
}
|
|
|
|
if finding.ResourceID != "vm-100" {
|
|
t.Errorf("Expected resource ID 'vm-100', got %s", finding.ResourceID)
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_SetSubsystems(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
// Create a findings store
|
|
findings := NewFindingsStore()
|
|
|
|
// Add a finding
|
|
findings.Add(&Finding{
|
|
ID: "test-finding",
|
|
Key: "test:finding",
|
|
Severity: FindingSeverityWarning,
|
|
Category: FindingCategoryPerformance,
|
|
ResourceID: "vm-100",
|
|
ResourceName: "test-vm",
|
|
ResourceType: "vm",
|
|
Title: "Test Finding",
|
|
DetectedAt: time.Now(),
|
|
LastSeenAt: time.Now(),
|
|
Source: "test",
|
|
})
|
|
|
|
// Set subsystems with just findings
|
|
intel.SetSubsystems(findings, nil, nil, nil, nil, nil, nil, nil)
|
|
|
|
// Get summary
|
|
summary := intel.GetSummary()
|
|
|
|
// Should have 1 warning
|
|
if summary.FindingsCount.Warning != 1 {
|
|
t.Errorf("Expected 1 warning, got %d", summary.FindingsCount.Warning)
|
|
}
|
|
if summary.FindingsCount.Total != 1 {
|
|
t.Errorf("Expected 1 total finding, got %d", summary.FindingsCount.Total)
|
|
}
|
|
|
|
// Health should be reduced due to warning
|
|
if summary.OverallHealth.Score >= 100 {
|
|
t.Error("Expected health score < 100 due to warning finding")
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_HealthGrades(t *testing.T) {
|
|
tests := []struct {
|
|
score float64
|
|
grade HealthGrade
|
|
}{
|
|
{100, HealthGradeA},
|
|
{95, HealthGradeA},
|
|
{90, HealthGradeA},
|
|
{85, HealthGradeB},
|
|
{75, HealthGradeB},
|
|
{70, HealthGradeC},
|
|
{60, HealthGradeC},
|
|
{55, HealthGradeD},
|
|
{40, HealthGradeD},
|
|
{30, HealthGradeF},
|
|
{0, HealthGradeF},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
grade := scoreToGrade(tt.score)
|
|
if grade != tt.grade {
|
|
t.Errorf("scoreToGrade(%f) = %s, expected %s", tt.score, grade, tt.grade)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_CheckBaselinesForResource(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
// With no baseline store, should return nil
|
|
anomalies := intel.CheckBaselinesForResource("vm-100", map[string]float64{
|
|
"cpu": 85.0,
|
|
"memory": 90.0,
|
|
})
|
|
|
|
if anomalies != nil {
|
|
t.Error("Expected nil anomalies when baseline store not configured")
|
|
}
|
|
}
|
|
|
|
// Note: mockStateProvider is already defined in alert_triggered_test.go
|
|
|
|
func TestIntelligence_FormatGlobalContext(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
// With no subsystems, should return empty
|
|
ctx := intel.FormatGlobalContext()
|
|
if ctx != "" {
|
|
t.Errorf("Expected empty context with no subsystems, got: %s", ctx)
|
|
}
|
|
|
|
// Set up knowledge store
|
|
knowledgeStore, err := knowledge.NewStore(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("Failed to create knowledge store: %v", err)
|
|
}
|
|
intel.SetSubsystems(nil, nil, nil, nil, nil, knowledgeStore, nil, nil)
|
|
|
|
// Should still be empty (no knowledge saved)
|
|
ctx = intel.FormatGlobalContext()
|
|
// Empty or with headers only is fine
|
|
}
|
|
|
|
func TestIntelligence_RecordLearning(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
// Without knowledge store, should return nil
|
|
err := intel.RecordLearning("vm-100", "test-vm", "vm", "Test Title", "Test content")
|
|
if err != nil {
|
|
t.Errorf("Expected nil error without knowledge store, got: %v", err)
|
|
}
|
|
|
|
// With knowledge store
|
|
knowledgeStore, err := knowledge.NewStore(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("Failed to create knowledge store: %v", err)
|
|
}
|
|
intel.SetSubsystems(nil, nil, nil, nil, nil, knowledgeStore, nil, nil)
|
|
|
|
err = intel.RecordLearning("vm-100", "test-vm", "vm", "Test Title", "Test content")
|
|
if err != nil {
|
|
t.Errorf("Expected nil error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSeverityOrder(t *testing.T) {
|
|
tests := []struct {
|
|
severity FindingSeverity
|
|
expected int
|
|
}{
|
|
{FindingSeverityCritical, 0},
|
|
{FindingSeverityWarning, 1},
|
|
{FindingSeverityWatch, 2},
|
|
{FindingSeverityInfo, 3},
|
|
{FindingSeverity("unknown"), 4},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
result := severityOrder(tt.severity)
|
|
if result != tt.expected {
|
|
t.Errorf("severityOrder(%s) = %d, expected %d", tt.severity, result, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_CheckBaselinesForResource_WithBaselines(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
// Create baseline store with learned data
|
|
baselineStore := baseline.NewStore(baseline.StoreConfig{MinSamples: 10})
|
|
|
|
// Learn baseline for CPU at ~20%
|
|
points := make([]baseline.MetricPoint, 100)
|
|
for i := 0; i < 100; i++ {
|
|
points[i] = baseline.MetricPoint{Value: 20 + float64(i%3) - 1} // 19-21
|
|
}
|
|
_ = baselineStore.Learn("vm-100", "vm", "cpu", points)
|
|
|
|
intel.SetSubsystems(nil, nil, nil, baselineStore, nil, nil, nil, nil)
|
|
|
|
// Check with anomalous value (80% is 4x baseline)
|
|
anomalies := intel.CheckBaselinesForResource("vm-100", map[string]float64{
|
|
"cpu": 80.0,
|
|
})
|
|
|
|
// Should detect anomaly for CPU (80% with baseline of 20% is 4x = anomalous)
|
|
if len(anomalies) == 0 {
|
|
t.Error("Expected anomaly for CPU at 80% with baseline of 20%")
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_GetResourceIntelligence_WithSubsystems(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
// Create findings store with a finding for the resource
|
|
findings := NewFindingsStore()
|
|
findings.Add(&Finding{
|
|
ID: "f1",
|
|
Key: "test:f1",
|
|
Severity: FindingSeverityCritical,
|
|
Category: FindingCategoryPerformance,
|
|
ResourceID: "vm-200",
|
|
ResourceName: "critical-vm",
|
|
ResourceType: "vm",
|
|
Title: "Critical CPU",
|
|
DetectedAt: time.Now(),
|
|
LastSeenAt: time.Now(),
|
|
Source: "test",
|
|
})
|
|
|
|
// Create correlation detector
|
|
correlationDetector := correlation.NewDetector(correlation.DefaultConfig())
|
|
|
|
intel.SetSubsystems(findings, nil, correlationDetector, nil, nil, nil, nil, nil)
|
|
canonicalStore := ur.NewMemoryStore()
|
|
if err := canonicalStore.RecordChange(ur.ResourceChange{
|
|
ID: "change-1",
|
|
ObservedAt: time.Now().Add(-time.Hour),
|
|
ResourceID: "vm-200",
|
|
Kind: ur.ChangeRestart,
|
|
SourceType: ur.SourcePlatformEvent,
|
|
Reason: "restarted after maintenance",
|
|
}); err != nil {
|
|
t.Fatalf("record canonical change: %v", err)
|
|
}
|
|
intel.SetResourceTimelineStore(canonicalStore, "org-1")
|
|
|
|
resourceIntel := intel.GetResourceIntelligence("vm-200")
|
|
|
|
if len(resourceIntel.ActiveFindings) != 1 {
|
|
t.Errorf("Expected 1 active finding, got %d", len(resourceIntel.ActiveFindings))
|
|
}
|
|
|
|
if resourceIntel.ResourceName != "critical-vm" {
|
|
t.Errorf("Expected resource name 'critical-vm', got %s", resourceIntel.ResourceName)
|
|
}
|
|
|
|
// Health should be reduced due to critical finding
|
|
if resourceIntel.Health.Score >= 100 {
|
|
t.Error("Expected reduced health score due to critical finding")
|
|
}
|
|
|
|
if len(resourceIntel.RecentChanges) == 0 {
|
|
t.Error("expected canonical recent changes")
|
|
}
|
|
|
|
// Grade should be less than A
|
|
if resourceIntel.Health.Grade == HealthGradeA {
|
|
t.Error("Expected health grade less than A due to critical finding")
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_GetResourceIntelligence_FallsBackToChangeDetector(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
|
|
changes.DetectChanges([]memory.ResourceSnapshot{
|
|
{ID: "vm-rc", Name: "resource-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()},
|
|
})
|
|
|
|
intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil)
|
|
|
|
resourceIntel := intel.GetResourceIntelligence("vm-rc")
|
|
if len(resourceIntel.RecentChanges) == 0 {
|
|
t.Fatal("expected fallback recent changes")
|
|
}
|
|
if resourceIntel.RecentChanges[0].SourceType != ur.SourceHeuristic {
|
|
t.Fatalf("expected heuristic fallback change source, got %s", resourceIntel.RecentChanges[0].SourceType)
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_GetRecentChanges_UsesCanonicalTimeline(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
canonicalStore := ur.NewMemoryStore()
|
|
if err := canonicalStore.RecordChange(ur.ResourceChange{
|
|
ID: "change-1",
|
|
ObservedAt: time.Now().Add(-time.Minute),
|
|
ResourceID: "vm-1",
|
|
Kind: ur.ChangeRestart,
|
|
SourceType: ur.SourcePlatformEvent,
|
|
Reason: "guest restarted",
|
|
}); err != nil {
|
|
t.Fatalf("record canonical change: %v", err)
|
|
}
|
|
intel.SetResourceTimelineStore(canonicalStore, "org-1")
|
|
|
|
recent := intel.GetRecentChanges(time.Now().Add(-time.Hour), 100)
|
|
if len(recent) != 1 {
|
|
t.Fatalf("expected 1 canonical recent change, got %d", len(recent))
|
|
}
|
|
if recent[0].Kind != ur.ChangeRestart {
|
|
t.Fatalf("expected canonical change kind %q, got %q", ur.ChangeRestart, recent[0].Kind)
|
|
}
|
|
if recent[0].Reason != "guest restarted" {
|
|
t.Fatalf("expected canonical reason, got %#v", recent[0].Reason)
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_GetRecentChanges_FallsBackToMemoryDetector(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
|
|
changes.DetectChanges([]memory.ResourceSnapshot{
|
|
{ID: "vm-fallback", Name: "resource-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()},
|
|
})
|
|
intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil)
|
|
|
|
recent := intel.GetRecentChanges(time.Now().Add(-time.Hour), 100)
|
|
if len(recent) != 1 {
|
|
t.Fatalf("expected 1 fallback recent change, got %d", len(recent))
|
|
}
|
|
if recent[0].SourceType != ur.SourceHeuristic {
|
|
t.Fatalf("expected heuristic fallback source, got %s", recent[0].SourceType)
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_GetCorrelations_UsesCanonicalFacade(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
detector := correlation.NewDetector(correlation.Config{
|
|
MaxEvents: 10,
|
|
CorrelationWindow: 2 * time.Hour,
|
|
MinOccurrences: 1,
|
|
RetentionWindow: 24 * time.Hour,
|
|
})
|
|
base := time.Now().Add(-30 * time.Minute)
|
|
detector.RecordEvent(correlation.Event{
|
|
ResourceID: "node-1",
|
|
ResourceName: "node-1",
|
|
ResourceType: "node",
|
|
EventType: correlation.EventHighCPU,
|
|
Timestamp: base,
|
|
})
|
|
detector.RecordEvent(correlation.Event{
|
|
ResourceID: "vm-1",
|
|
ResourceName: "vm-1",
|
|
ResourceType: "vm",
|
|
EventType: correlation.EventRestart,
|
|
Timestamp: base.Add(1 * time.Minute),
|
|
})
|
|
intel.SetSubsystems(nil, nil, detector, nil, nil, nil, nil, nil)
|
|
|
|
if !intel.HasCorrelationsSource() {
|
|
t.Fatal("expected correlation source to be available")
|
|
}
|
|
|
|
correlations := intel.GetCorrelations("vm-1")
|
|
if len(correlations) != 1 {
|
|
t.Fatalf("expected 1 canonical correlation, got %d", len(correlations))
|
|
}
|
|
if correlations[0].TargetID != "vm-1" {
|
|
t.Fatalf("target id = %q, want vm-1", correlations[0].TargetID)
|
|
}
|
|
|
|
ctx := intel.FormatCorrelationsContext("vm-1")
|
|
for _, want := range []string{
|
|
"## Resource Correlations",
|
|
"node-1",
|
|
"vm-1",
|
|
} {
|
|
if !strings.Contains(ctx, want) {
|
|
t.Fatalf("expected canonical correlation context %q to contain %q", ctx, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_DescribeResource_UsesUnifiedProvider(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
intel.SetUnifiedResourceProvider(&mockUnifiedResourceProvider{
|
|
getAllFunc: func() []ur.Resource {
|
|
return []ur.Resource{
|
|
{
|
|
ID: "vm-1",
|
|
Name: "canonical-vm",
|
|
Type: ur.ResourceTypeVM,
|
|
},
|
|
}
|
|
},
|
|
})
|
|
|
|
name, resourceType := intel.DescribeResource("vm-1")
|
|
if name != "canonical-vm" {
|
|
t.Fatalf("expected canonical display name, got %q", name)
|
|
}
|
|
if resourceType != string(ur.ResourceTypeVM) {
|
|
t.Fatalf("expected resource type vm, got %q", resourceType)
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_FormatContext_WithKnowledge(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
// Create knowledge store with data
|
|
knowledgeStore, err := knowledge.NewStore(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("Failed to create knowledge store: %v", err)
|
|
}
|
|
_ = knowledgeStore.SaveNote("vm-300", "test-vm", "vm", "general", "Test Note", "This is test content")
|
|
|
|
intel.SetSubsystems(nil, nil, nil, nil, nil, knowledgeStore, nil, nil)
|
|
|
|
ctx := intel.FormatContext("vm-300")
|
|
|
|
// Should contain the knowledge context
|
|
if ctx == "" {
|
|
t.Error("Expected non-empty context with knowledge")
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_FormatContext_IncludesCanonicalRecentChanges(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
|
|
changes.DetectChanges([]memory.ResourceSnapshot{
|
|
{ID: "vm-300", Name: "fallback-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()},
|
|
})
|
|
intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil)
|
|
|
|
canonicalStore := ur.NewMemoryStore()
|
|
if err := canonicalStore.RecordChange(ur.ResourceChange{
|
|
ID: "change-1",
|
|
ObservedAt: time.Now().Add(-time.Hour),
|
|
ResourceID: "vm-300",
|
|
Kind: ur.ChangeConfigUpdate,
|
|
SourceType: ur.SourcePulseDiff,
|
|
SourceAdapter: ur.AdapterOpsAgent,
|
|
Reason: "canonical refresh",
|
|
}); err != nil {
|
|
t.Fatalf("record canonical change: %v", err)
|
|
}
|
|
intel.SetResourceTimelineStore(canonicalStore, "org-1")
|
|
|
|
ctx := intel.FormatContext("vm-300")
|
|
if !strings.Contains(ctx, "Recent Changes") {
|
|
t.Fatalf("expected recent changes section, got %q", ctx)
|
|
}
|
|
if !strings.Contains(ctx, "canonical refresh") {
|
|
t.Fatalf("expected canonical timeline entry, got %q", ctx)
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_FormatContext_FallsBackToChangeDetector(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
|
|
changes.DetectChanges([]memory.ResourceSnapshot{
|
|
{ID: "vm-301", Name: "fallback-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()},
|
|
})
|
|
intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil)
|
|
|
|
ctx := intel.FormatContext("vm-301")
|
|
if !strings.Contains(ctx, "Recent Changes") {
|
|
t.Fatalf("expected recent changes section, got %q", ctx)
|
|
}
|
|
if !strings.Contains(ctx, "fallback-vm") {
|
|
t.Fatalf("expected fallback change detector entry, got %q", ctx)
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_FormatGlobalContext_IncludesCanonicalRecentChanges(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
canonicalStore := ur.NewMemoryStore()
|
|
if err := canonicalStore.RecordChange(ur.ResourceChange{
|
|
ID: "change-1",
|
|
ObservedAt: time.Now().Add(-time.Hour),
|
|
ResourceID: "node-1",
|
|
Kind: ur.ChangeRestart,
|
|
SourceType: ur.SourcePlatformEvent,
|
|
SourceAdapter: ur.AdapterProxmox,
|
|
Reason: "node restarted after maintenance",
|
|
}); err != nil {
|
|
t.Fatalf("record canonical change: %v", err)
|
|
}
|
|
intel.SetResourceTimelineStore(canonicalStore, "org-1")
|
|
|
|
ctx := intel.FormatGlobalContext()
|
|
if !strings.Contains(ctx, "Recent Changes Across Infrastructure") {
|
|
t.Fatalf("expected global recent changes section, got %q", ctx)
|
|
}
|
|
if !strings.Contains(ctx, "node-1") {
|
|
t.Fatalf("expected resource identifier in global timeline, got %q", ctx)
|
|
}
|
|
if !strings.Contains(ctx, "node restarted after maintenance") {
|
|
t.Fatalf("expected canonical global timeline entry, got %q", ctx)
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_FormatGlobalContext_FallsBackToChangeDetector(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
|
|
changes.DetectChanges([]memory.ResourceSnapshot{
|
|
{ID: "node-fallback", Name: "fallback-node", Type: "node", Status: "running", SnapshotTime: time.Now()},
|
|
})
|
|
intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil)
|
|
|
|
ctx := intel.FormatGlobalContext()
|
|
if !strings.Contains(ctx, "Recent Changes Across Infrastructure") {
|
|
t.Fatalf("expected global recent changes section, got %q", ctx)
|
|
}
|
|
if !strings.Contains(ctx, "fallback-node") {
|
|
t.Fatalf("expected fallback global timeline entry, got %q", ctx)
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_BuildRecentChangesContext_UsesCanonicalSectionFormatter(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
store := ur.NewMemoryStore()
|
|
if err := store.RecordChange(ur.ResourceChange{
|
|
ID: "change-1",
|
|
ObservedAt: time.Now().Add(-time.Hour),
|
|
ResourceID: "node-1",
|
|
Kind: ur.ChangeRestart,
|
|
SourceType: ur.SourcePlatformEvent,
|
|
SourceAdapter: ur.AdapterProxmox,
|
|
Reason: "node restarted after maintenance",
|
|
}); err != nil {
|
|
t.Fatalf("record canonical change: %v", err)
|
|
}
|
|
|
|
ctx := intel.buildRecentChangesContext("node-1", store, nil, true, 5)
|
|
for _, want := range []string{
|
|
"## Recent Changes Across Infrastructure",
|
|
"node-1: **Restart**",
|
|
"platform_event/proxmox_adapter",
|
|
} {
|
|
if !strings.Contains(ctx, want) {
|
|
t.Fatalf("expected canonical recent-changes context %q to contain %q", ctx, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_BuildRecentChangesContext_FallsBackToMemoryFormatter(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
|
|
changes.DetectChanges([]memory.ResourceSnapshot{
|
|
{ID: "vm-302", Name: "fallback-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()},
|
|
})
|
|
|
|
ctx := intel.buildRecentChangesContext("vm-302", nil, changes, true, 5)
|
|
want := memory.FormatRecentChangesContext(changes.GetChangesForResource("vm-302", 5), true, "##")
|
|
if ctx != want {
|
|
t.Fatalf("expected fallback recent-changes context to use shared memory formatter:\nwant %q\n got %q", want, ctx)
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_CreatePredictionFinding_LowSeverity(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
// Prediction far in the future with low confidence
|
|
pred := patterns.FailurePrediction{
|
|
ResourceID: "vm-100",
|
|
EventType: patterns.EventHighMemory,
|
|
DaysUntil: 14, // Far away
|
|
Confidence: 0.3, // Low confidence
|
|
Basis: "Pattern detected",
|
|
}
|
|
|
|
finding := intel.CreatePredictionFinding(pred)
|
|
|
|
// Should be watch severity (not critical or warning)
|
|
if finding.Severity != FindingSeverityWatch {
|
|
t.Errorf("Expected watch severity for far-off low-confidence prediction, got %s", finding.Severity)
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_CreatePredictionFinding_WarningSeverity(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
// Prediction soon but low confidence
|
|
pred := patterns.FailurePrediction{
|
|
ResourceID: "vm-100",
|
|
EventType: patterns.EventHighCPU,
|
|
DaysUntil: 0.5, // Soon
|
|
Confidence: 0.6, // Medium confidence (not > 0.8)
|
|
Basis: "Pattern detected",
|
|
}
|
|
|
|
finding := intel.CreatePredictionFinding(pred)
|
|
|
|
// Should be warning (soon but not high confidence)
|
|
if finding.Severity != FindingSeverityWarning {
|
|
t.Errorf("Expected warning severity for imminent medium-confidence prediction, got %s", finding.Severity)
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_GetSummary_WithCriticalFindings(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
findings := NewFindingsStore()
|
|
|
|
// Add multiple critical findings
|
|
for i := 0; i < 3; i++ {
|
|
findings.Add(&Finding{
|
|
ID: "crit-" + string(rune('A'+i)),
|
|
Key: "critical:" + string(rune('A'+i)),
|
|
Severity: FindingSeverityCritical,
|
|
Category: FindingCategoryReliability,
|
|
ResourceID: "vm-crit-" + string(rune('A'+i)),
|
|
Title: "Critical Issue",
|
|
DetectedAt: time.Now(),
|
|
LastSeenAt: time.Now(),
|
|
Source: "test",
|
|
})
|
|
}
|
|
|
|
intel.SetSubsystems(findings, nil, nil, nil, nil, nil, nil, nil)
|
|
|
|
summary := intel.GetSummary()
|
|
|
|
// Should have 3 critical
|
|
if summary.FindingsCount.Critical != 3 {
|
|
t.Errorf("Expected 3 critical findings, got %d", summary.FindingsCount.Critical)
|
|
}
|
|
|
|
// Health should be significantly reduced
|
|
// Note: critical impact is capped at 40 points, so score = 100 - 40 = 60
|
|
if summary.OverallHealth.Score > 60 {
|
|
t.Errorf("Expected health score <= 60 with 3 critical findings, got %f", summary.OverallHealth.Score)
|
|
}
|
|
|
|
// Prediction text should mention critical issues
|
|
if summary.OverallHealth.Prediction == "" {
|
|
t.Error("Expected non-empty prediction text")
|
|
}
|
|
}
|
|
|
|
func TestAbsFloatIntel(t *testing.T) {
|
|
tests := []struct {
|
|
input float64
|
|
expected float64
|
|
}{
|
|
{5.5, 5.5},
|
|
{-5.5, 5.5},
|
|
{0, 0},
|
|
{-0, 0},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
result := absFloatIntel(tt.input)
|
|
if result != tt.expected {
|
|
t.Errorf("absFloatIntel(%f) = %f, expected %f", tt.input, result, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_GetSummary_WithPatterns(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
// Create pattern detector with predictions
|
|
patternDetector := patterns.NewDetector(patterns.DefaultConfig())
|
|
|
|
// Set up the subsystems
|
|
intel.SetSubsystems(nil, patternDetector, nil, nil, nil, nil, nil, nil)
|
|
|
|
summary := intel.GetSummary()
|
|
|
|
// Predictions count should be available
|
|
if summary.PredictionsCount < 0 {
|
|
t.Error("PredictionsCount should not be negative")
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_GetResourceIntelligence_WithBaselines(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
// Create baseline store with learned data
|
|
baselineStore := baseline.NewStore(baseline.StoreConfig{MinSamples: 10})
|
|
|
|
// Learn baseline for CPU
|
|
points := make([]baseline.MetricPoint, 100)
|
|
for i := 0; i < 100; i++ {
|
|
points[i] = baseline.MetricPoint{Value: 30 + float64(i%5) - 2}
|
|
}
|
|
_ = baselineStore.Learn("vm-with-baseline", "vm", "cpu", points)
|
|
_ = baselineStore.Learn("vm-with-baseline", "vm", "memory", points)
|
|
|
|
intel.SetSubsystems(nil, nil, nil, baselineStore, nil, nil, nil, nil)
|
|
|
|
resourceIntel := intel.GetResourceIntelligence("vm-with-baseline")
|
|
|
|
// Should have baselines
|
|
if len(resourceIntel.Baselines) == 0 {
|
|
t.Error("Expected baselines to be populated")
|
|
}
|
|
|
|
// Check CPU baseline exists
|
|
if _, ok := resourceIntel.Baselines["cpu"]; !ok {
|
|
t.Error("Expected CPU baseline")
|
|
}
|
|
if _, ok := resourceIntel.Baselines["memory"]; !ok {
|
|
t.Error("Expected memory baseline")
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_GetResourceIntelligence_WithIncidents(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
// Create incident store with some incidents
|
|
incidentStore := memory.NewIncidentStore(memory.IncidentStoreConfig{
|
|
MaxIncidents: 10,
|
|
})
|
|
|
|
// Record an incident for the resource
|
|
incidentStore.RecordAnalysis("alert-vm-500", "Analysis for vm-500", nil)
|
|
|
|
intel.SetSubsystems(nil, nil, nil, nil, incidentStore, nil, nil, nil)
|
|
|
|
resourceIntel := intel.GetResourceIntelligence("vm-500")
|
|
|
|
// Should have the resource ID
|
|
if resourceIntel.ResourceID != "vm-500" {
|
|
t.Errorf("Expected resource ID 'vm-500', got %s", resourceIntel.ResourceID)
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_calculateResourceHealth_WithAnomalies(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
// Create resource intelligence with anomalies
|
|
resourceIntel := &ResourceIntelligence{
|
|
ResourceID: "test-vm",
|
|
Anomalies: []AnomalyReport{
|
|
{
|
|
Metric: "cpu",
|
|
CurrentValue: 90,
|
|
BaselineMean: 30,
|
|
ZScore: 5.0,
|
|
Severity: baseline.AnomalyCritical,
|
|
Description: "CPU is 3x baseline",
|
|
},
|
|
{
|
|
Metric: "memory",
|
|
CurrentValue: 80,
|
|
BaselineMean: 50,
|
|
ZScore: 3.0,
|
|
Severity: baseline.AnomalyMedium,
|
|
Description: "Memory is elevated",
|
|
},
|
|
},
|
|
}
|
|
|
|
health := intel.calculateResourceHealth(resourceIntel)
|
|
|
|
// Health should be reduced due to anomalies
|
|
if health.Score >= 100 {
|
|
t.Error("Expected reduced health score due to anomalies")
|
|
}
|
|
|
|
// Should have factors for anomalies
|
|
hasAnomalyFactor := false
|
|
for _, f := range health.Factors {
|
|
if f.Category == "baseline" {
|
|
hasAnomalyFactor = true
|
|
break
|
|
}
|
|
}
|
|
if !hasAnomalyFactor {
|
|
t.Error("Expected baseline/anomaly factor in health factors")
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_calculateResourceHealth_WithPredictions(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
// Create resource intelligence with predictions
|
|
resourceIntel := &ResourceIntelligence{
|
|
ResourceID: "test-vm",
|
|
Predictions: []patterns.FailurePrediction{
|
|
{
|
|
ResourceID: "test-vm",
|
|
EventType: patterns.EventHighCPU,
|
|
DaysUntil: 2.0,
|
|
Confidence: 0.8,
|
|
Basis: "Pattern detected",
|
|
},
|
|
},
|
|
}
|
|
|
|
health := intel.calculateResourceHealth(resourceIntel)
|
|
|
|
// Health should be reduced due to predictions
|
|
if health.Score >= 100 {
|
|
t.Error("Expected reduced health score due to predictions")
|
|
}
|
|
|
|
// Should have a prediction factor
|
|
hasPredictionFactor := false
|
|
for _, f := range health.Factors {
|
|
if f.Category == "prediction" {
|
|
hasPredictionFactor = true
|
|
break
|
|
}
|
|
}
|
|
if !hasPredictionFactor {
|
|
t.Error("Expected prediction factor in health factors")
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_calculateResourceHealth_WithNotes(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
// Create resource intelligence with notes (bonus for documentation)
|
|
resourceIntel := &ResourceIntelligence{
|
|
ResourceID: "test-vm",
|
|
NoteCount: 3,
|
|
}
|
|
|
|
health := intel.calculateResourceHealth(resourceIntel)
|
|
|
|
// Health should have a bonus for having notes
|
|
if health.Score < 100 {
|
|
t.Error("Expected health score >= 100 with only notes (bonus)")
|
|
}
|
|
|
|
// Should have a learning factor
|
|
hasLearningFactor := false
|
|
for _, f := range health.Factors {
|
|
if f.Category == "learning" {
|
|
hasLearningFactor = true
|
|
break
|
|
}
|
|
}
|
|
if !hasLearningFactor {
|
|
t.Error("Expected learning factor for documented resource")
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_GetSummary_WithLearningBonus(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
// Create knowledge store with many resources
|
|
knowledgeStore, err := knowledge.NewStore(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("Failed to create knowledge store: %v", err)
|
|
}
|
|
|
|
// Add knowledge for 6+ resources to trigger learning bonus
|
|
for i := 0; i < 7; i++ {
|
|
resourceID := "vm-" + string(rune('A'+i))
|
|
_ = knowledgeStore.SaveNote(resourceID, "VM "+string(rune('A'+i)), "vm", "general", "Note", "Content")
|
|
}
|
|
|
|
intel.SetSubsystems(nil, nil, nil, nil, nil, knowledgeStore, nil, nil)
|
|
|
|
summary := intel.GetSummary()
|
|
|
|
// With 6+ resources learned, should have learning bonus factor
|
|
hasLearningFactor := false
|
|
for _, f := range summary.OverallHealth.Factors {
|
|
if f.Category == "learning" {
|
|
hasLearningFactor = true
|
|
break
|
|
}
|
|
}
|
|
if !hasLearningFactor {
|
|
t.Error("Expected learning factor with 6+ resources learned")
|
|
}
|
|
}
|
|
|
|
func TestIntelligence_generateHealthPrediction_Warnings(t *testing.T) {
|
|
intel := NewIntelligence(IntelligenceConfig{})
|
|
|
|
health := HealthScore{
|
|
Score: 80,
|
|
Grade: HealthGradeB,
|
|
}
|
|
|
|
summary := &IntelligenceSummary{
|
|
FindingsCount: FindingsCounts{
|
|
Warning: 3,
|
|
},
|
|
}
|
|
|
|
prediction := intel.generateHealthPrediction(health, summary)
|
|
|
|
if prediction == "" {
|
|
t.Error("Expected non-empty prediction")
|
|
}
|
|
if !strings.Contains(prediction, "warning") && !strings.Contains(prediction, "Warning") {
|
|
t.Errorf("Expected prediction to mention warnings, got: %s", prediction)
|
|
}
|
|
}
|