// ABOUTME: Tests for the summary accumulator that tracks metrics across a run. // ABOUTME: Validates stats computation (peak/avg/P95), process peak tracking, and edge cases. package summary import ( "testing" "time" "edp.buildth.ing/DevFW-CICD/forgejo-runner-optimiser/internal/metrics" ) func TestAccumulator_NoSamples(t *testing.T) { acc := NewAccumulator(5) result := acc.Summarize() if result != nil { t.Errorf("expected nil summary for no samples, got %+v", result) } } func TestAccumulator_SingleSample(t *testing.T) { acc := NewAccumulator(5) acc.Add(&metrics.SystemMetrics{ Timestamp: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), CPU: metrics.CPUMetrics{TotalPercent: 42.5}, Memory: metrics.MemoryMetrics{ UsedBytes: 1000, UsedPercent: 50.0, }, }) s := acc.Summarize() if s == nil { t.Fatal("expected non-nil summary") } // With a single sample, peak=avg=p95 if s.CPUTotal.Peak != 42.5 { t.Errorf("CPU peak: got %f, want 42.5", s.CPUTotal.Peak) } if s.CPUTotal.Avg != 42.5 { t.Errorf("CPU avg: got %f, want 42.5", s.CPUTotal.Avg) } if s.CPUTotal.P95 != 42.5 { t.Errorf("CPU p95: got %f, want 42.5", s.CPUTotal.P95) } if s.MemUsedBytes.Peak != 1000 { t.Errorf("MemUsedBytes peak: got %f, want 1000", s.MemUsedBytes.Peak) } if s.MemUsedPercent.Peak != 50.0 { t.Errorf("MemUsedPercent peak: got %f, want 50.0", s.MemUsedPercent.Peak) } } func TestAccumulator_Stats(t *testing.T) { acc := NewAccumulator(5) cpuValues := []float64{10, 20, 30, 40, 50} for i, v := range cpuValues { acc.Add(&metrics.SystemMetrics{ Timestamp: time.Date(2025, 1, 1, 0, 0, i, 0, time.UTC), CPU: metrics.CPUMetrics{TotalPercent: v}, Memory: metrics.MemoryMetrics{ UsedBytes: uint64(v * 100), UsedPercent: v, }, }) } s := acc.Summarize() if s == nil { t.Fatal("expected non-nil summary") } // Peak = max = 50 if s.CPUTotal.Peak != 50 { t.Errorf("CPU peak: got %f, want 50", s.CPUTotal.Peak) } // Avg = (10+20+30+40+50)/5 = 30 if s.CPUTotal.Avg != 30 { t.Errorf("CPU avg: got %f, want 30", s.CPUTotal.Avg) } // P95: sorted=[10,20,30,40,50], index=int(4*0.95)=int(3.8)=3, value=40 if s.CPUTotal.P95 != 40 { t.Errorf("CPU p95: got %f, want 40", s.CPUTotal.P95) } } func TestAccumulator_P95_LargerDataset(t *testing.T) { acc := NewAccumulator(5) // 20 values: 1, 2, 3, ..., 20 for i := 1; i <= 20; i++ { acc.Add(&metrics.SystemMetrics{ Timestamp: time.Date(2025, 1, 1, 0, 0, i, 0, time.UTC), CPU: metrics.CPUMetrics{TotalPercent: float64(i)}, Memory: metrics.MemoryMetrics{}, }) } s := acc.Summarize() if s == nil { t.Fatal("expected non-nil summary") } // P95: sorted=[1..20], index=int(19*0.95)=int(18.05)=18, value=19 if s.CPUTotal.P95 != 19 { t.Errorf("CPU p95: got %f, want 19", s.CPUTotal.P95) } // Avg = (1+2+...+20)/20 = 210/20 = 10.5 if s.CPUTotal.Avg != 10.5 { t.Errorf("CPU avg: got %f, want 10.5", s.CPUTotal.Avg) } } func TestAccumulator_MemoryStats(t *testing.T) { acc := NewAccumulator(5) memBytes := []uint64{1000, 2000, 3000, 4000, 5000} memPercent := []float64{10, 20, 30, 40, 50} for i := range memBytes { acc.Add(&metrics.SystemMetrics{ Timestamp: time.Date(2025, 1, 1, 0, 0, i, 0, time.UTC), CPU: metrics.CPUMetrics{TotalPercent: 0}, Memory: metrics.MemoryMetrics{ UsedBytes: memBytes[i], UsedPercent: memPercent[i], }, }) } s := acc.Summarize() if s == nil { t.Fatal("expected non-nil summary") } // MemUsedBytes: peak=5000, avg=3000, p95=4000 if s.MemUsedBytes.Peak != 5000 { t.Errorf("MemUsedBytes peak: got %f, want 5000", s.MemUsedBytes.Peak) } if s.MemUsedBytes.Avg != 3000 { t.Errorf("MemUsedBytes avg: got %f, want 3000", s.MemUsedBytes.Avg) } if s.MemUsedBytes.P95 != 4000 { t.Errorf("MemUsedBytes p95: got %f, want 4000", s.MemUsedBytes.P95) } // MemUsedPercent: peak=50, avg=30, p95=40 if s.MemUsedPercent.Peak != 50 { t.Errorf("MemUsedPercent peak: got %f, want 50", s.MemUsedPercent.Peak) } if s.MemUsedPercent.Avg != 30 { t.Errorf("MemUsedPercent avg: got %f, want 30", s.MemUsedPercent.Avg) } if s.MemUsedPercent.P95 != 40 { t.Errorf("MemUsedPercent p95: got %f, want 40", s.MemUsedPercent.P95) } } func TestAccumulator_ProcessPeaks(t *testing.T) { acc := NewAccumulator(5) // Same PID across two samples; peaks should be retained acc.Add(&metrics.SystemMetrics{ Timestamp: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), CPU: metrics.CPUMetrics{}, Memory: metrics.MemoryMetrics{}, TopCPU: []metrics.ProcessMetrics{ {PID: 1, Name: "a", CPUPercent: 10, MemRSS: 100}, }, TopMemory: []metrics.ProcessMetrics{ {PID: 1, Name: "a", CPUPercent: 10, MemRSS: 100}, }, }) acc.Add(&metrics.SystemMetrics{ Timestamp: time.Date(2025, 1, 1, 0, 0, 1, 0, time.UTC), CPU: metrics.CPUMetrics{}, Memory: metrics.MemoryMetrics{}, TopCPU: []metrics.ProcessMetrics{ {PID: 1, Name: "a", CPUPercent: 20, MemRSS: 50}, }, TopMemory: []metrics.ProcessMetrics{ {PID: 1, Name: "a", CPUPercent: 5, MemRSS: 200}, }, }) s := acc.Summarize() if s == nil { t.Fatal("expected non-nil summary") } // Should find PID 1 with peak CPU=20, peak mem=200 found := false for _, p := range s.TopCPUProcesses { if p.PID == 1 { found = true if p.PeakCPU != 20 { t.Errorf("PeakCPU: got %f, want 20", p.PeakCPU) } if p.PeakMem != 200 { t.Errorf("PeakMem: got %d, want 200", p.PeakMem) } } } if !found { t.Error("PID 1 not found in TopCPUProcesses") } } func TestAccumulator_ProcessPeaks_TopN(t *testing.T) { acc := NewAccumulator(2) // Only top 2 acc.Add(&metrics.SystemMetrics{ Timestamp: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), CPU: metrics.CPUMetrics{}, Memory: metrics.MemoryMetrics{}, TopCPU: []metrics.ProcessMetrics{ {PID: 1, Name: "low", CPUPercent: 10, MemRSS: 100}, {PID: 2, Name: "mid", CPUPercent: 50, MemRSS: 500}, {PID: 3, Name: "high", CPUPercent: 90, MemRSS: 900}, }, TopMemory: []metrics.ProcessMetrics{ {PID: 1, Name: "low", CPUPercent: 10, MemRSS: 100}, {PID: 2, Name: "mid", CPUPercent: 50, MemRSS: 500}, {PID: 3, Name: "high", CPUPercent: 90, MemRSS: 900}, }, }) s := acc.Summarize() if s == nil { t.Fatal("expected non-nil summary") } // TopCPUProcesses should have at most 2 entries, sorted by PeakCPU descending if len(s.TopCPUProcesses) != 2 { t.Fatalf("TopCPUProcesses length: got %d, want 2", len(s.TopCPUProcesses)) } if s.TopCPUProcesses[0].PeakCPU != 90 { t.Errorf("TopCPU[0] PeakCPU: got %f, want 90", s.TopCPUProcesses[0].PeakCPU) } if s.TopCPUProcesses[1].PeakCPU != 50 { t.Errorf("TopCPU[1] PeakCPU: got %f, want 50", s.TopCPUProcesses[1].PeakCPU) } // TopMemProcesses should have at most 2 entries, sorted by PeakMem descending if len(s.TopMemProcesses) != 2 { t.Fatalf("TopMemProcesses length: got %d, want 2", len(s.TopMemProcesses)) } if s.TopMemProcesses[0].PeakMem != 900 { t.Errorf("TopMem[0] PeakMem: got %d, want 900", s.TopMemProcesses[0].PeakMem) } if s.TopMemProcesses[1].PeakMem != 500 { t.Errorf("TopMem[1] PeakMem: got %d, want 500", s.TopMemProcesses[1].PeakMem) } } func TestAccumulator_ProcessPeaks_Dedup(t *testing.T) { acc := NewAccumulator(5) // A process appears in both TopCPU and TopMemory acc.Add(&metrics.SystemMetrics{ Timestamp: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), CPU: metrics.CPUMetrics{}, Memory: metrics.MemoryMetrics{}, TopCPU: []metrics.ProcessMetrics{ {PID: 1, Name: "proc", CPUPercent: 80, MemRSS: 100}, }, TopMemory: []metrics.ProcessMetrics{ {PID: 1, Name: "proc", CPUPercent: 30, MemRSS: 500}, }, }) s := acc.Summarize() if s == nil { t.Fatal("expected non-nil summary") } // The internal process map should have merged the peaks // PeakCPU should be 80 (from TopCPU), PeakMem should be 500 (from TopMemory) for _, p := range s.TopCPUProcesses { if p.PID == 1 { if p.PeakCPU != 80 { t.Errorf("PeakCPU: got %f, want 80", p.PeakCPU) } if p.PeakMem != 500 { t.Errorf("PeakMem: got %d, want 500", p.PeakMem) } } } } func TestAccumulator_SampleCount(t *testing.T) { acc := NewAccumulator(5) if acc.SampleCount() != 0 { t.Errorf("initial SampleCount: got %d, want 0", acc.SampleCount()) } for i := 0; i < 3; i++ { acc.Add(&metrics.SystemMetrics{ Timestamp: time.Date(2025, 1, 1, 0, 0, i, 0, time.UTC), CPU: metrics.CPUMetrics{}, Memory: metrics.MemoryMetrics{}, }) } if acc.SampleCount() != 3 { t.Errorf("SampleCount after 3 adds: got %d, want 3", acc.SampleCount()) } } func TestAccumulator_Duration(t *testing.T) { acc := NewAccumulator(5) start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) end := time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC) // 60 seconds later acc.Add(&metrics.SystemMetrics{ Timestamp: start, CPU: metrics.CPUMetrics{}, Memory: metrics.MemoryMetrics{}, }) acc.Add(&metrics.SystemMetrics{ Timestamp: end, CPU: metrics.CPUMetrics{}, Memory: metrics.MemoryMetrics{}, }) s := acc.Summarize() if s == nil { t.Fatal("expected non-nil summary") } if !s.StartTime.Equal(start) { t.Errorf("StartTime: got %v, want %v", s.StartTime, start) } if s.DurationSeconds != 60 { t.Errorf("DurationSeconds: got %f, want 60", s.DurationSeconds) } } func TestAccumulator_AllPercentiles(t *testing.T) { acc := NewAccumulator(5) // 20 values: 1, 2, 3, ..., 20 for i := 1; i <= 20; i++ { acc.Add(&metrics.SystemMetrics{ Timestamp: time.Date(2025, 1, 1, 0, 0, i, 0, time.UTC), CPU: metrics.CPUMetrics{TotalPercent: float64(i)}, Memory: metrics.MemoryMetrics{}, }) } s := acc.Summarize() if s == nil { t.Fatal("expected non-nil summary") } // Peak = 20 if s.CPUTotal.Peak != 20 { t.Errorf("CPU peak: got %f, want 20", s.CPUTotal.Peak) } // P99: index=int(19*0.99)=int(18.81)=18, value=19 if s.CPUTotal.P99 != 19 { t.Errorf("CPU p99: got %f, want 19", s.CPUTotal.P99) } // P95: index=int(19*0.95)=int(18.05)=18, value=19 if s.CPUTotal.P95 != 19 { t.Errorf("CPU p95: got %f, want 19", s.CPUTotal.P95) } // P75: index=int(19*0.75)=int(14.25)=14, value=15 if s.CPUTotal.P75 != 15 { t.Errorf("CPU p75: got %f, want 15", s.CPUTotal.P75) } // P50: index=int(19*0.50)=int(9.5)=9, value=10 if s.CPUTotal.P50 != 10 { t.Errorf("CPU p50: got %f, want 10", s.CPUTotal.P50) } // Avg = (1+2+...+20)/20 = 210/20 = 10.5 if s.CPUTotal.Avg != 10.5 { t.Errorf("CPU avg: got %f, want 10.5", s.CPUTotal.Avg) } } func TestAccumulator_ContainerMetrics(t *testing.T) { acc := NewAccumulator(5) // Add samples with container metrics (HasDelta=true to indicate valid CPU measurements) for i := 1; i <= 5; i++ { acc.Add(&metrics.SystemMetrics{ Timestamp: time.Date(2025, 1, 1, 0, 0, i, 0, time.UTC), CPU: metrics.CPUMetrics{TotalPercent: float64(i * 10)}, Memory: metrics.MemoryMetrics{}, Cgroups: map[string]*metrics.CgroupMetrics{ "container-a": { Name: "container-a", CPU: metrics.CgroupCPUMetrics{UsedCores: float64(i), HasDelta: true}, Memory: metrics.CgroupMemoryMetrics{ TotalRSSBytes: uint64(i * 1000), }, }, "container-b": { Name: "container-b", CPU: metrics.CgroupCPUMetrics{UsedCores: float64(i * 2), HasDelta: true}, Memory: metrics.CgroupMemoryMetrics{ TotalRSSBytes: uint64(i * 2000), }, }, }, }) } s := acc.Summarize() if s == nil { t.Fatal("expected non-nil summary") } // Should have 2 containers if len(s.Containers) != 2 { t.Fatalf("Containers length: got %d, want 2", len(s.Containers)) } // Containers should be sorted by name if s.Containers[0].Name != "container-a" { t.Errorf("Containers[0].Name: got %s, want container-a", s.Containers[0].Name) } if s.Containers[1].Name != "container-b" { t.Errorf("Containers[1].Name: got %s, want container-b", s.Containers[1].Name) } // Container A: CPU cores [1,2,3,4,5], peak=5, avg=3 containerA := s.Containers[0] if containerA.CPUCores.Peak != 5 { t.Errorf("container-a CPUCores.Peak: got %f, want 5", containerA.CPUCores.Peak) } if containerA.CPUCores.Avg != 3 { t.Errorf("container-a CPUCores.Avg: got %f, want 3", containerA.CPUCores.Avg) } // Memory bytes [1000,2000,3000,4000,5000], peak=5000, avg=3000 if containerA.MemoryBytes.Peak != 5000 { t.Errorf("container-a MemoryBytes.Peak: got %f, want 5000", containerA.MemoryBytes.Peak) } if containerA.MemoryBytes.Avg != 3000 { t.Errorf("container-a MemoryBytes.Avg: got %f, want 3000", containerA.MemoryBytes.Avg) } // Container B: CPU cores [2,4,6,8,10], peak=10, avg=6 containerB := s.Containers[1] if containerB.CPUCores.Peak != 10 { t.Errorf("container-b CPUCores.Peak: got %f, want 10", containerB.CPUCores.Peak) } if containerB.CPUCores.Avg != 6 { t.Errorf("container-b CPUCores.Avg: got %f, want 6", containerB.CPUCores.Avg) } } func TestAccumulator_ContainerMetrics_NoContainers(t *testing.T) { acc := NewAccumulator(5) acc.Add(&metrics.SystemMetrics{ Timestamp: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), CPU: metrics.CPUMetrics{TotalPercent: 50}, Memory: metrics.MemoryMetrics{}, Cgroups: nil, // No containers }) s := acc.Summarize() if s == nil { t.Fatal("expected non-nil summary") } if len(s.Containers) != 0 { t.Errorf("Containers length: got %d, want 0", len(s.Containers)) } } func TestAccumulator_ContainerMetrics_PartialSamples(t *testing.T) { acc := NewAccumulator(5) // First sample: only container-a acc.Add(&metrics.SystemMetrics{ Timestamp: time.Date(2025, 1, 1, 0, 0, 1, 0, time.UTC), CPU: metrics.CPUMetrics{}, Memory: metrics.MemoryMetrics{}, Cgroups: map[string]*metrics.CgroupMetrics{ "container-a": { Name: "container-a", CPU: metrics.CgroupCPUMetrics{UsedCores: 1, HasDelta: true}, Memory: metrics.CgroupMemoryMetrics{TotalRSSBytes: 1000}, }, }, }) // Second sample: both containers acc.Add(&metrics.SystemMetrics{ Timestamp: time.Date(2025, 1, 1, 0, 0, 2, 0, time.UTC), CPU: metrics.CPUMetrics{}, Memory: metrics.MemoryMetrics{}, Cgroups: map[string]*metrics.CgroupMetrics{ "container-a": { Name: "container-a", CPU: metrics.CgroupCPUMetrics{UsedCores: 2, HasDelta: true}, Memory: metrics.CgroupMemoryMetrics{TotalRSSBytes: 2000}, }, "container-b": { Name: "container-b", CPU: metrics.CgroupCPUMetrics{UsedCores: 5, HasDelta: true}, Memory: metrics.CgroupMemoryMetrics{TotalRSSBytes: 5000}, }, }, }) s := acc.Summarize() if s == nil { t.Fatal("expected non-nil summary") } // Should have 2 containers if len(s.Containers) != 2 { t.Fatalf("Containers length: got %d, want 2", len(s.Containers)) } // Container A: 2 samples [1,2] containerA := s.Containers[0] if containerA.CPUCores.Peak != 2 { t.Errorf("container-a CPUCores.Peak: got %f, want 2", containerA.CPUCores.Peak) } if containerA.CPUCores.Avg != 1.5 { t.Errorf("container-a CPUCores.Avg: got %f, want 1.5", containerA.CPUCores.Avg) } // Container B: 1 sample [5] containerB := s.Containers[1] if containerB.CPUCores.Peak != 5 { t.Errorf("container-b CPUCores.Peak: got %f, want 5", containerB.CPUCores.Peak) } if containerB.CPUCores.Avg != 5 { t.Errorf("container-b CPUCores.Avg: got %f, want 5", containerB.CPUCores.Avg) } } func TestAccumulator_ContainerMetrics_InvalidDeltaExcluded(t *testing.T) { acc := NewAccumulator(5) // Sample 1: no valid CPU delta (first sample / underflow) — should be excluded from CPU stats acc.Add(&metrics.SystemMetrics{ Timestamp: time.Date(2025, 1, 1, 0, 0, 1, 0, time.UTC), CPU: metrics.CPUMetrics{}, Memory: metrics.MemoryMetrics{}, Cgroups: map[string]*metrics.CgroupMetrics{ "runner": { Name: "runner", CPU: metrics.CgroupCPUMetrics{UsedCores: 0, HasDelta: false}, Memory: metrics.CgroupMemoryMetrics{TotalRSSBytes: 1000}, }, }, }) // Samples 2-4: valid deltas for i := 2; i <= 4; i++ { acc.Add(&metrics.SystemMetrics{ Timestamp: time.Date(2025, 1, 1, 0, 0, i, 0, time.UTC), CPU: metrics.CPUMetrics{}, Memory: metrics.MemoryMetrics{}, Cgroups: map[string]*metrics.CgroupMetrics{ "runner": { Name: "runner", CPU: metrics.CgroupCPUMetrics{UsedCores: float64(i), HasDelta: true}, Memory: metrics.CgroupMemoryMetrics{TotalRSSBytes: uint64(i * 1000)}, }, }, }) } s := acc.Summarize() if s == nil { t.Fatal("expected non-nil summary") } if len(s.Containers) != 1 { t.Fatalf("Containers length: got %d, want 1", len(s.Containers)) } runner := s.Containers[0] // CPU should only include samples 2,3,4 (values 2,3,4) — NOT the invalid zero // Peak=4, Avg=3, P50=3 if runner.CPUCores.Peak != 4 { t.Errorf("CPUCores.Peak: got %f, want 4", runner.CPUCores.Peak) } if runner.CPUCores.Avg != 3 { t.Errorf("CPUCores.Avg: got %f, want 3", runner.CPUCores.Avg) } if runner.CPUCores.P50 != 3 { t.Errorf("CPUCores.P50: got %f, want 3", runner.CPUCores.P50) } // Memory should include all 4 samples (memory is always valid) // Values: 1000, 2000, 3000, 4000 if runner.MemoryBytes.Peak != 4000 { t.Errorf("MemoryBytes.Peak: got %f, want 4000", runner.MemoryBytes.Peak) } if runner.MemoryBytes.Avg != 2500 { t.Errorf("MemoryBytes.Avg: got %f, want 2500", runner.MemoryBytes.Avg) } }