Pulse/internal/ai/context/trends_test.go
rcourtman 9e339957c6 fix: Update runtime config when toggling Docker update actions setting
The DisableDockerUpdateActions setting was being saved to disk but not
updated in h.config, causing the UI toggle to appear to revert on page
refresh since the API returned the stale runtime value.

Related to #1023
2026-01-03 11:14:17 +00:00

605 lines
16 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)
}
}
// TestComputeTrend_ShortTimeSpanBlip tests that a small fluctuation
// over a very short time span (like 1 minute) doesn't get extrapolated
// to an absurd daily rate like 700%/day
func TestComputeTrend_ShortTimeSpanBlip(t *testing.T) {
// This simulates the exact bug: homepage-docker goes from 24.8% to 25.2%
// over 1 minute (3 data points), but was being reported as 708%/day growth
now := time.Now()
points := []MetricPoint{
{Value: 24.8, Timestamp: now.Add(-2 * time.Minute)},
{Value: 25.0, Timestamp: now.Add(-1 * time.Minute)},
{Value: 25.2, Timestamp: now}, // Only 0.4% change total
}
trend := ComputeTrend(points, "memory", 24*time.Hour)
// With only 2 minutes of data, we should NOT extrapolate to crazy daily rates
// The observed change is only 0.4%, so a 700% daily rate is nonsense
if trend.RatePerDay > 50 {
t.Errorf("Short time span blip should not extrapolate to %f%%/day (expected < 50)", trend.RatePerDay)
}
// Confidence should be low for such short time spans
if trend.Confidence > 0.5 {
t.Errorf("Expected low confidence for 2-minute span, got %.2f", trend.Confidence)
}
}
// TestComputeTrend_PercentageCapping tests that percentage metrics (0-100)
// have their growth rates capped to physically possible limits
func TestComputeTrend_PercentageCapping(t *testing.T) {
// Even with a long time span, if the raw rate comes out absurdly high
// (which shouldn't happen with good data, but let's test the cap)
now := time.Now()
// Create data that would naively produce a >100%/day rate
// 5 points over 2 hours with aggressive growth
points := make([]MetricPoint, 5)
for i := 0; i < 5; i++ {
points[i] = MetricPoint{
Value: 20 + float64(i)*10, // 20, 30, 40, 50, 60
Timestamp: now.Add(time.Duration(-4+i) * 30 * time.Minute), // 30 min apart
}
}
trend := ComputeTrend(points, "memory", 24*time.Hour)
// For a percentage metric, rate should be capped at 100%/day max
if trend.RatePerDay > 100 {
t.Errorf("Percentage metric should be capped at 100%%/day, got %.2f", trend.RatePerDay)
}
}
// TestComputeTrend_MediumTimeSpan tests that 10-60 minutes of data
// gets moderate rate capping but isn't completely zeroed out
func TestComputeTrend_MediumTimeSpan(t *testing.T) {
now := time.Now()
// 30 minutes of data with steady growth
points := make([]MetricPoint, 7)
for i := 0; i < 7; i++ {
points[i] = MetricPoint{
Value: 30 + float64(i)*1.5, // Growing ~10% over 30 min
Timestamp: now.Add(time.Duration(-30+i*5) * time.Minute),
}
}
trend := ComputeTrend(points, "cpu", 24*time.Hour)
// Rate should be present (not zeroed) but reasonable
if trend.RatePerHour == 0 {
t.Errorf("Medium time span should have non-zero hourly rate")
}
// But daily extrapolation should be constrained
observedChange := 1.5 * 6 // ~9% change
if trend.RatePerDay > observedChange*15 {
t.Errorf("Daily rate %.2f should not vastly exceed observed change %.2f",
trend.RatePerDay, observedChange)
}
}
// TestComputeTrend_LongTimeSpanNoChange tests that with 24h of data
// and minimal change, we get stable (not growing) trend
func TestComputeTrend_LongTimeSpanNoChange(t *testing.T) {
now := time.Now()
// 24 hours of stable data at ~25%
points := make([]MetricPoint, 24)
for i := 0; i < 24; i++ {
// Very small oscillation around 25%
points[i] = MetricPoint{
Value: 25.0 + float64(i%2)*0.2, // 25.0, 25.2, 25.0, 25.2...
Timestamp: now.Add(time.Duration(-24+i) * time.Hour),
}
}
trend := ComputeTrend(points, "memory", 24*time.Hour)
if trend.Direction == TrendGrowing {
t.Errorf("Stable oscillating data should not be classified as Growing")
}
// Rate should be tiny
if trend.RatePerDay > 1 || trend.RatePerDay < -1 {
t.Errorf("Stable data should have near-zero rate, got %.2f/day", trend.RatePerDay)
}
}
// ========================================
// intToString tests
// ========================================
func TestIntToString(t *testing.T) {
tests := []struct {
input int
expected string
}{
{0, "0"},
{1, "1"},
{9, "9"},
{10, "10"},
{123, "123"},
{1000, "1000"},
{-1, "-1"},
{-99, "-99"},
{-123, "-123"},
}
for _, tt := range tests {
result := intToString(tt.input)
if result != tt.expected {
t.Errorf("intToString(%d) = %q, want %q", tt.input, result, tt.expected)
}
}
}
// ========================================
// floatToString tests
// ========================================
func TestFloatToString(t *testing.T) {
tests := []struct {
name string
value float64
precision int
expected string
}{
{"zero precision positive", 5.7, 0, "6"},
{"zero precision negative", -5.7, 0, "-6"},
{"one precision", 5.43, 1, "5.4"}, // 5.43 rounds down to 5.4
{"one precision round up", 5.48, 1, "5.5"},
{"two precision", 3.14159, 2, "3.14"},
{"three precision", 3.14159, 3, "3.142"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := floatToString(tt.value, tt.precision)
if result != tt.expected {
t.Errorf("floatToString(%.4f, %d) = %q, want %q", tt.value, tt.precision, result, tt.expected)
}
})
}
}
// ========================================
// trimTrailingZeros tests
// ========================================
func TestTrimTrailingZeros(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"", ""},
{"123", "123"},
{"12.00", "12"},
{"12.30", "12.3"},
{"12.34", "12.34"},
{"100.0", "100"},
{"100.100", "100.1"},
{"0.00", "0"},
{"0.50", "0.5"},
}
for _, tt := range tests {
result := trimTrailingZeros(tt.input)
if result != tt.expected {
t.Errorf("trimTrailingZeros(%q) = %q, want %q", tt.input, result, tt.expected)
}
}
}
func TestComputeStats_Empty(t *testing.T) {
stats := computeStats([]MetricPoint{})
if stats.Count != 0 {
t.Errorf("Expected count 0, got %d", stats.Count)
}
}
func TestLinearRegression_ZeroDenominator(t *testing.T) {
now := time.Now()
points := []MetricPoint{
{Value: 10, Timestamp: now},
{Value: 20, Timestamp: now}, // Same timestamp
}
result := linearRegression(points)
if result.R2 != 0 {
t.Errorf("Expected R2 0 for zero denominator, got %f", result.R2)
}
}
func TestClassifyTrend_HighMean(t *testing.T) {
// For mean > 100, threshold is mean * 0.005
// If mean = 1000, threshold = 5.0/hr
// Slope 6.0/hr should be growing
slope := 6.0 / 3600
mean := 1000.0
stddev := 1.0
direction := classifyTrend(slope, mean, stddev)
if direction != TrendGrowing {
t.Errorf("Expected TrendGrowing for slope 6.0/hr with mean 1000, got %s", direction)
}
// Slope 4.0/hr should be stable
direction = classifyTrend(4.0/3600, mean, stddev)
if direction != TrendStable {
t.Errorf("Expected TrendStable for slope 4.0/hr with mean 1000, got %s", direction)
}
}
func TestComputePercentiles_Edge(t *testing.T) {
points := []MetricPoint{
{Value: 10},
{Value: 20},
}
// Test invalid percentiles
res := ComputePercentiles(points, -1, 101)
if len(res) != 0 {
t.Errorf("Expected 0 results for invalid percentiles, got %d", len(res))
}
// Test empty points
res = ComputePercentiles([]MetricPoint{}, 50)
if len(res) != 0 {
t.Errorf("Expected 0 results for empty points, got %d", len(res))
}
// Test single point
res = ComputePercentiles([]MetricPoint{{Value: 100}}, 50)
if res[50] != 100 {
t.Errorf("Expected 100, got %f", res[50])
}
}
func TestTrendSummary_StableVolatile(t *testing.T) {
s1 := TrendSummary(Trend{Direction: TrendStable, DataPoints: 10})
if s1 != "stable" {
t.Errorf("Expected stable, got %s", s1)
}
s2 := TrendSummary(Trend{Direction: TrendVolatile, DataPoints: 10})
if s2 != "volatile" {
t.Errorf("Expected volatile, got %s", s2)
}
}
func TestComputeTrend_NegativeCapping(t *testing.T) {
now := time.Now()
// Negative runaway
points := []MetricPoint{
{Value: 90, Timestamp: now.Add(-15 * time.Minute)},
{Value: 10, Timestamp: now},
}
trend := ComputeTrend(points, "cpu", 24*time.Hour)
if trend.RatePerDay < -100 {
t.Errorf("Expected capped negative rate at -100, got %f", trend.RatePerDay)
}
// Test maxReasonableDaily cap with negative rate
points2 := []MetricPoint{
{Value: 50.1, Timestamp: now.Add(-50 * time.Minute)},
{Value: 50.0, Timestamp: now}, // 0.1 change in 50m = 0.12/hr = 2.88/day
}
// observedChange = 0.1, maxReasonableDaily = 1.0
trend2 := ComputeTrend(points2, "cpu", 24*time.Hour)
if trend2.RatePerDay < -1.000001 {
t.Errorf("Expected capped negative rate at ~ -1.0, got %f", trend2.RatePerDay)
}
}
func TestLinearRegression_SinglePoint(t *testing.T) {
res := linearRegression([]MetricPoint{{Value: 10}})
if res.Slope != 0 {
t.Error("Expected 0 slope for single point")
}
}
func TestFormatTrendLine_Invalid(t *testing.T) {
res := formatTrendLine("cpu", Trend{Direction: TrendDirection("invalid"), DataPoints: 10})
if res != "" {
t.Error("Expected empty string for invalid direction")
}
}
func TestComputeTrend_ExtremeShort(t *testing.T) {
now := time.Now()
points := []MetricPoint{
{Value: 10, Timestamp: now.Add(-1 * time.Minute)},
{Value: 20, Timestamp: now},
}
// actualSpan = 1m < minSpanForHourlyRate (10m)
trend := ComputeTrend(points, "cpu", time.Hour)
if trend.RatePerHour != 0 {
t.Errorf("Expected 0 RatePerHour for extremely short span, got %f", trend.RatePerHour)
}
// Test 100% cap
points2 := []MetricPoint{
{Value: 10, Timestamp: now.Add(-15 * time.Minute)},
{Value: 90, Timestamp: now},
}
// change = 80 in 15 mins = 320/hr = 7680/day
trend2 := ComputeTrend(points2, "cpu", 24*time.Hour)
if trend2.RatePerDay > 100 {
t.Errorf("Expected capped rate at 100, got %f", trend2.RatePerDay)
}
}
func TestClassifyTrend_Volatile(t *testing.T) {
// mean = 10, stddev = 4. 4/10 = 0.4 > 0.3
direction := classifyTrend(0, 10.0, 4.0)
if direction != TrendVolatile {
t.Errorf("Expected TrendVolatile, got %s", direction)
}
// mean = 0, should not be volatile based on ratio
direction = classifyTrend(0, 0, 4.0)
if direction != TrendStable {
t.Errorf("Expected TrendStable for mean 0, got %s", direction)
}
}
func TestComputePercentiles_Large(t *testing.T) {
points := make([]MetricPoint, 10)
for i := range points {
points[i].Value = float64(i)
}
// idx = 1.0 * 9 = 9. lower=9, upper=9. values[9] = 9.
res := ComputePercentiles(points, 100)
if res[100] != 9 {
t.Errorf("Expected 9, got %f", res[100])
}
}