Pulse/internal/ai/context/trends_test.go
rcourtman 96af101c98 feat(ai): Add enriched context with historical trends and predictions
Phase 1 of Pulse AI differentiation:

- Create internal/ai/context package with types, trends, builder, formatter
- Implement linear regression for trend computation (growing/declining/stable/volatile)
- Add storage capacity predictions (predicts days until 90% and 100%)
- Wire MetricsHistory from monitor to patrol service
- Update patrol to use buildEnrichedContext instead of basic summary
- Update patrol prompt to reference trend indicators and predictions

This gives the AI awareness of historical patterns, enabling it to:
- Identify resources with concerning growth rates
- Predict capacity exhaustion before it happens
- Distinguish between stable high usage vs growing problems
- Provide more actionable, time-aware insights

All tests passing. Falls back to basic summary if metrics history unavailable.
2025-12-12 09:45:57 +00:00

250 lines
5.5 KiB
Go

package context
import (
"testing"
"time"
)
func TestComputeTrend_Growing(t *testing.T) {
// Create growing data (10% per day)
now := time.Now()
points := make([]MetricPoint, 24)
for i := 0; i < 24; i++ {
// 10% per day = ~0.417% per hour
points[i] = MetricPoint{
Value: 50 + float64(i)*0.417,
Timestamp: now.Add(time.Duration(-24+i) * time.Hour),
}
}
trend := ComputeTrend(points, "memory", 24*time.Hour)
if trend.Direction != TrendGrowing {
t.Errorf("Expected TrendGrowing, got %s", trend.Direction)
}
// Rate should be ~10% per day
if trend.RatePerDay < 8 || trend.RatePerDay > 12 {
t.Errorf("Expected rate ~10/day, got %.2f", trend.RatePerDay)
}
if trend.DataPoints != 24 {
t.Errorf("Expected 24 data points, got %d", trend.DataPoints)
}
}
func TestComputeTrend_Stable(t *testing.T) {
// Create stable data with small fluctuations
now := time.Now()
points := make([]MetricPoint, 24)
for i := 0; i < 24; i++ {
// Small random-looking variation around 50%, but no trend
offset := float64(i%3 - 1) * 0.2
points[i] = MetricPoint{
Value: 50 + offset,
Timestamp: now.Add(time.Duration(-24+i) * time.Hour),
}
}
trend := ComputeTrend(points, "cpu", 24*time.Hour)
if trend.Direction != TrendStable {
t.Errorf("Expected TrendStable, got %s (rate: %.4f/hr)", trend.Direction, trend.RatePerHour)
}
}
func TestComputeTrend_Declining(t *testing.T) {
// Create declining data
now := time.Now()
points := make([]MetricPoint, 24)
for i := 0; i < 24; i++ {
points[i] = MetricPoint{
Value: 80 - float64(i)*0.5, // -12% per day
Timestamp: now.Add(time.Duration(-24+i) * time.Hour),
}
}
trend := ComputeTrend(points, "disk", 24*time.Hour)
if trend.Direction != TrendDeclining {
t.Errorf("Expected TrendDeclining, got %s", trend.Direction)
}
}
func TestComputeTrend_Volatile(t *testing.T) {
// Create volatile data with high variance
now := time.Now()
points := make([]MetricPoint, 24)
for i := 0; i < 24; i++ {
// Alternating high/low values
value := 50.0
if i%2 == 0 {
value = 80.0
} else {
value = 20.0
}
points[i] = MetricPoint{
Value: value,
Timestamp: now.Add(time.Duration(-24+i) * time.Hour),
}
}
trend := ComputeTrend(points, "cpu", 24*time.Hour)
if trend.Direction != TrendVolatile {
t.Errorf("Expected TrendVolatile, got %s (stddev: %.2f, mean: %.2f)",
trend.Direction, trend.StdDev, trend.Average)
}
}
func TestComputeTrend_InsufficientData(t *testing.T) {
// Only one data point
points := []MetricPoint{
{Value: 50, Timestamp: time.Now()},
}
trend := ComputeTrend(points, "memory", 24*time.Hour)
if trend.Confidence != 0 {
t.Errorf("Expected 0 confidence with insufficient data, got %.2f", trend.Confidence)
}
}
func TestLinearRegression_Perfect(t *testing.T) {
// Perfect linear data: y = 2x + 10
now := time.Now()
points := make([]MetricPoint, 10)
for i := 0; i < 10; i++ {
points[i] = MetricPoint{
Value: 10 + float64(i)*2,
Timestamp: now.Add(time.Duration(i) * time.Second),
}
}
result := linearRegression(points)
// Slope should be 2 per second
if result.Slope < 1.9 || result.Slope > 2.1 {
t.Errorf("Expected slope ~2, got %.4f", result.Slope)
}
// R² should be 1 (perfect fit)
if result.R2 < 0.99 {
t.Errorf("Expected R² ~1, got %.4f", result.R2)
}
}
func TestComputePercentiles(t *testing.T) {
now := time.Now()
// Create 100 points with values 1-100
points := make([]MetricPoint, 100)
for i := 0; i < 100; i++ {
points[i] = MetricPoint{
Value: float64(i + 1),
Timestamp: now.Add(time.Duration(i) * time.Second),
}
}
percentiles := ComputePercentiles(points, 5, 50, 95)
// P5 should be ~5
if percentiles[5] < 4 || percentiles[5] > 6 {
t.Errorf("Expected P5 ~5, got %.2f", percentiles[5])
}
// P50 should be ~50
if percentiles[50] < 49 || percentiles[50] > 51 {
t.Errorf("Expected P50 ~50, got %.2f", percentiles[50])
}
// P95 should be ~95
if percentiles[95] < 94 || percentiles[95] > 96 {
t.Errorf("Expected P95 ~95, got %.2f", percentiles[95])
}
}
func TestTrendSummary(t *testing.T) {
tests := []struct {
name string
trend Trend
expected string
}{
{
name: "growing fast",
trend: Trend{
Direction: TrendGrowing,
RatePerDay: 5.5,
RatePerHour: 0.23,
DataPoints: 24,
},
expected: "growing 5.5/day",
},
{
name: "growing slow",
trend: Trend{
Direction: TrendGrowing,
RatePerDay: 0.5,
RatePerHour: 0.02,
DataPoints: 24,
},
expected: "growing 0.02/hr",
},
{
name: "stable",
trend: Trend{
Direction: TrendStable,
DataPoints: 24,
},
expected: "stable",
},
{
name: "volatile",
trend: Trend{
Direction: TrendVolatile,
DataPoints: 24,
},
expected: "volatile",
},
{
name: "insufficient data",
trend: Trend{
DataPoints: 1,
},
expected: "insufficient data",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := TrendSummary(tt.trend)
if result != tt.expected {
t.Errorf("Expected %q, got %q", tt.expected, result)
}
})
}
}
func TestComputeStats(t *testing.T) {
points := []MetricPoint{
{Value: 10},
{Value: 20},
{Value: 30},
{Value: 40},
{Value: 50},
}
stats := computeStats(points)
if stats.Count != 5 {
t.Errorf("Expected count 5, got %d", stats.Count)
}
if stats.Min != 10 {
t.Errorf("Expected min 10, got %.2f", stats.Min)
}
if stats.Max != 50 {
t.Errorf("Expected max 50, got %.2f", stats.Max)
}
if stats.Mean != 30 {
t.Errorf("Expected mean 30, got %.2f", stats.Mean)
}
}