mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 11:30:15 +00:00
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
605 lines
16 KiB
Go
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])
|
|
}
|
|
}
|