Pulse/internal/ai/context/builder_test.go
rcourtman 3fdf753a5b Enhance devcontainer and CI workflows
- Add persistent volume mounts for Go/npm caches (faster rebuilds)
- Add shell config with helpful aliases and custom prompt
- Add comprehensive devcontainer documentation
- Add pre-commit hooks for Go formatting and linting
- Use go-version-file in CI workflows instead of hardcoded versions
- Simplify docker compose commands with --wait flag
- Add gitignore entries for devcontainer auth files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:29:15 +00:00

483 lines
12 KiB
Go

package context
import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
func TestNewBuilder(t *testing.T) {
builder := NewBuilder()
if builder == nil {
t.Fatal("Expected non-nil builder")
}
}
func TestBuilder_WithMethods(t *testing.T) {
builder := NewBuilder()
// Test chaining methods return the builder
result := builder.
WithMetricsHistory(nil).
WithKnowledge(nil).
WithFindings(nil).
WithBaseline(nil)
if result != builder {
t.Error("Expected method chaining to return same builder instance")
}
}
func TestBuilder_BuildForInfrastructure_Empty(t *testing.T) {
builder := NewBuilder()
// Empty state
state := models.StateSnapshot{}
ctx := builder.BuildForInfrastructure(state)
if ctx == nil {
t.Fatal("Expected non-nil context")
}
// Empty state should have zero totals
if ctx.TotalResources != 0 {
t.Errorf("Expected 0 total resources for empty state, got %d", ctx.TotalResources)
}
// GeneratedAt should be set
if ctx.GeneratedAt.IsZero() {
t.Error("Expected GeneratedAt to be set")
}
}
func TestBuilder_BuildForInfrastructure_WithNodes(t *testing.T) {
builder := NewBuilder()
state := models.StateSnapshot{
Nodes: []models.Node{
{
ID: "node-1",
Name: "pve-primary",
Status: "online",
CPU: 0.45,
},
{
ID: "node-2",
Name: "pve-secondary",
Status: "online",
CPU: 0.25,
},
},
}
ctx := builder.BuildForInfrastructure(state)
if len(ctx.Nodes) != 2 {
t.Errorf("Expected 2 nodes, got %d", len(ctx.Nodes))
}
}
func TestBuilder_BuildForInfrastructure_WithGuests(t *testing.T) {
builder := NewBuilder()
state := models.StateSnapshot{
Nodes: []models.Node{
{ID: "node-1", Name: "pve-primary", Status: "online"},
},
VMs: []models.VM{
{ID: "vm-100", Name: "web-server", Node: "pve-primary", Status: "running", CPU: 0.30},
{ID: "vm-101", Name: "database", Node: "pve-primary", Status: "running", CPU: 0.50},
},
Containers: []models.Container{
{ID: "ct-200", Name: "nginx-proxy", Node: "pve-primary", Status: "running", CPU: 0.10},
},
}
ctx := builder.BuildForInfrastructure(state)
if len(ctx.VMs) != 2 {
t.Errorf("Expected 2 VMs, got %d", len(ctx.VMs))
}
if len(ctx.Containers) != 1 {
t.Errorf("Expected 1 container, got %d", len(ctx.Containers))
}
}
func TestBuilder_BuildForInfrastructure_SkipsTemplates(t *testing.T) {
builder := NewBuilder()
state := models.StateSnapshot{
VMs: []models.VM{
{ID: "vm-100", Name: "web-server", Status: "running", Template: false},
{ID: "vm-101", Name: "template", Status: "stopped", Template: true},
},
Containers: []models.Container{
{ID: "ct-200", Name: "nginx", Status: "running", Template: false},
{ID: "ct-201", Name: "template", Status: "stopped", Template: true},
},
}
ctx := builder.BuildForInfrastructure(state)
// Should skip templates
if len(ctx.VMs) != 1 {
t.Errorf("Expected 1 VM (template skipped), got %d", len(ctx.VMs))
}
if len(ctx.Containers) != 1 {
t.Errorf("Expected 1 container (template skipped), got %d", len(ctx.Containers))
}
}
func TestBuilder_BuildForInfrastructure_WithStorage(t *testing.T) {
builder := NewBuilder()
state := models.StateSnapshot{
Storage: []models.Storage{
{
ID: "storage-1",
Name: "local-zfs",
Type: "zfspool",
Status: "available",
},
{
ID: "storage-2",
Name: "nfs-share",
Type: "nfs",
Status: "available",
},
},
}
ctx := builder.BuildForInfrastructure(state)
if len(ctx.Storage) != 2 {
t.Errorf("Expected 2 storage, got %d", len(ctx.Storage))
}
}
func TestBuilder_BuildForInfrastructure_WithDocker(t *testing.T) {
builder := NewBuilder()
state := models.StateSnapshot{
DockerHosts: []models.DockerHost{
{
ID: "docker-1",
Hostname: "docker-host-1",
Containers: []models.DockerContainer{
{ID: "container-1", Name: "nginx", State: "running"},
{ID: "container-2", Name: "redis", State: "running"},
},
},
},
}
ctx := builder.BuildForInfrastructure(state)
if len(ctx.DockerHosts) != 1 {
t.Errorf("Expected 1 docker host, got %d", len(ctx.DockerHosts))
}
}
func TestBuilder_BuildForInfrastructure_WithHosts(t *testing.T) {
builder := NewBuilder()
state := models.StateSnapshot{
Hosts: []models.Host{
{
ID: "host-1",
Hostname: "server-1",
Status: "online",
CPUCount: 8,
},
},
}
ctx := builder.BuildForInfrastructure(state)
if len(ctx.Hosts) != 1 {
t.Errorf("Expected 1 host, got %d", len(ctx.Hosts))
}
}
func TestBuilder_BuildForInfrastructure_TotalResources(t *testing.T) {
builder := NewBuilder()
state := models.StateSnapshot{
Nodes: []models.Node{
{ID: "node-1", Name: "pve-1", Status: "online"},
},
VMs: []models.VM{
{ID: "vm-100", Name: "web", Status: "running"},
{ID: "vm-101", Name: "db", Status: "running"},
},
Containers: []models.Container{
{ID: "ct-200", Name: "nginx", Status: "running"},
},
Storage: []models.Storage{
{ID: "local", Name: "local", Status: "available"},
},
}
ctx := builder.BuildForInfrastructure(state)
// 1 node + 2 VMs + 1 container + 1 storage = 5
expected := 5
if ctx.TotalResources != expected {
t.Errorf("Expected %d total resources, got %d", expected, ctx.TotalResources)
}
}
func TestBuilder_BuildForInfrastructure_OCI(t *testing.T) {
builder := NewBuilder()
state := models.StateSnapshot{
Containers: []models.Container{
{
ID: "ct-200",
Name: "nginx-oci",
Status: "running",
IsOCI: true,
OSTemplate: "docker.io/library/nginx:latest",
},
},
}
ctx := builder.BuildForInfrastructure(state)
if len(ctx.Containers) != 1 {
t.Fatalf("Expected 1 container, got %d", len(ctx.Containers))
}
// OCI container should have type oci_container
if ctx.Containers[0].ResourceType != "oci_container" {
t.Errorf("Expected resource type 'oci_container', got '%s'", ctx.Containers[0].ResourceType)
}
// Should have metadata with image
if ctx.Containers[0].Metadata == nil {
t.Error("Expected metadata to be set for OCI container")
} else if ctx.Containers[0].Metadata["oci_image"] != "docker.io/library/nginx:latest" {
t.Errorf("Expected oci_image metadata, got %v", ctx.Containers[0].Metadata)
}
}
func TestBuilder_MergeContexts(t *testing.T) {
builder := NewBuilder()
target := &ResourceContext{
ResourceID: "vm-100",
ResourceType: "vm",
ResourceName: "web-server",
Status: "running",
Node: "pve-1",
}
infra := &InfrastructureContext{
TotalResources: 10,
GeneratedAt: time.Now(),
VMs: []ResourceContext{
{ResourceID: "vm-100", ResourceName: "web-server", Node: "pve-1"},
{ResourceID: "vm-101", ResourceName: "database", Node: "pve-1"},
},
}
result := builder.MergeContexts(target, infra)
if result == "" {
t.Error("Expected non-empty merged context")
}
// Should contain target resource section
if !containsSubstring(result, "Target Resource") {
t.Error("Expected merged context to contain 'Target Resource' section")
}
}
func TestBuilder_MergeContexts_IncludesRelated(t *testing.T) {
builder := NewBuilder()
target := &ResourceContext{
ResourceID: "vm-100",
ResourceType: "vm",
ResourceName: "web-server",
Node: "pve-1",
}
infra := &InfrastructureContext{
VMs: []ResourceContext{
{ResourceID: "vm-100", ResourceName: "web-server", Node: "pve-1"},
{ResourceID: "vm-101", ResourceName: "database", Node: "pve-1"},
{ResourceID: "vm-102", ResourceName: "other", Node: "pve-2"}, // Different node
},
}
result := builder.MergeContexts(target, infra)
// Should include related resources section
if !containsSubstring(result, "Related Resources") {
t.Error("Expected merged context to contain 'Related Resources' section when target has a node")
}
}
func TestResourceContext_Fields(t *testing.T) {
ctx := ResourceContext{
ResourceID: "vm-100",
ResourceType: "vm",
ResourceName: "test-vm",
Node: "node-1",
CurrentCPU: 0.45,
CurrentMemory: 0.65,
CurrentDisk: 0.30,
Status: "running",
}
if ctx.ResourceID != "vm-100" {
t.Errorf("Expected ResourceID 'vm-100', got '%s'", ctx.ResourceID)
}
if ctx.CurrentCPU != 0.45 {
t.Errorf("Expected CurrentCPU 0.45, got %f", ctx.CurrentCPU)
}
if ctx.Node != "node-1" {
t.Errorf("Expected Node 'node-1', got '%s'", ctx.Node)
}
}
func TestInfrastructureContext_Fields(t *testing.T) {
now := time.Now()
ctx := InfrastructureContext{
GeneratedAt: now,
TotalResources: 25,
ResourcesWithData: 20,
}
if ctx.TotalResources != 25 {
t.Errorf("Expected 25 total resources, got %d", ctx.TotalResources)
}
if ctx.GeneratedAt != now {
t.Error("Expected GeneratedAt to match")
}
if ctx.ResourcesWithData != 20 {
t.Errorf("Expected 20 resources with data, got %d", ctx.ResourcesWithData)
}
}
// Helper function
func containsSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func TestFilterRecentPoints_Empty(t *testing.T) {
points := []MetricPoint{}
result := filterRecentPoints(points, time.Hour)
if len(result) != 0 {
t.Errorf("Expected empty result, got %d points", len(result))
}
}
func TestFilterRecentPoints_AllRecent(t *testing.T) {
now := time.Now()
points := []MetricPoint{
{Timestamp: now.Add(-30 * time.Minute), Value: 1.0},
{Timestamp: now.Add(-15 * time.Minute), Value: 2.0},
{Timestamp: now.Add(-5 * time.Minute), Value: 3.0},
}
result := filterRecentPoints(points, time.Hour)
if len(result) != 3 {
t.Errorf("Expected 3 points, got %d", len(result))
}
}
func TestFilterRecentPoints_FilterOld(t *testing.T) {
now := time.Now()
points := []MetricPoint{
{Timestamp: now.Add(-3 * time.Hour), Value: 1.0}, // Old
{Timestamp: now.Add(-2 * time.Hour), Value: 2.0}, // Old
{Timestamp: now.Add(-30 * time.Minute), Value: 3.0}, // Recent
}
result := filterRecentPoints(points, time.Hour)
if len(result) != 1 {
t.Errorf("Expected 1 recent point, got %d", len(result))
}
if result[0].Value != 3.0 {
t.Errorf("Expected value 3.0, got %f", result[0].Value)
}
}
func TestFormatAnomalyDescription(t *testing.T) {
tests := []struct {
name string
metric string
current float64
mean float64
stddev float64
severity string
direction string
wantContains []string
}{
{
name: "cpu high",
metric: "cpu",
current: 95.0,
mean: 50.0,
stddev: 10.0,
severity: "significantly",
direction: "above",
wantContains: []string{"Cpu", "significantly", "above", "95%", "50%"},
},
{
name: "memory low",
metric: "memory",
current: 20.0,
mean: 60.0,
stddev: 15.0,
severity: "slightly",
direction: "below",
wantContains: []string{"Memory", "slightly", "below", "20%", "60%"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := formatAnomalyDescription(tt.metric, tt.current, tt.mean, tt.stddev, tt.severity, tt.direction)
for _, want := range tt.wantContains {
if !containsSubstring(result, want) {
t.Errorf("formatAnomalyDescription() = %q, want to contain %q", result, want)
}
}
})
}
}
func TestFormatPredictionBasis(t *testing.T) {
trend := Trend{
RatePerDay: 2.5,
Period: 7 * 24 * time.Hour, // 7 days
}
result := formatPredictionBasis(trend)
if !containsSubstring(result, "Growing") {
t.Errorf("Expected 'Growing' in result, got %q", result)
}
if !containsSubstring(result, "based on") {
t.Errorf("Expected 'based on' in result, got %q", result)
}
}