mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 19:41:17 +00:00
504 lines
14 KiB
Go
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))
|
|
}
|
|
}
|