diff --git a/cmd/status/metrics_health_test.go b/cmd/status/metrics_health_test.go index e9c9205..b88df18 100644 --- a/cmd/status/metrics_health_test.go +++ b/cmd/status/metrics_health_test.go @@ -86,20 +86,14 @@ func TestColorizeTempThresholds(t *testing.T) { } func TestColorizeTempStyleRanges(t *testing.T) { - // Test that different temperature ranges use different styles - // We can't easily test the exact style applied, but we can verify - // the function returns consistent results for each range - normalTemp := colorizeTemp(40.0) warningTemp := colorizeTemp(65.0) dangerTemp := colorizeTemp(85.0) - // All should be non-empty and contain the formatted value if normalTemp == "" || warningTemp == "" || dangerTemp == "" { t.Fatal("colorizeTemp should not return empty strings") } - // Verify formatting precision (one decimal place) if !strings.Contains(normalTemp, "40.0") { t.Errorf("normal temp should contain '40.0', got: %s", normalTemp) } @@ -110,3 +104,93 @@ func TestColorizeTempStyleRanges(t *testing.T) { t.Errorf("danger temp should contain '85.0', got: %s", dangerTemp) } } + +func TestCalculateHealthScoreEdgeCases(t *testing.T) { + tests := []struct { + name string + cpu CPUStatus + mem MemoryStatus + disks []DiskStatus + diskIO DiskIOStatus + thermal ThermalStatus + wantMin int + wantMax int + }{ + { + name: "all metrics at normal threshold", + cpu: CPUStatus{Usage: 30.0}, + mem: MemoryStatus{UsedPercent: 50.0}, + disks: []DiskStatus{{UsedPercent: 70.0}}, + diskIO: DiskIOStatus{ReadRate: 25.0, WriteRate: 25.0}, + thermal: ThermalStatus{CPUTemp: 60.0}, + wantMin: 95, + wantMax: 100, + }, + { + name: "memory pressure warning only", + cpu: CPUStatus{Usage: 10.0}, + mem: MemoryStatus{UsedPercent: 40.0, Pressure: "warn"}, + disks: []DiskStatus{{UsedPercent: 40.0}}, + diskIO: DiskIOStatus{ReadRate: 5.0, WriteRate: 5.0}, + thermal: ThermalStatus{CPUTemp: 40.0}, + wantMin: 90, + wantMax: 100, + }, + { + name: "empty disks array", + cpu: CPUStatus{Usage: 10.0}, + mem: MemoryStatus{UsedPercent: 30.0}, + disks: []DiskStatus{}, + diskIO: DiskIOStatus{ReadRate: 5.0, WriteRate: 5.0}, + thermal: ThermalStatus{CPUTemp: 40.0}, + wantMin: 95, + wantMax: 100, + }, + { + name: "zero thermal data", + cpu: CPUStatus{Usage: 10.0}, + mem: MemoryStatus{UsedPercent: 30.0}, + disks: []DiskStatus{{UsedPercent: 40.0}}, + diskIO: DiskIOStatus{ReadRate: 5.0, WriteRate: 5.0}, + thermal: ThermalStatus{CPUTemp: 0}, + wantMin: 95, + wantMax: 100, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + score, _ := calculateHealthScore(tt.cpu, tt.mem, tt.disks, tt.diskIO, tt.thermal) + if score < tt.wantMin || score > tt.wantMax { + t.Errorf("calculateHealthScore() = %d, want range [%d, %d]", score, tt.wantMin, tt.wantMax) + } + }) + } +} + +func TestFormatUptimeEdgeCases(t *testing.T) { + tests := []struct { + name string + secs uint64 + want string + }{ + {"zero seconds", 0, "0m"}, + {"59 seconds", 59, "0m"}, + {"one minute exact", 60, "1m"}, + {"59 minutes 59 seconds", 3599, "59m"}, + {"one hour exact", 3600, "1h 0m"}, + {"one day exact", 86400, "1d 0h"}, + {"one day one hour", 90000, "1d 1h"}, + {"multiple days no hours", 172800, "2d 0h"}, + {"large uptime", 31536000, "365d 0h"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatUptime(tt.secs) + if got != tt.want { + t.Errorf("formatUptime(%d) = %q, want %q", tt.secs, got, tt.want) + } + }) + } +} diff --git a/cmd/status/view_test.go b/cmd/status/view_test.go index 9fc7ca9..ba67327 100644 --- a/cmd/status/view_test.go +++ b/cmd/status/view_test.go @@ -1,6 +1,9 @@ package main -import "testing" +import ( + "strings" + "testing" +) func TestFormatRate(t *testing.T) { tests := []struct { @@ -368,3 +371,610 @@ func TestDiskLabel(t *testing.T) { }) } } + +func TestParseInt(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + // Basic integers. + {"simple number", "123", 123}, + {"zero", "0", 0}, + {"single digit", "5", 5}, + + // With whitespace. + {"leading space", " 42", 42}, + {"trailing space", "42 ", 42}, + {"both spaces", " 42 ", 42}, + + // With non-numeric padding. + {"leading @", "@60", 60}, + {"trailing Hz", "120Hz", 120}, + {"both padding", "@60Hz", 60}, + + // Decimals (truncated to int). + {"decimal", "60.00", 60}, + {"decimal with suffix", "119.88hz", 119}, + + // Edge cases. + {"empty string", "", 0}, + {"only spaces", " ", 0}, + {"no digits", "abc", 0}, + {"negative strips sign", "-5", 5}, // Strips non-numeric prefix. + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseInt(tt.input) + if got != tt.want { + t.Errorf("parseInt(%q) = %d, want %d", tt.input, got, tt.want) + } + }) + } +} + +func TestParseRefreshRate(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + // Standard formats. + {"60Hz format", "Resolution: 1920x1080 @ 60Hz", "60Hz"}, + {"120Hz format", "Resolution: 2560x1600 @ 120Hz", "120Hz"}, + {"separated Hz", "Refresh Rate: 60 Hz", "60Hz"}, + + // Decimal refresh rates. + {"decimal Hz", "Resolution: 3840x2160 @ 59.94Hz", "59Hz"}, + {"ProMotion", "Resolution: 3456x2234 @ 120.00Hz", "120Hz"}, + + // Multiple lines — picks highest valid. + {"multiple rates", "Display 1: 60Hz\nDisplay 2: 120Hz", "120Hz"}, + + // Edge cases. + {"empty string", "", ""}, + {"no Hz found", "Resolution: 1920x1080", ""}, + {"invalid Hz value", "Rate: abcHz", ""}, + {"Hz too high filtered", "Rate: 600Hz", ""}, + + // Case insensitivity. + {"lowercase hz", "60hz", "60Hz"}, + {"uppercase HZ", "60HZ", "60Hz"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseRefreshRate(tt.input) + if got != tt.want { + t.Errorf("parseRefreshRate(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestIsNoiseInterface(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + // Noise interfaces (should return true). + {"loopback", "lo0", true}, + {"awdl", "awdl0", true}, + {"utun", "utun0", true}, + {"llw", "llw0", true}, + {"bridge", "bridge0", true}, + {"gif", "gif0", true}, + {"stf", "stf0", true}, + {"xhc", "xhc0", true}, + {"anpi", "anpi0", true}, + {"ap", "ap1", true}, + + // Real interfaces (should return false). + {"ethernet", "en0", false}, + {"wifi", "en1", false}, + {"thunderbolt", "en5", false}, + + // Case insensitivity. + {"uppercase LO", "LO0", true}, + {"mixed case Awdl", "Awdl0", true}, + + // Edge cases. + {"empty string", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isNoiseInterface(tt.input) + if got != tt.want { + t.Errorf("isNoiseInterface(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestParsePMSet(t *testing.T) { + tests := []struct { + name string + raw string + health string + cycles int + capacity int + wantLen int + wantPct float64 + wantStat string + wantTime string + }{ + { + name: "charging with time", + raw: `Now drawing from 'AC Power' + -InternalBattery-0 (id=1234) 85%; charging; 0:45 remaining present: true`, + health: "Good", + cycles: 150, + capacity: 92, + wantLen: 1, + wantPct: 85, + wantStat: "charging", + wantTime: "0:45", + }, + { + name: "discharging", + raw: `Now drawing from 'Battery Power' + -InternalBattery-0 (id=1234) 45%; discharging; 2:30 remaining present: true`, + health: "Normal", + cycles: 200, + capacity: 88, + wantLen: 1, + wantPct: 45, + wantStat: "discharging", + wantTime: "2:30", + }, + { + name: "fully charged", + raw: `Now drawing from 'AC Power' + -InternalBattery-0 (id=1234) 100%; charged; present: true`, + health: "Good", + cycles: 50, + capacity: 100, + wantLen: 1, + wantPct: 100, + wantStat: "charged", + wantTime: "", + }, + { + name: "empty output", + raw: "", + health: "", + cycles: 0, + capacity: 0, + wantLen: 0, + }, + { + name: "no battery line", + raw: "Now drawing from 'AC Power'\nNo batteries found.", + health: "", + cycles: 0, + capacity: 0, + wantLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parsePMSet(tt.raw, tt.health, tt.cycles, tt.capacity) + if len(got) != tt.wantLen { + t.Errorf("parsePMSet() returned %d batteries, want %d", len(got), tt.wantLen) + return + } + if tt.wantLen == 0 { + return + } + b := got[0] + if b.Percent != tt.wantPct { + t.Errorf("Percent = %v, want %v", b.Percent, tt.wantPct) + } + if b.Status != tt.wantStat { + t.Errorf("Status = %q, want %q", b.Status, tt.wantStat) + } + if b.TimeLeft != tt.wantTime { + t.Errorf("TimeLeft = %q, want %q", b.TimeLeft, tt.wantTime) + } + if b.Health != tt.health { + t.Errorf("Health = %q, want %q", b.Health, tt.health) + } + if b.CycleCount != tt.cycles { + t.Errorf("CycleCount = %d, want %d", b.CycleCount, tt.cycles) + } + if b.Capacity != tt.capacity { + t.Errorf("Capacity = %d, want %d", b.Capacity, tt.capacity) + } + }) + } +} + +func TestProgressBar(t *testing.T) { + tests := []struct { + name string + percent float64 + wantRune int + }{ + {"zero percent", 0, 16}, + {"negative clamped", -10, 16}, + {"low percent", 25, 16}, + {"half", 50, 16}, + {"high percent", 75, 16}, + {"full", 100, 16}, + {"over 100 clamped", 150, 16}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := progressBar(tt.percent) + if len(got) == 0 { + t.Errorf("progressBar(%v) returned empty string", tt.percent) + return + } + gotClean := stripANSI(got) + gotRuneCount := len([]rune(gotClean)) + if gotRuneCount != tt.wantRune { + t.Errorf("progressBar(%v) rune count = %d, want %d", tt.percent, gotRuneCount, tt.wantRune) + } + }) + } +} + +func TestBatteryProgressBar(t *testing.T) { + tests := []struct { + name string + percent float64 + wantRune int + }{ + {"zero percent", 0, 16}, + {"negative clamped", -10, 16}, + {"critical low", 15, 16}, + {"low", 25, 16}, + {"medium", 50, 16}, + {"high", 75, 16}, + {"full", 100, 16}, + {"over 100 clamped", 120, 16}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := batteryProgressBar(tt.percent) + if len(got) == 0 { + t.Errorf("batteryProgressBar(%v) returned empty string", tt.percent) + return + } + gotClean := stripANSI(got) + gotRuneCount := len([]rune(gotClean)) + if gotRuneCount != tt.wantRune { + t.Errorf("batteryProgressBar(%v) rune count = %d, want %d", tt.percent, gotRuneCount, tt.wantRune) + } + }) + } +} + +func TestColorizeTemp(t *testing.T) { + tests := []struct { + name string + temp float64 + }{ + {"very low", 20.0}, + {"low", 40.0}, + {"normal threshold", 55.9}, + {"at warn threshold", 56.0}, + {"warn range", 65.0}, + {"just below danger", 75.9}, + {"at danger threshold", 76.0}, + {"high", 85.0}, + {"very high", 95.0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := colorizeTemp(tt.temp) + if got == "" { + t.Errorf("colorizeTemp(%v) returned empty string", tt.temp) + } + }) + } +} + +func TestIoBar(t *testing.T) { + tests := []struct { + name string + rate float64 + }{ + {"zero", 0}, + {"very low", 5}, + {"low normal", 20}, + {"at warn threshold", 30}, + {"warn range", 50}, + {"just below danger", 79}, + {"at danger threshold", 80}, + {"high", 100}, + {"very high", 200}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ioBar(tt.rate) + if got == "" { + t.Errorf("ioBar(%v) returned empty string", tt.rate) + return + } + gotClean := stripANSI(got) + gotRuneCount := len([]rune(gotClean)) + if gotRuneCount != 5 { + t.Errorf("ioBar(%v) rune count = %d, want 5", tt.rate, gotRuneCount) + } + }) + } +} + +func TestMiniBar(t *testing.T) { + tests := []struct { + name string + percent float64 + }{ + {"zero", 0}, + {"negative", -5}, + {"low", 15}, + {"at first step", 20}, + {"mid", 50}, + {"high", 75}, + {"full", 100}, + {"over 100", 120}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := miniBar(tt.percent) + if got == "" { + t.Errorf("miniBar(%v) returned empty string", tt.percent) + return + } + gotClean := stripANSI(got) + gotRuneCount := len([]rune(gotClean)) + if gotRuneCount != 5 { + t.Errorf("miniBar(%v) rune count = %d, want 5", tt.percent, gotRuneCount) + } + }) + } +} + +func TestFormatDiskLine(t *testing.T) { + tests := []struct { + name string + label string + disk DiskStatus + }{ + { + name: "empty label defaults to DISK", + label: "", + disk: DiskStatus{UsedPercent: 50.5, Used: 100 << 30, Total: 200 << 30}, + }, + { + name: "internal disk", + label: "INTR", + disk: DiskStatus{UsedPercent: 67.2, Used: 336 << 30, Total: 500 << 30}, + }, + { + name: "external disk", + label: "EXTR1", + disk: DiskStatus{UsedPercent: 85.0, Used: 850 << 30, Total: 1000 << 30}, + }, + { + name: "low usage", + label: "INTR", + disk: DiskStatus{UsedPercent: 15.3, Used: 15 << 30, Total: 100 << 30}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatDiskLine(tt.label, tt.disk) + if got == "" { + t.Errorf("formatDiskLine(%q, ...) returned empty string", tt.label) + return + } + expectedLabel := tt.label + if expectedLabel == "" { + expectedLabel = "DISK" + } + if !contains(got, expectedLabel) { + t.Errorf("formatDiskLine(%q, ...) = %q, should contain label %q", tt.label, got, expectedLabel) + } + }) + } +} + +func TestGetScoreStyle(t *testing.T) { + tests := []struct { + name string + score int + }{ + {"critical low", 10}, + {"poor low", 25}, + {"just below fair", 39}, + {"at fair threshold", 40}, + {"fair range", 50}, + {"just below good", 59}, + {"at good threshold", 60}, + {"good range", 70}, + {"just below excellent", 74}, + {"at excellent threshold", 75}, + {"excellent range", 85}, + {"just below perfect", 89}, + {"perfect", 90}, + {"max", 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + style := getScoreStyle(tt.score) + if style.GetForeground() == nil { + t.Errorf("getScoreStyle(%d) returned style with no foreground color", tt.score) + } + }) + } +} + +func TestMaxInt(t *testing.T) { + tests := []struct { + name string + a int + b int + want int + }{ + {"a greater", 10, 5, 10}, + {"b greater", 3, 8, 8}, + {"equal", 7, 7, 7}, + {"negative a greater", -5, -10, -5}, + {"negative b greater", -10, -5, -5}, + {"zero vs positive", 0, 5, 5}, + {"zero vs negative", 0, -5, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := maxInt(tt.a, tt.b) + if got != tt.want { + t.Errorf("maxInt(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestSparkline(t *testing.T) { + tests := []struct { + name string + history []float64 + current float64 + width int + wantLen int + }{ + { + name: "empty history", + history: []float64{}, + current: 1.5, + width: 10, + wantLen: 10, + }, + { + name: "short history padded", + history: []float64{1.0, 2.0, 3.0}, + current: 3.0, + width: 10, + wantLen: 10, + }, + { + name: "exact width", + history: []float64{1.0, 2.0, 3.0, 4.0, 5.0}, + current: 5.0, + width: 5, + wantLen: 5, + }, + { + name: "history longer than width", + history: []float64{1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0}, + current: 10.0, + width: 5, + wantLen: 5, + }, + { + name: "low current value ok style", + history: []float64{1.0, 1.5, 2.0}, + current: 2.0, + width: 5, + wantLen: 5, + }, + { + name: "medium current value warn style", + history: []float64{3.0, 4.0, 5.0}, + current: 5.0, + width: 5, + wantLen: 5, + }, + { + name: "high current value danger style", + history: []float64{8.0, 9.0, 10.0}, + current: 10.0, + width: 5, + wantLen: 5, + }, + { + name: "all identical values flatline", + history: []float64{5.0, 5.0, 5.0, 5.0, 5.0}, + current: 5.0, + width: 5, + wantLen: 5, + }, + { + name: "zero width edge case", + history: []float64{1.0, 2.0, 3.0}, + current: 2.0, + width: 0, + wantLen: 0, + }, + { + name: "width of 1", + history: []float64{1.0, 2.0, 3.0}, + current: 2.0, + width: 1, + wantLen: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sparkline(tt.history, tt.current, tt.width) + if tt.width == 0 { + return + } + if got == "" { + t.Errorf("sparkline() returned empty string") + return + } + gotClean := stripANSI(got) + if len([]rune(gotClean)) != tt.wantLen { + t.Errorf("sparkline() rune length = %d, want %d", len([]rune(gotClean)), tt.wantLen) + } + }) + } +} + +func stripANSI(s string) string { + var result strings.Builder + i := 0 + for i < len(s) { + if i < len(s)-1 && s[i] == '\x1b' && s[i+1] == '[' { + i += 2 + for i < len(s) && (s[i] < 'A' || s[i] > 'Z') && (s[i] < 'a' || s[i] > 'z') { + i++ + } + if i < len(s) { + i++ + } + } else { + result.WriteByte(s[i]) + i++ + } + } + return result.String() +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsMiddle(s, substr))) +} + +func containsMiddle(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}