Pulse/internal/ai/context/trends_test.go
rcourtman 3fdf753a5b Enhance devcontainer and CI workflows
- 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>
2026-01-01 22:29:15 +00:00

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)
}
}
}