mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
- Add persistent volume mounts for Go/npm caches (faster rebuilds) - Add shell config with helpful aliases and custom prompt - Add comprehensive devcontainer documentation - Add pre-commit hooks for Go formatting and linting - Use go-version-file in CI workflows instead of hardcoded versions - Simplify docker compose commands with --wait flag - Add gitignore entries for devcontainer auth files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
441 lines
11 KiB
Go
441 lines
11 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)
|
|
}
|
|
}
|
|
}
|