Pulse/internal/ai/forecast/service_additional_test.go
2026-01-25 21:08:44 +00:00

504 lines
14 KiB
Go

package forecast
import (
"testing"
"time"
)
type mockStateProvider struct {
state StateSnapshot
}
func (m mockStateProvider) GetState() StateSnapshot {
return m.state
}
func buildLinearData(end time.Time, points int, step time.Duration, startValue, stepValue float64) []MetricDataPoint {
data := make([]MetricDataPoint, points)
start := end.Add(-time.Duration(points-1) * step)
for i := 0; i < points; i++ {
data[i] = MetricDataPoint{
Timestamp: start.Add(time.Duration(i) * step),
Value: startValue + float64(i)*stepValue,
}
}
return data
}
func TestNewService_DefaultsApplied(t *testing.T) {
svc := NewService(ForecastConfig{})
if svc.config.ShortTermWindow <= 0 {
t.Error("expected ShortTermWindow default to be set")
}
if svc.config.MediumTermWindow <= 0 {
t.Error("expected MediumTermWindow default to be set")
}
if svc.config.LongTermWindow <= 0 {
t.Error("expected LongTermWindow default to be set")
}
if svc.config.DefaultHorizon <= 0 {
t.Error("expected DefaultHorizon default to be set")
}
if svc.config.MaxHorizon <= 0 {
t.Error("expected MaxHorizon default to be set")
}
if svc.config.StableThreshold <= 0 {
t.Error("expected StableThreshold default to be set")
}
if svc.config.VolatileThreshold <= 0 {
t.Error("expected VolatileThreshold default to be set")
}
}
func TestIsPercentageMetric(t *testing.T) {
cases := map[string]bool{
"cpu": true,
"CPU": true,
"memory": true,
"mem": true,
"disk": true,
"iops": false,
}
for metric, expected := range cases {
if got := isPercentageMetric(metric); got != expected {
t.Errorf("metric %q expected %v, got %v", metric, expected, got)
}
}
}
func TestCalculateTrend_Volatile(t *testing.T) {
svc := NewService(DefaultForecastConfig())
now := time.Now()
data := []MetricDataPoint{
{Timestamp: now.Add(-5 * time.Hour), Value: 10},
{Timestamp: now.Add(-4 * time.Hour), Value: 120},
{Timestamp: now.Add(-3 * time.Hour), Value: 15},
{Timestamp: now.Add(-2 * time.Hour), Value: 130},
{Timestamp: now.Add(-1 * time.Hour), Value: 20},
{Timestamp: now, Value: 140},
}
trend := svc.calculateTrend(data, DefaultForecastConfig())
if trend.Direction != TrendVolatile {
t.Fatalf("expected volatile trend, got %s", trend.Direction)
}
}
func TestDetectSeasonality_InsufficientData(t *testing.T) {
svc := NewService(DefaultForecastConfig())
now := time.Now()
data := buildLinearData(now, 24, time.Hour, 10, 0)
if seasonality := svc.detectSeasonality(data); seasonality != nil {
t.Fatalf("expected nil seasonality for insufficient data")
}
}
func TestDetectSeasonality_DailyPeaks(t *testing.T) {
svc := NewService(DefaultForecastConfig())
base := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)
var data []MetricDataPoint
for day := 0; day < 3; day++ {
for hour := 0; hour < 24; hour++ {
value := 1.0
if hour == 14 {
value = 100.0
}
data = append(data, MetricDataPoint{
Timestamp: base.Add(time.Duration(day*24+hour) * time.Hour),
Value: value,
})
}
}
seasonality := svc.detectSeasonality(data)
if seasonality == nil || !seasonality.HasDaily {
t.Fatalf("expected daily seasonality to be detected")
}
if seasonality.HasWeekly {
t.Fatalf("expected weekly seasonality to be false")
}
foundPeak := false
for _, hour := range seasonality.PeakHours {
if hour == 14 {
foundPeak = true
break
}
}
if !foundPeak {
t.Fatalf("expected peak hour 14 to be detected")
}
}
func TestGenerateDescription_WithThresholdAndAcceleration(t *testing.T) {
svc := NewService(DefaultForecastConfig())
threshold := 90.0
timeToThreshold := 4 * time.Hour
trend := Trend{
Direction: TrendIncreasing,
RatePerDay: 12.5,
Acceleration: 1.0,
}
desc := svc.generateDescription("cpu", 70, 80, trend, &timeToThreshold, threshold)
if !containsStr(desc, "increasing") {
t.Fatalf("expected increasing text in description")
}
if !containsStr(desc, "Will reach 90% in 4 hours") {
t.Fatalf("expected time-to-threshold text in description: %s", desc)
}
if !containsStr(desc, "accelerating") {
t.Fatalf("expected accelerating text in description")
}
}
func TestGenerateDescription_WeeksAndDecelerating(t *testing.T) {
svc := NewService(DefaultForecastConfig())
timeToThreshold := 14 * 24 * time.Hour
trend := Trend{
Direction: TrendDecreasing,
RatePerDay: -2.0,
Acceleration: -1.2,
}
desc := svc.generateDescription("disk", 80, 60, trend, &timeToThreshold, 50)
if !containsStr(desc, "decreasing") {
t.Fatalf("expected decreasing text in description")
}
if !containsStr(desc, "weeks") {
t.Fatalf("expected weeks time-to-threshold text")
}
if !containsStr(desc, "decelerating") {
t.Fatalf("expected decelerating text in description")
}
}
func TestCalculateConfidence_ClampHigh(t *testing.T) {
svc := NewService(DefaultForecastConfig())
now := time.Now()
data := buildLinearData(now, 1000, time.Minute, 10, 0)
trend := Trend{Direction: TrendStable}
confidence := svc.calculateConfidence(data, trend)
if confidence != 0.95 {
t.Fatalf("expected confidence to be clamped at 0.95, got %.2f", confidence)
}
}
func TestCalculateConfidence_VolatileAcceleration(t *testing.T) {
svc := NewService(DefaultForecastConfig())
now := time.Now()
data := buildLinearData(now, 10, time.Minute, 10, 5)
trend := Trend{Direction: TrendVolatile, Acceleration: 2.0}
confidence := svc.calculateConfidence(data, trend)
if confidence >= 0.5 {
t.Fatalf("expected lower confidence for volatile acceleration, got %.2f", confidence)
}
}
func TestFormatForContext_LowConfidenceNote(t *testing.T) {
svc := NewService(DefaultForecastConfig())
forecasts := []*Forecast{
{
ResourceID: "vm-201",
Metric: "cpu",
Trend: Trend{Direction: TrendIncreasing},
Description: "CPU is increasing",
Confidence: 0.2,
},
}
context := svc.FormatForContext(forecasts)
if !containsStr(context, "low confidence") {
t.Fatalf("expected low confidence note in context")
}
}
func TestFormatKeyForecasts_NoProviders(t *testing.T) {
svc := NewService(DefaultForecastConfig())
if result := svc.FormatKeyForecasts(); result != "" {
t.Fatalf("expected empty result when providers are missing")
}
}
func TestFormatKeyForecasts_NoStateProvider(t *testing.T) {
svc := NewService(DefaultForecastConfig())
svc.SetDataProvider(&mockDataProvider{data: map[string][]MetricDataPoint{}})
if result := svc.FormatKeyForecasts(); result != "" {
t.Fatalf("expected empty result when state provider is missing")
}
}
func TestFormatKeyForecasts_Concerns(t *testing.T) {
cfg := DefaultForecastConfig()
cfg.VolatileThreshold = 100.0
svc := NewService(cfg)
now := time.Now()
data := buildLinearData(now, 24, time.Hour, 70, 1.0)
svc.SetDataProvider(&mockDataProvider{
data: map[string][]MetricDataPoint{
"vm-1:cpu": data,
},
})
svc.SetStateProvider(mockStateProvider{
state: StateSnapshot{
VMs: []VMInfo{{ID: "vm-1", Name: ""}},
},
})
result := svc.FormatKeyForecasts()
if result == "" {
t.Fatalf("expected non-empty result for concerning trends")
}
if !containsStr(result, "vm-1") {
t.Fatalf("expected vm-1 to be mentioned in concerns")
}
if !containsStr(result, "increasing") {
t.Fatalf("expected increasing trend note in concerns")
}
if !containsStr(result, "critical") {
t.Fatalf("expected critical note in concerns")
}
}
func TestFormatKeyForecasts_AllResourceTypes(t *testing.T) {
cfg := DefaultForecastConfig()
cfg.VolatileThreshold = 100.0
svc := NewService(cfg)
now := time.Now()
data := buildLinearData(now, 24, time.Hour, 70, 1.0)
svc.SetDataProvider(&mockDataProvider{
data: map[string][]MetricDataPoint{
"vm-1:cpu": data,
"ct-1:cpu": data,
"node-1:cpu": data,
"storage-1:disk": data,
},
})
svc.SetStateProvider(mockStateProvider{
state: StateSnapshot{
VMs: []VMInfo{{ID: "vm-1", Name: "vm"}},
Containers: []ContainerInfo{{ID: "ct-1", Name: "ct"}},
Nodes: []NodeInfo{{ID: "node-1", Name: "node"}},
Storage: []StorageInfo{{ID: "storage-1", Name: ""}},
},
})
result := svc.FormatKeyForecasts()
if result == "" {
t.Fatalf("expected formatted concerns")
}
if !containsStr(result, "storage-1") {
t.Fatalf("expected storage to be included")
}
}
func TestForecastAll_ActionableSorted(t *testing.T) {
cfg := DefaultForecastConfig()
cfg.VolatileThreshold = 100.0
svc := NewService(cfg)
now := time.Now()
rapid := buildLinearData(now, 80, time.Hour, 10, 1.0) // current near threshold
slow := buildLinearData(now, 80, time.Hour, 20, 0.5) // slower breach
flat := buildLinearData(now, 80, time.Hour, 60, 0.0) // non-increasing
svc.SetDataProvider(&mockDataProvider{
data: map[string][]MetricDataPoint{
"vm-fast:disk": rapid,
"vm-slow:disk": slow,
"vm-flat:disk": flat,
},
})
svc.SetStateProvider(mockStateProvider{
state: StateSnapshot{
VMs: []VMInfo{
{ID: "vm-fast", Name: "fast"},
{ID: "vm-slow", Name: "slow"},
{ID: "vm-flat", Name: "flat"},
},
},
})
resp, err := svc.ForecastAll("disk", 24*time.Hour, 90)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.Forecasts) != 2 {
t.Fatalf("expected 2 actionable forecasts, got %d", len(resp.Forecasts))
}
if resp.Forecasts[0].ResourceID != "vm-fast" {
t.Fatalf("expected vm-fast to be most urgent, got %s", resp.Forecasts[0].ResourceID)
}
if resp.Forecasts[1].ResourceID != "vm-slow" {
t.Fatalf("expected vm-slow to be second, got %s", resp.Forecasts[1].ResourceID)
}
}
func TestForecastAll_MissingProviders(t *testing.T) {
svc := NewService(DefaultForecastConfig())
if _, err := svc.ForecastAll("disk", time.Hour, 80); err == nil {
t.Fatalf("expected error when data provider is missing")
}
svc.SetDataProvider(&mockDataProvider{data: map[string][]MetricDataPoint{}})
if _, err := svc.ForecastAll("disk", time.Hour, 80); err == nil {
t.Fatalf("expected error when state provider is missing")
}
}
func TestForecastAll_FiltersNonActionable(t *testing.T) {
cfg := DefaultForecastConfig()
cfg.VolatileThreshold = 100.0
svc := NewService(cfg)
now := time.Now()
aboveThreshold := buildLinearData(now, 60, time.Hour, 80, 0.3)
lowConfidence := buildLinearData(now, 5, time.Hour, 10, 1.0)
svc.SetDataProvider(&mockDataProvider{
data: map[string][]MetricDataPoint{
"vm-above:disk": aboveThreshold,
"vm-low:disk": lowConfidence,
},
})
svc.SetStateProvider(mockStateProvider{
state: StateSnapshot{
VMs: []VMInfo{
{ID: "vm-above", Name: "above"},
{ID: "vm-low", Name: "low"},
},
},
})
resp, err := svc.ForecastAll("disk", 24*time.Hour, 90)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.Forecasts) != 0 {
t.Fatalf("expected no actionable forecasts, got %d", len(resp.Forecasts))
}
}
func TestForecastAll_MultipleResourceTypes(t *testing.T) {
cfg := DefaultForecastConfig()
cfg.VolatileThreshold = 100.0
svc := NewService(cfg)
now := time.Now()
vmData := buildLinearData(now, 60, time.Hour, 20, 0.8)
ctData := buildLinearData(now, 60, time.Hour, 30, 0.6)
nodeData := buildLinearData(now, 60, time.Hour, 40, 0.4)
svc.SetDataProvider(&mockDataProvider{
data: map[string][]MetricDataPoint{
"vm-1:disk": vmData,
"ct-1:disk": ctData,
"node-1:disk": nodeData,
},
})
svc.SetStateProvider(mockStateProvider{
state: StateSnapshot{
VMs: []VMInfo{{ID: "vm-1", Name: "vm"}},
Containers: []ContainerInfo{{ID: "ct-1", Name: "ct"}},
Nodes: []NodeInfo{{ID: "node-1", Name: "node"}},
},
})
resp, err := svc.ForecastAll("disk", 24*time.Hour, 90)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.Forecasts) != 3 {
t.Fatalf("expected forecasts for vm, container, node, got %d", len(resp.Forecasts))
}
}
func TestForecastAll_SkipsErroredResources(t *testing.T) {
cfg := DefaultForecastConfig()
cfg.VolatileThreshold = 100.0
svc := NewService(cfg)
now := time.Now()
vmData := buildLinearData(now, 60, time.Hour, 20, 0.8)
svc.SetDataProvider(&mockDataProvider{
data: map[string][]MetricDataPoint{
"vm-1:disk": vmData,
},
})
svc.SetStateProvider(mockStateProvider{
state: StateSnapshot{
VMs: []VMInfo{{ID: "vm-1", Name: "vm"}},
Containers: []ContainerInfo{{ID: "ct-1", Name: "ct"}},
},
})
resp, err := svc.ForecastAll("disk", 24*time.Hour, 90)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.Forecasts) != 1 {
t.Fatalf("expected only VM forecast, got %d", len(resp.Forecasts))
}
}
func TestForecastToOverviewItem(t *testing.T) {
ttl := 2 * time.Hour
item := forecastToOverviewItem(&Forecast{
ResourceID: "vm-9",
ResourceName: "db",
Metric: "disk",
CurrentValue: 70,
PredictedValue: 85,
TimeToThreshold: &ttl,
Confidence: 0.6,
Trend: Trend{Direction: TrendIncreasing},
}, "vm")
if item.TimeToThreshold == nil || *item.TimeToThreshold == 0 {
t.Fatalf("expected time to threshold to be converted to seconds")
}
if item.ResourceType != "vm" {
t.Fatalf("expected resource type vm, got %s", item.ResourceType)
}
}
func TestLinearRegression_Degenerate(t *testing.T) {
now := time.Now()
data := []MetricDataPoint{
{Timestamp: now, Value: 10},
{Timestamp: now, Value: 20},
{Timestamp: now, Value: 30},
}
slope, intercept := linearRegression(data)
if slope != 0 {
t.Fatalf("expected zero slope for degenerate timestamps, got %.2f", slope)
}
if intercept != 20 {
t.Fatalf("expected intercept to be mean (20), got %.2f", intercept)
}
}
func TestFilterByWindow_InclusiveBounds(t *testing.T) {
now := time.Now()
data := []MetricDataPoint{
{Timestamp: now.Add(-2 * time.Hour), Value: 1},
{Timestamp: now.Add(-1 * time.Hour), Value: 2},
{Timestamp: now, Value: 3},
}
filtered := filterByWindow(data, now.Add(-2*time.Hour), now)
if len(filtered) != 3 {
t.Fatalf("expected inclusive bounds to include all points, got %d", len(filtered))
}
}