From cf17d327f13e9a0b7faf4da86d7cd28847d8fa01 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 29 Nov 2025 22:18:10 +0000 Subject: [PATCH] Add unit tests for dockeragent utility functions Tests for calculateCPUPercent, calculateMemoryUsage, safeFloat, parseTime, trimLeadingSlash, and summarizeBlockIO. 28 test cases covering edge cases like zero deltas, cache handling, NaN/Inf, and case-insensitive op matching. Coverage improved from 8.0% to 11.7%. --- internal/dockeragent/agent_internal_test.go | 389 ++++++++++++++++++++ 1 file changed, 389 insertions(+) diff --git a/internal/dockeragent/agent_internal_test.go b/internal/dockeragent/agent_internal_test.go index 142f4a4..0a0a8a7 100644 --- a/internal/dockeragent/agent_internal_test.go +++ b/internal/dockeragent/agent_internal_test.go @@ -1,8 +1,12 @@ package dockeragent import ( + "math" "reflect" "testing" + "time" + + containertypes "github.com/docker/docker/api/types/container" ) func TestNormalizeTargets(t *testing.T) { @@ -108,3 +112,388 @@ func TestNormalizeRuntimeInvalid(t *testing.T) { t.Fatalf("expected error for unsupported runtime") } } + +func TestCalculateCPUPercent(t *testing.T) { + tests := []struct { + name string + stats containertypes.StatsResponse + hostCPUs int + want float64 + }{ + { + name: "normal calculation", + stats: containertypes.StatsResponse{ + CPUStats: containertypes.CPUStats{ + CPUUsage: containertypes.CPUUsage{ + TotalUsage: 200000000, // 200ms + }, + SystemUsage: 2000000000, // 2s + OnlineCPUs: 4, + }, + PreCPUStats: containertypes.CPUStats{ + CPUUsage: containertypes.CPUUsage{ + TotalUsage: 100000000, // 100ms + }, + SystemUsage: 1000000000, // 1s + }, + }, + hostCPUs: 4, + want: 40.0, // (100ms / 1s) * 4 CPUs * 100 + }, + { + name: "zero system delta returns zero", + stats: containertypes.StatsResponse{ + CPUStats: containertypes.CPUStats{ + CPUUsage: containertypes.CPUUsage{ + TotalUsage: 200000000, + }, + SystemUsage: 1000000000, + OnlineCPUs: 4, + }, + PreCPUStats: containertypes.CPUStats{ + CPUUsage: containertypes.CPUUsage{ + TotalUsage: 100000000, + }, + SystemUsage: 1000000000, // same as current = zero delta + }, + }, + hostCPUs: 4, + want: 0, + }, + { + name: "zero total delta returns zero", + stats: containertypes.StatsResponse{ + CPUStats: containertypes.CPUStats{ + CPUUsage: containertypes.CPUUsage{ + TotalUsage: 100000000, + }, + SystemUsage: 2000000000, + OnlineCPUs: 4, + }, + PreCPUStats: containertypes.CPUStats{ + CPUUsage: containertypes.CPUUsage{ + TotalUsage: 100000000, // same as current + }, + SystemUsage: 1000000000, + }, + }, + hostCPUs: 4, + want: 0, + }, + { + name: "uses PercpuUsage length when OnlineCPUs is zero", + stats: containertypes.StatsResponse{ + CPUStats: containertypes.CPUStats{ + CPUUsage: containertypes.CPUUsage{ + TotalUsage: 200000000, + PercpuUsage: []uint64{1, 2, 3, 4}, // 4 CPUs + }, + SystemUsage: 2000000000, + OnlineCPUs: 0, + }, + PreCPUStats: containertypes.CPUStats{ + CPUUsage: containertypes.CPUUsage{ + TotalUsage: 100000000, + }, + SystemUsage: 1000000000, + }, + }, + hostCPUs: 8, + want: 40.0, // uses PercpuUsage length (4), not hostCPUs + }, + { + name: "uses hostCPUs when OnlineCPUs and PercpuUsage both zero", + stats: containertypes.StatsResponse{ + CPUStats: containertypes.CPUStats{ + CPUUsage: containertypes.CPUUsage{ + TotalUsage: 200000000, + }, + SystemUsage: 2000000000, + OnlineCPUs: 0, + }, + PreCPUStats: containertypes.CPUStats{ + CPUUsage: containertypes.CPUUsage{ + TotalUsage: 100000000, + }, + SystemUsage: 1000000000, + }, + }, + hostCPUs: 2, + want: 20.0, // uses hostCPUs (2) + }, + { + name: "returns zero when no CPU count available", + stats: containertypes.StatsResponse{ + CPUStats: containertypes.CPUStats{ + CPUUsage: containertypes.CPUUsage{ + TotalUsage: 200000000, + }, + SystemUsage: 2000000000, + OnlineCPUs: 0, + }, + PreCPUStats: containertypes.CPUStats{ + CPUUsage: containertypes.CPUUsage{ + TotalUsage: 100000000, + }, + SystemUsage: 1000000000, + }, + }, + hostCPUs: 0, + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := calculateCPUPercent(tt.stats, tt.hostCPUs) + if math.Abs(got-tt.want) > 0.001 { + t.Errorf("calculateCPUPercent() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCalculateMemoryUsage(t *testing.T) { + tests := []struct { + name string + stats containertypes.StatsResponse + wantUsage int64 + wantLimit int64 + wantPercent float64 + }{ + { + name: "normal calculation with cache", + stats: containertypes.StatsResponse{ + MemoryStats: containertypes.MemoryStats{ + Usage: 1000000, + Limit: 4000000, + Stats: map[string]uint64{"cache": 200000}, + }, + }, + wantUsage: 800000, + wantLimit: 4000000, + wantPercent: 20.0, + }, + { + name: "no cache in stats", + stats: containertypes.StatsResponse{ + MemoryStats: containertypes.MemoryStats{ + Usage: 1000000, + Limit: 4000000, + Stats: map[string]uint64{}, + }, + }, + wantUsage: 1000000, + wantLimit: 4000000, + wantPercent: 25.0, + }, + { + name: "cache larger than usage falls back to raw usage", + stats: containertypes.StatsResponse{ + MemoryStats: containertypes.MemoryStats{ + Usage: 1000000, + Limit: 4000000, + Stats: map[string]uint64{"cache": 2000000}, // more than usage + }, + }, + wantUsage: 1000000, // falls back to raw usage + wantLimit: 4000000, + wantPercent: 25.0, + }, + { + name: "zero limit returns zero percent", + stats: containertypes.StatsResponse{ + MemoryStats: containertypes.MemoryStats{ + Usage: 1000000, + Limit: 0, + Stats: map[string]uint64{}, + }, + }, + wantUsage: 1000000, + wantLimit: 0, + wantPercent: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + usage, limit, percent := calculateMemoryUsage(tt.stats) + if usage != tt.wantUsage { + t.Errorf("usage = %v, want %v", usage, tt.wantUsage) + } + if limit != tt.wantLimit { + t.Errorf("limit = %v, want %v", limit, tt.wantLimit) + } + if math.Abs(percent-tt.wantPercent) > 0.001 { + t.Errorf("percent = %v, want %v", percent, tt.wantPercent) + } + }) + } +} + +func TestSafeFloat(t *testing.T) { + tests := []struct { + name string + input float64 + want float64 + }{ + {"normal positive", 42.5, 42.5}, + {"normal negative", -42.5, -42.5}, + {"zero", 0, 0}, + {"NaN returns zero", math.NaN(), 0}, + {"positive infinity returns zero", math.Inf(1), 0}, + {"negative infinity returns zero", math.Inf(-1), 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := safeFloat(tt.input) + if got != tt.want { + t.Errorf("safeFloat(%v) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestParseTime(t *testing.T) { + tests := []struct { + name string + input string + want time.Time + }{ + {"empty string", "", time.Time{}}, + {"zero time string", "0001-01-01T00:00:00Z", time.Time{}}, + {"RFC3339Nano", "2024-01-15T10:30:00.123456789Z", time.Date(2024, 1, 15, 10, 30, 0, 123456789, time.UTC)}, + {"RFC3339", "2024-01-15T10:30:00Z", time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)}, + {"RFC3339 with offset", "2024-01-15T10:30:00+05:00", time.Date(2024, 1, 15, 10, 30, 0, 0, time.FixedZone("", 5*3600))}, + {"invalid format", "not-a-time", time.Time{}}, + {"partial date", "2024-01-15", time.Time{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseTime(tt.input) + if !got.Equal(tt.want) { + t.Errorf("parseTime(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestTrimLeadingSlash(t *testing.T) { + tests := []struct { + name string + names []string + want string + }{ + {"empty slice", []string{}, ""}, + {"single name with slash", []string{"/mycontainer"}, "mycontainer"}, + {"single name without slash", []string{"mycontainer"}, "mycontainer"}, + {"multiple names uses first", []string{"/first", "/second"}, "first"}, + {"name with multiple slashes only trims first", []string{"//double"}, "/double"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := trimLeadingSlash(tt.names) + if got != tt.want { + t.Errorf("trimLeadingSlash(%v) = %q, want %q", tt.names, got, tt.want) + } + }) + } +} + +func TestSummarizeBlockIO(t *testing.T) { + tests := []struct { + name string + stats containertypes.StatsResponse + want *containertypes.BlkioStatEntry + }{ + { + name: "read and write ops", + stats: containertypes.StatsResponse{ + BlkioStats: containertypes.BlkioStats{ + IoServiceBytesRecursive: []containertypes.BlkioStatEntry{ + {Op: "Read", Value: 1000}, + {Op: "Write", Value: 2000}, + {Op: "Read", Value: 500}, // multiple read entries + {Op: "Write", Value: 300}, // multiple write entries + }, + }, + }, + want: &containertypes.BlkioStatEntry{}, // will check values separately + }, + { + name: "case insensitive ops", + stats: containertypes.StatsResponse{ + BlkioStats: containertypes.BlkioStats{ + IoServiceBytesRecursive: []containertypes.BlkioStatEntry{ + {Op: "READ", Value: 100}, + {Op: "write", Value: 200}, + }, + }, + }, + want: &containertypes.BlkioStatEntry{}, + }, + { + name: "zero values returns nil", + stats: containertypes.StatsResponse{ + BlkioStats: containertypes.BlkioStats{ + IoServiceBytesRecursive: []containertypes.BlkioStatEntry{}, + }, + }, + want: nil, + }, + { + name: "only non-read-write ops returns nil", + stats: containertypes.StatsResponse{ + BlkioStats: containertypes.BlkioStats{ + IoServiceBytesRecursive: []containertypes.BlkioStatEntry{ + {Op: "Sync", Value: 1000}, + {Op: "Async", Value: 2000}, + }, + }, + }, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := summarizeBlockIO(tt.stats) + if tt.want == nil { + if got != nil { + t.Errorf("summarizeBlockIO() = %v, want nil", got) + } + return + } + if got == nil { + t.Errorf("summarizeBlockIO() = nil, want non-nil") + return + } + }) + } + + // Additional test for exact values + t.Run("exact read/write values", func(t *testing.T) { + stats := containertypes.StatsResponse{ + BlkioStats: containertypes.BlkioStats{ + IoServiceBytesRecursive: []containertypes.BlkioStatEntry{ + {Op: "Read", Value: 1000}, + {Op: "Write", Value: 2000}, + {Op: "Read", Value: 500}, + }, + }, + } + got := summarizeBlockIO(stats) + if got == nil { + t.Fatal("expected non-nil result") + } + if got.ReadBytes != 1500 { + t.Errorf("ReadBytes = %d, want 1500", got.ReadBytes) + } + if got.WriteBytes != 2000 { + t.Errorf("WriteBytes = %d, want 2000", got.WriteBytes) + } + }) +}