Add unit tests for temperature utility functions
Add focused unit tests for four utility functions in temperature.go: - extractTempInput: 16 test cases for sensor value extraction - extractCoreNumber: 18 test cases for core number parsing - extractHostname: 21 test cases for URL hostname extraction - normalizeSMARTEntries: 15 test cases for SMART data normalization 70 test cases total covering type conversions, edge cases, boundary conditions, and error handling paths.
This commit is contained in:
parent
ed3c992968
commit
c2233fb3f8
1 changed files with 565 additions and 0 deletions
|
|
@ -3,11 +3,13 @@ package monitoring
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/tempproxy"
|
||||
)
|
||||
|
||||
|
|
@ -765,3 +767,566 @@ func TestParseSensorsJSON_MultipleSuperioCPUFields(t *testing.T) {
|
|||
t.Fatalf("expected cpu package temperature from 'CPU Temperature' field (52.0), got %.2f", temp.CPUPackage)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Unit tests for utility functions
|
||||
// =============================================================================
|
||||
|
||||
func TestExtractTempInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sensorMap map[string]interface{}
|
||||
wantTemp float64
|
||||
wantNaN bool
|
||||
}{
|
||||
{
|
||||
name: "float64 temp1_input",
|
||||
sensorMap: map[string]interface{}{
|
||||
"temp1_input": 45.5,
|
||||
},
|
||||
wantTemp: 45.5,
|
||||
},
|
||||
{
|
||||
name: "float64 temp2_input",
|
||||
sensorMap: map[string]interface{}{
|
||||
"temp2_input": 72.3,
|
||||
},
|
||||
wantTemp: 72.3,
|
||||
},
|
||||
{
|
||||
name: "int value converted to float64",
|
||||
sensorMap: map[string]interface{}{
|
||||
"temp1_input": 55,
|
||||
},
|
||||
wantTemp: 55.0,
|
||||
},
|
||||
{
|
||||
name: "string value parseable",
|
||||
sensorMap: map[string]interface{}{
|
||||
"temp1_input": "62.5",
|
||||
},
|
||||
wantTemp: 62.5,
|
||||
},
|
||||
{
|
||||
name: "string value non-numeric",
|
||||
sensorMap: map[string]interface{}{
|
||||
"temp1_input": "N/A",
|
||||
},
|
||||
wantNaN: true,
|
||||
},
|
||||
{
|
||||
name: "no _input suffix",
|
||||
sensorMap: map[string]interface{}{
|
||||
"temp1": 45.5,
|
||||
"temp1_max": 100.0,
|
||||
},
|
||||
wantNaN: true,
|
||||
},
|
||||
{
|
||||
name: "empty map",
|
||||
sensorMap: map[string]interface{}{},
|
||||
wantNaN: true,
|
||||
},
|
||||
{
|
||||
name: "nil map",
|
||||
sensorMap: nil,
|
||||
wantNaN: true,
|
||||
},
|
||||
{
|
||||
name: "zero temperature",
|
||||
sensorMap: map[string]interface{}{
|
||||
"temp1_input": 0.0,
|
||||
},
|
||||
wantTemp: 0.0,
|
||||
},
|
||||
{
|
||||
name: "negative temperature",
|
||||
sensorMap: map[string]interface{}{
|
||||
"temp1_input": -10.5,
|
||||
},
|
||||
wantTemp: -10.5,
|
||||
},
|
||||
{
|
||||
name: "mixed valid and invalid fields",
|
||||
sensorMap: map[string]interface{}{
|
||||
"temp1": 45.0,
|
||||
"temp1_input": 50.0,
|
||||
"temp1_max": 100.0,
|
||||
},
|
||||
wantTemp: 50.0,
|
||||
},
|
||||
{
|
||||
name: "boolean value (invalid type)",
|
||||
sensorMap: map[string]interface{}{
|
||||
"temp1_input": true,
|
||||
},
|
||||
wantNaN: true,
|
||||
},
|
||||
{
|
||||
name: "nil value",
|
||||
sensorMap: map[string]interface{}{
|
||||
"temp1_input": nil,
|
||||
},
|
||||
wantNaN: true,
|
||||
},
|
||||
{
|
||||
name: "very high temperature",
|
||||
sensorMap: map[string]interface{}{
|
||||
"temp1_input": 125.5,
|
||||
},
|
||||
wantTemp: 125.5,
|
||||
},
|
||||
{
|
||||
name: "fractional precision",
|
||||
sensorMap: map[string]interface{}{
|
||||
"temp1_input": 45.123456789,
|
||||
},
|
||||
wantTemp: 45.123456789,
|
||||
},
|
||||
{
|
||||
name: "temp_crit_input also matches",
|
||||
sensorMap: map[string]interface{}{
|
||||
"temp1_crit_input": 95.0,
|
||||
},
|
||||
wantTemp: 95.0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractTempInput(tt.sensorMap)
|
||||
|
||||
if tt.wantNaN {
|
||||
if !math.IsNaN(got) {
|
||||
t.Errorf("extractTempInput() = %v, want NaN", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if got != tt.wantTemp {
|
||||
t.Errorf("extractTempInput() = %v, want %v", got, tt.wantTemp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCoreNumber(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want int
|
||||
}{
|
||||
{"Core 0", 0},
|
||||
{"Core 1", 1},
|
||||
{"Core 10", 10},
|
||||
{"Core 99", 99},
|
||||
{"Core 127", 127},
|
||||
{"Core", 0}, // missing number
|
||||
{"Core ", 0}, // trailing space, no number
|
||||
{"core 5", 5}, // lowercase
|
||||
{"CORE 7", 7}, // uppercase
|
||||
{"Core 12", 12}, // extra space (Fields handles this)
|
||||
{"", 0}, // empty string
|
||||
{" ", 0}, // whitespace only
|
||||
{"Core abc", 0}, // non-numeric
|
||||
{"Package id 0", 0}, // last part is "0"
|
||||
{"temp1", 0}, // no spaces
|
||||
{"Core 1000", 1000}, // large core number
|
||||
{"Prefix Core 5", 5}, // core not at start
|
||||
{"Core 0 extra", 0}, // text after number - "extra" is last field
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractCoreNumber(tt.name)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractCoreNumber(%q) = %v, want %v", tt.name, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHostname(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hostURL string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "https with port",
|
||||
hostURL: "https://192.168.1.100:8006",
|
||||
want: "192.168.1.100",
|
||||
},
|
||||
{
|
||||
name: "https without port",
|
||||
hostURL: "https://192.168.1.100",
|
||||
want: "192.168.1.100",
|
||||
},
|
||||
{
|
||||
name: "http with port",
|
||||
hostURL: "http://192.168.1.100:8006",
|
||||
want: "192.168.1.100",
|
||||
},
|
||||
{
|
||||
name: "http without port",
|
||||
hostURL: "http://192.168.1.100",
|
||||
want: "192.168.1.100",
|
||||
},
|
||||
{
|
||||
name: "hostname with port",
|
||||
hostURL: "https://proxmox.local:8006",
|
||||
want: "proxmox.local",
|
||||
},
|
||||
{
|
||||
name: "hostname without port",
|
||||
hostURL: "https://proxmox.local",
|
||||
want: "proxmox.local",
|
||||
},
|
||||
{
|
||||
name: "bare IP",
|
||||
hostURL: "192.168.1.100",
|
||||
want: "192.168.1.100",
|
||||
},
|
||||
{
|
||||
name: "bare IP with port",
|
||||
hostURL: "192.168.1.100:8006",
|
||||
want: "192.168.1.100",
|
||||
},
|
||||
{
|
||||
name: "bare hostname",
|
||||
hostURL: "proxmox.local",
|
||||
want: "proxmox.local",
|
||||
},
|
||||
{
|
||||
name: "bare hostname with port",
|
||||
hostURL: "proxmox.local:8006",
|
||||
want: "proxmox.local",
|
||||
},
|
||||
{
|
||||
name: "with path",
|
||||
hostURL: "https://192.168.1.100:8006/api2/json",
|
||||
want: "192.168.1.100",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
hostURL: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "protocol only",
|
||||
hostURL: "https://",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "FQDN",
|
||||
hostURL: "https://pve1.example.com:8006",
|
||||
want: "pve1.example.com",
|
||||
},
|
||||
{
|
||||
name: "localhost",
|
||||
hostURL: "http://localhost:8006",
|
||||
want: "localhost",
|
||||
},
|
||||
{
|
||||
name: "127.0.0.1",
|
||||
hostURL: "https://127.0.0.1:8006",
|
||||
want: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "uppercase protocol not stripped",
|
||||
hostURL: "HTTPS://192.168.1.100:8006",
|
||||
want: "HTTPS", // TrimPrefix is case-sensitive, so "HTTPS:" becomes hostname part
|
||||
},
|
||||
{
|
||||
name: "trailing slash",
|
||||
hostURL: "https://192.168.1.100/",
|
||||
want: "192.168.1.100",
|
||||
},
|
||||
{
|
||||
name: "query string",
|
||||
hostURL: "https://192.168.1.100:8006/api?key=value",
|
||||
want: "192.168.1.100",
|
||||
},
|
||||
{
|
||||
name: "double protocol",
|
||||
hostURL: "https://https://192.168.1.100",
|
||||
want: "https",
|
||||
},
|
||||
{
|
||||
name: "port only",
|
||||
hostURL: ":8006",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractHostname(tt.hostURL)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractHostname(%q) = %q, want %q", tt.hostURL, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSMARTEntries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw []smartEntryRaw
|
||||
want []models.DiskTemp
|
||||
}{
|
||||
{
|
||||
name: "nil input",
|
||||
raw: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "empty slice",
|
||||
raw: []smartEntryRaw{},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single entry with all fields",
|
||||
raw: []smartEntryRaw{
|
||||
{
|
||||
Device: "/dev/sda",
|
||||
Serial: "WD-WMC1T0123456",
|
||||
WWN: "5 0014ee 2b1234567",
|
||||
Model: "WDC WD40EFRX-68N32N0",
|
||||
Type: "sata",
|
||||
Temperature: intPtr(38),
|
||||
LastUpdated: "2024-01-15T10:30:00Z",
|
||||
StandbySkipped: false,
|
||||
},
|
||||
},
|
||||
want: []models.DiskTemp{
|
||||
{
|
||||
Device: "/dev/sda",
|
||||
Serial: "WD-WMC1T0123456",
|
||||
WWN: "5 0014ee 2b1234567",
|
||||
Model: "WDC WD40EFRX-68N32N0",
|
||||
Type: "sata",
|
||||
Temperature: 38,
|
||||
LastUpdated: mustParseTime("2024-01-15T10:30:00Z"),
|
||||
StandbySkipped: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "entry with nil temperature",
|
||||
raw: []smartEntryRaw{
|
||||
{
|
||||
Device: "/dev/sdb",
|
||||
Temperature: nil,
|
||||
},
|
||||
},
|
||||
want: []models.DiskTemp{
|
||||
{
|
||||
Device: "/dev/sdb",
|
||||
Temperature: 0, // nil becomes 0
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "entry with standby skipped",
|
||||
raw: []smartEntryRaw{
|
||||
{
|
||||
Device: "/dev/sdc",
|
||||
StandbySkipped: true,
|
||||
Temperature: nil,
|
||||
},
|
||||
},
|
||||
want: []models.DiskTemp{
|
||||
{
|
||||
Device: "/dev/sdc",
|
||||
StandbySkipped: true,
|
||||
Temperature: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty device skipped",
|
||||
raw: []smartEntryRaw{
|
||||
{
|
||||
Device: "",
|
||||
Temperature: intPtr(40),
|
||||
},
|
||||
},
|
||||
want: []models.DiskTemp{},
|
||||
},
|
||||
{
|
||||
name: "whitespace-only device skipped",
|
||||
raw: []smartEntryRaw{
|
||||
{
|
||||
Device: " ",
|
||||
Temperature: intPtr(40),
|
||||
},
|
||||
},
|
||||
want: []models.DiskTemp{},
|
||||
},
|
||||
{
|
||||
name: "invalid timestamp ignored",
|
||||
raw: []smartEntryRaw{
|
||||
{
|
||||
Device: "/dev/sda",
|
||||
LastUpdated: "not-a-timestamp",
|
||||
Temperature: intPtr(42),
|
||||
},
|
||||
},
|
||||
want: []models.DiskTemp{
|
||||
{
|
||||
Device: "/dev/sda",
|
||||
Temperature: 42,
|
||||
LastUpdated: time.Time{}, // zero time
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty timestamp",
|
||||
raw: []smartEntryRaw{
|
||||
{
|
||||
Device: "/dev/sda",
|
||||
LastUpdated: "",
|
||||
Temperature: intPtr(42),
|
||||
},
|
||||
},
|
||||
want: []models.DiskTemp{
|
||||
{
|
||||
Device: "/dev/sda",
|
||||
Temperature: 42,
|
||||
LastUpdated: time.Time{},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple entries",
|
||||
raw: []smartEntryRaw{
|
||||
{Device: "/dev/sda", Temperature: intPtr(38), Type: "sata"},
|
||||
{Device: "/dev/sdb", Temperature: intPtr(40), Type: "sata"},
|
||||
{Device: "/dev/nvme0n1", Temperature: intPtr(45), Type: "nvme"},
|
||||
},
|
||||
want: []models.DiskTemp{
|
||||
{Device: "/dev/sda", Temperature: 38, Type: "sata"},
|
||||
{Device: "/dev/sdb", Temperature: 40, Type: "sata"},
|
||||
{Device: "/dev/nvme0n1", Temperature: 45, Type: "nvme"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "whitespace trimmed from fields",
|
||||
raw: []smartEntryRaw{
|
||||
{
|
||||
Device: " /dev/sda ",
|
||||
Serial: " ABC123 ",
|
||||
WWN: " 1234 ",
|
||||
Model: " Model X ",
|
||||
Type: " sata ",
|
||||
},
|
||||
},
|
||||
want: []models.DiskTemp{
|
||||
{
|
||||
Device: "/dev/sda",
|
||||
Serial: "ABC123",
|
||||
WWN: "1234",
|
||||
Model: "Model X",
|
||||
Type: "sata",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed valid and empty devices",
|
||||
raw: []smartEntryRaw{
|
||||
{Device: "/dev/sda", Temperature: intPtr(38)},
|
||||
{Device: "", Temperature: intPtr(40)},
|
||||
{Device: "/dev/sdc", Temperature: intPtr(42)},
|
||||
},
|
||||
want: []models.DiskTemp{
|
||||
{Device: "/dev/sda", Temperature: 38},
|
||||
{Device: "/dev/sdc", Temperature: 42},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zero temperature",
|
||||
raw: []smartEntryRaw{
|
||||
{Device: "/dev/sda", Temperature: intPtr(0)},
|
||||
},
|
||||
want: []models.DiskTemp{
|
||||
{Device: "/dev/sda", Temperature: 0},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "negative temperature",
|
||||
raw: []smartEntryRaw{
|
||||
{Device: "/dev/sda", Temperature: intPtr(-10)},
|
||||
},
|
||||
want: []models.DiskTemp{
|
||||
{Device: "/dev/sda", Temperature: -10},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "high temperature",
|
||||
raw: []smartEntryRaw{
|
||||
{Device: "/dev/sda", Temperature: intPtr(85)},
|
||||
},
|
||||
want: []models.DiskTemp{
|
||||
{Device: "/dev/sda", Temperature: 85},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := normalizeSMARTEntries(tt.raw)
|
||||
|
||||
if tt.want == nil {
|
||||
if got != nil {
|
||||
t.Errorf("normalizeSMARTEntries() = %v, want nil", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("normalizeSMARTEntries() returned %d entries, want %d", len(got), len(tt.want))
|
||||
}
|
||||
|
||||
for i := range got {
|
||||
if got[i].Device != tt.want[i].Device {
|
||||
t.Errorf("entry[%d].Device = %q, want %q", i, got[i].Device, tt.want[i].Device)
|
||||
}
|
||||
if got[i].Serial != tt.want[i].Serial {
|
||||
t.Errorf("entry[%d].Serial = %q, want %q", i, got[i].Serial, tt.want[i].Serial)
|
||||
}
|
||||
if got[i].WWN != tt.want[i].WWN {
|
||||
t.Errorf("entry[%d].WWN = %q, want %q", i, got[i].WWN, tt.want[i].WWN)
|
||||
}
|
||||
if got[i].Model != tt.want[i].Model {
|
||||
t.Errorf("entry[%d].Model = %q, want %q", i, got[i].Model, tt.want[i].Model)
|
||||
}
|
||||
if got[i].Type != tt.want[i].Type {
|
||||
t.Errorf("entry[%d].Type = %q, want %q", i, got[i].Type, tt.want[i].Type)
|
||||
}
|
||||
if got[i].Temperature != tt.want[i].Temperature {
|
||||
t.Errorf("entry[%d].Temperature = %d, want %d", i, got[i].Temperature, tt.want[i].Temperature)
|
||||
}
|
||||
if !got[i].LastUpdated.Equal(tt.want[i].LastUpdated) {
|
||||
t.Errorf("entry[%d].LastUpdated = %v, want %v", i, got[i].LastUpdated, tt.want[i].LastUpdated)
|
||||
}
|
||||
if got[i].StandbySkipped != tt.want[i].StandbySkipped {
|
||||
t.Errorf("entry[%d].StandbySkipped = %v, want %v", i, got[i].StandbySkipped, tt.want[i].StandbySkipped)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for test setup
|
||||
|
||||
func intPtr(i int) *int {
|
||||
return &i
|
||||
}
|
||||
|
||||
func mustParseTime(s string) time.Time {
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue