mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 11:30:15 +00:00
In single-node setups, guest alerts had Instance == Node, causing reevaluateActiveAlertsLocked to evaluate them against NodeDefaults instead of GuestDefaults. Setting guest memory threshold to 0 (disabled) wouldn't clear existing guest alerts because they were being kept alive by the still-enabled node memory threshold. - Add resourceID colon check to distinguish guest IDs (instance:node:vmid) from node IDs (instance-node) in reevaluateActiveAlertsLocked - Clear stale alerts in checkMetric when threshold is nil or disabled - Skip hysteresis validation for disabled thresholds (Trigger <= 0) - Fix frontend tooltip: "0" not "-1" disables a threshold
2544 lines
57 KiB
Go
2544 lines
57 KiB
Go
package alerts
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
)
|
|
|
|
// TestSanitizeAlertKey tests the sanitizeAlertKey function
|
|
func TestSanitizeAlertKey(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
// Basic cases
|
|
{
|
|
name: "empty string returns empty",
|
|
input: "",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "whitespace only returns empty",
|
|
input: " ",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "simple lowercase passes through",
|
|
input: "disk",
|
|
want: "disk",
|
|
},
|
|
{
|
|
name: "uppercase converted to lowercase",
|
|
input: "DISK",
|
|
want: "disk",
|
|
},
|
|
{
|
|
name: "mixed case normalized",
|
|
input: "MyDisk",
|
|
want: "mydisk",
|
|
},
|
|
|
|
// Root handling
|
|
{
|
|
name: "single slash becomes root",
|
|
input: "/",
|
|
want: "root",
|
|
},
|
|
{
|
|
name: "slashes trimmed",
|
|
input: "/disk/",
|
|
want: "disk",
|
|
},
|
|
{
|
|
name: "leading slashes trimmed",
|
|
input: "/mnt/data",
|
|
want: "mnt-data",
|
|
},
|
|
|
|
// Special character handling
|
|
{
|
|
name: "spaces become dashes",
|
|
input: "my disk",
|
|
want: "my-disk",
|
|
},
|
|
{
|
|
name: "multiple spaces become single dash",
|
|
input: "my disk",
|
|
want: "my-disk",
|
|
},
|
|
{
|
|
name: "underscores become dashes",
|
|
input: "my_disk",
|
|
want: "my-disk",
|
|
},
|
|
{
|
|
name: "backslashes handled",
|
|
input: "C:\\Users\\Data",
|
|
want: "c-users-data",
|
|
},
|
|
{
|
|
name: "dots preserved",
|
|
input: "disk.local",
|
|
want: "disk.local",
|
|
},
|
|
{
|
|
name: "numbers preserved",
|
|
input: "disk123",
|
|
want: "disk123",
|
|
},
|
|
{
|
|
name: "alphanumeric with dots",
|
|
input: "nvme0n1p1",
|
|
want: "nvme0n1p1",
|
|
},
|
|
|
|
// Edge cases
|
|
{
|
|
name: "only slashes and backslashes becomes root",
|
|
input: "//\\\\",
|
|
want: "root",
|
|
},
|
|
{
|
|
name: "only special chars becomes disk",
|
|
input: "@#$%",
|
|
want: "disk",
|
|
},
|
|
{
|
|
name: "trailing dashes trimmed",
|
|
input: "disk--",
|
|
want: "disk",
|
|
},
|
|
{
|
|
name: "trailing dots trimmed",
|
|
input: "disk..",
|
|
want: "disk",
|
|
},
|
|
{
|
|
name: "leading and trailing trimmed",
|
|
input: "--disk--",
|
|
want: "disk",
|
|
},
|
|
|
|
// Real-world examples
|
|
{
|
|
name: "linux mount path",
|
|
input: "/mnt/storage/backup",
|
|
want: "mnt-storage-backup",
|
|
},
|
|
{
|
|
name: "linux device path",
|
|
input: "/dev/sda1",
|
|
want: "dev-sda1",
|
|
},
|
|
{
|
|
name: "nvme device",
|
|
input: "/dev/nvme0n1",
|
|
want: "dev-nvme0n1",
|
|
},
|
|
{
|
|
name: "windows drive letter",
|
|
input: "C:",
|
|
want: "c",
|
|
},
|
|
{
|
|
name: "docker volume name",
|
|
input: "my_app_data",
|
|
want: "my-app-data",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := sanitizeAlertKey(tc.input)
|
|
if got != tc.want {
|
|
t.Errorf("sanitizeAlertKey(%q) = %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAbs tests the abs function for float64
|
|
func TestAbs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input float64
|
|
want float64
|
|
}{
|
|
{
|
|
name: "positive returns unchanged",
|
|
input: 5.5,
|
|
want: 5.5,
|
|
},
|
|
{
|
|
name: "negative becomes positive",
|
|
input: -5.5,
|
|
want: 5.5,
|
|
},
|
|
{
|
|
name: "zero returns zero",
|
|
input: 0,
|
|
want: 0,
|
|
},
|
|
{
|
|
name: "small positive",
|
|
input: 0.001,
|
|
want: 0.001,
|
|
},
|
|
{
|
|
name: "small negative",
|
|
input: -0.001,
|
|
want: 0.001,
|
|
},
|
|
{
|
|
name: "large positive",
|
|
input: 1e10,
|
|
want: 1e10,
|
|
},
|
|
{
|
|
name: "large negative",
|
|
input: -1e10,
|
|
want: 1e10,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := abs(tc.input)
|
|
if got != tc.want {
|
|
t.Errorf("abs(%v) = %v, want %v", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestIsQueueOutlier tests the isQueueOutlier function
|
|
func TestIsQueueOutlier(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value int
|
|
median int
|
|
want bool
|
|
}{
|
|
// Zero median cases
|
|
{
|
|
name: "zero median with zero value is not outlier",
|
|
value: 0,
|
|
median: 0,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "zero median with positive value is outlier",
|
|
value: 1,
|
|
median: 0,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "zero median with large value is outlier",
|
|
value: 100,
|
|
median: 0,
|
|
want: true,
|
|
},
|
|
|
|
// Normal cases (threshold is 40% above median)
|
|
{
|
|
name: "value equal to median is not outlier",
|
|
value: 100,
|
|
median: 100,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "value 20% above median is not outlier",
|
|
value: 120,
|
|
median: 100,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "value 40% above median is not outlier (boundary)",
|
|
value: 140,
|
|
median: 100,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "value 41% above median is outlier",
|
|
value: 141,
|
|
median: 100,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "value 50% above median is outlier",
|
|
value: 150,
|
|
median: 100,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "value 100% above median is outlier",
|
|
value: 200,
|
|
median: 100,
|
|
want: true,
|
|
},
|
|
|
|
// Value below median
|
|
{
|
|
name: "value below median is not outlier",
|
|
value: 50,
|
|
median: 100,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "value at zero with nonzero median is not outlier",
|
|
value: 0,
|
|
median: 100,
|
|
want: false,
|
|
},
|
|
|
|
// Small numbers
|
|
{
|
|
name: "small median value at boundary",
|
|
value: 7,
|
|
median: 5,
|
|
want: false, // 7/5 = 1.4 = 40%, at boundary
|
|
},
|
|
{
|
|
name: "small median value just over",
|
|
value: 8,
|
|
median: 5,
|
|
want: true, // 8/5 = 1.6 = 60% above
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := isQueueOutlier(tc.value, tc.median)
|
|
if got != tc.want {
|
|
t.Errorf("isQueueOutlier(%d, %d) = %v, want %v", tc.value, tc.median, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestScaleThreshold tests the scaleThreshold function
|
|
func TestScaleThreshold(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
threshold int
|
|
scaleFactor float64
|
|
want int
|
|
}{
|
|
// Zero threshold
|
|
{
|
|
name: "zero threshold returns zero",
|
|
threshold: 0,
|
|
scaleFactor: 2.0,
|
|
want: 0,
|
|
},
|
|
{
|
|
name: "negative threshold returns zero",
|
|
threshold: -5,
|
|
scaleFactor: 2.0,
|
|
want: 0,
|
|
},
|
|
|
|
// Normal scaling
|
|
{
|
|
name: "scale by 1.0 returns same",
|
|
threshold: 100,
|
|
scaleFactor: 1.0,
|
|
want: 100,
|
|
},
|
|
{
|
|
name: "scale by 2.0 doubles",
|
|
threshold: 100,
|
|
scaleFactor: 2.0,
|
|
want: 200,
|
|
},
|
|
{
|
|
name: "scale by 0.5 halves",
|
|
threshold: 100,
|
|
scaleFactor: 0.5,
|
|
want: 50,
|
|
},
|
|
{
|
|
name: "scale by 1.5",
|
|
threshold: 100,
|
|
scaleFactor: 1.5,
|
|
want: 150,
|
|
},
|
|
|
|
// Ceiling behavior
|
|
{
|
|
name: "ceiling applied for fractional result",
|
|
threshold: 10,
|
|
scaleFactor: 1.1,
|
|
want: 11, // 10 * 1.1 = 11.0 (exact)
|
|
},
|
|
{
|
|
name: "ceiling rounds up",
|
|
threshold: 10,
|
|
scaleFactor: 0.33,
|
|
want: 4, // 10 * 0.33 = 3.3 -> ceil = 4
|
|
},
|
|
{
|
|
name: "small threshold with small factor",
|
|
threshold: 3,
|
|
scaleFactor: 0.5,
|
|
want: 2, // 3 * 0.5 = 1.5 -> ceil = 2
|
|
},
|
|
|
|
// Minimum value of 1
|
|
{
|
|
name: "very small result floors to 1",
|
|
threshold: 1,
|
|
scaleFactor: 0.1,
|
|
want: 1, // 1 * 0.1 = 0.1 -> ceil = 1, min = 1
|
|
},
|
|
{
|
|
name: "result near zero floors to 1",
|
|
threshold: 5,
|
|
scaleFactor: 0.001,
|
|
want: 1, // 5 * 0.001 = 0.005 -> ceil = 1
|
|
},
|
|
|
|
// Edge cases
|
|
{
|
|
name: "large threshold",
|
|
threshold: 10000,
|
|
scaleFactor: 10.0,
|
|
want: 100000,
|
|
},
|
|
{
|
|
name: "zero scale factor gives 1",
|
|
threshold: 100,
|
|
scaleFactor: 0.0,
|
|
want: 1, // 100 * 0 = 0 -> ceil(0) = 0, min = 1
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := scaleThreshold(tc.threshold, tc.scaleFactor)
|
|
if got != tc.want {
|
|
t.Errorf("scaleThreshold(%d, %v) = %d, want %d", tc.threshold, tc.scaleFactor, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCalculateMedianInt tests the calculateMedianInt function
|
|
func TestCalculateMedianInt(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
values []int
|
|
want int
|
|
}{
|
|
// Empty and single element
|
|
{
|
|
name: "empty slice returns 0",
|
|
values: []int{},
|
|
want: 0,
|
|
},
|
|
{
|
|
name: "nil slice returns 0",
|
|
values: nil,
|
|
want: 0,
|
|
},
|
|
{
|
|
name: "single element returns that element",
|
|
values: []int{5},
|
|
want: 5,
|
|
},
|
|
{
|
|
name: "single zero returns 0",
|
|
values: []int{0},
|
|
want: 0,
|
|
},
|
|
|
|
// Odd number of elements
|
|
{
|
|
name: "three elements returns middle",
|
|
values: []int{1, 2, 3},
|
|
want: 2,
|
|
},
|
|
{
|
|
name: "three unsorted elements",
|
|
values: []int{3, 1, 2},
|
|
want: 2,
|
|
},
|
|
{
|
|
name: "five elements returns middle",
|
|
values: []int{1, 2, 3, 4, 5},
|
|
want: 3,
|
|
},
|
|
{
|
|
name: "five unsorted elements",
|
|
values: []int{5, 1, 3, 2, 4},
|
|
want: 3,
|
|
},
|
|
|
|
// Even number of elements
|
|
{
|
|
name: "two elements returns average",
|
|
values: []int{2, 4},
|
|
want: 3, // (2 + 4) / 2 = 3
|
|
},
|
|
{
|
|
name: "four elements returns average of middle two",
|
|
values: []int{1, 2, 3, 4},
|
|
want: 2, // (2 + 3) / 2 = 2 (integer division)
|
|
},
|
|
{
|
|
name: "four unsorted elements",
|
|
values: []int{4, 1, 3, 2},
|
|
want: 2, // sorted: 1,2,3,4 -> (2+3)/2 = 2
|
|
},
|
|
{
|
|
name: "six elements",
|
|
values: []int{1, 2, 3, 4, 5, 6},
|
|
want: 3, // (3 + 4) / 2 = 3 (integer division)
|
|
},
|
|
|
|
// Duplicates
|
|
{
|
|
name: "all same values",
|
|
values: []int{5, 5, 5, 5, 5},
|
|
want: 5,
|
|
},
|
|
{
|
|
name: "some duplicates odd count",
|
|
values: []int{1, 2, 2, 3, 3},
|
|
want: 2,
|
|
},
|
|
{
|
|
name: "some duplicates even count",
|
|
values: []int{1, 1, 3, 3},
|
|
want: 2, // (1 + 3) / 2 = 2
|
|
},
|
|
|
|
// Negative numbers
|
|
{
|
|
name: "negative numbers",
|
|
values: []int{-5, -3, -1},
|
|
want: -3,
|
|
},
|
|
{
|
|
name: "mixed positive and negative",
|
|
values: []int{-5, 0, 5},
|
|
want: 0,
|
|
},
|
|
{
|
|
name: "mixed even count",
|
|
values: []int{-4, -2, 2, 4},
|
|
want: 0, // (-2 + 2) / 2 = 0
|
|
},
|
|
|
|
// Large values
|
|
{
|
|
name: "large values",
|
|
values: []int{1000000, 2000000, 3000000},
|
|
want: 2000000,
|
|
},
|
|
|
|
// Edge case for integer division
|
|
{
|
|
name: "average truncates down",
|
|
values: []int{1, 4},
|
|
want: 2, // (1 + 4) / 2 = 2 (not 2.5)
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Make a copy to ensure original isn't modified
|
|
var input []int
|
|
if tc.values != nil {
|
|
input = make([]int, len(tc.values))
|
|
copy(input, tc.values)
|
|
}
|
|
|
|
got := calculateMedianInt(input)
|
|
if got != tc.want {
|
|
t.Errorf("calculateMedianInt(%v) = %d, want %d", tc.values, got, tc.want)
|
|
}
|
|
|
|
// Verify original slice wasn't modified (if non-nil)
|
|
if tc.values != nil && input != nil {
|
|
for i := range input {
|
|
if input[i] != tc.values[i] {
|
|
t.Errorf("calculateMedianInt modified input slice")
|
|
break
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCalculateMedianInt_DoesNotModifyInput verifies the input slice is copied
|
|
func TestCalculateMedianInt_DoesNotModifyInput(t *testing.T) {
|
|
input := []int{5, 1, 3, 2, 4}
|
|
original := make([]int, len(input))
|
|
copy(original, input)
|
|
|
|
_ = calculateMedianInt(input)
|
|
|
|
for i := range input {
|
|
if input[i] != original[i] {
|
|
t.Errorf("calculateMedianInt modified input: got %v, original was %v", input, original)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHostResourceID tests the hostResourceID function
|
|
func TestHostResourceID(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hostID string
|
|
want string
|
|
}{
|
|
{
|
|
name: "normal host ID",
|
|
hostID: "host-123",
|
|
want: "host:host-123",
|
|
},
|
|
{
|
|
name: "empty string returns unknown",
|
|
hostID: "",
|
|
want: "host:unknown",
|
|
},
|
|
{
|
|
name: "whitespace only returns unknown",
|
|
hostID: " ",
|
|
want: "host:unknown",
|
|
},
|
|
{
|
|
name: "whitespace is trimmed",
|
|
hostID: " host-456 ",
|
|
want: "host:host-456",
|
|
},
|
|
{
|
|
name: "UUID format",
|
|
hostID: "550e8400-e29b-41d4-a716-446655440000",
|
|
want: "host:550e8400-e29b-41d4-a716-446655440000",
|
|
},
|
|
{
|
|
name: "simple hostname",
|
|
hostID: "server1",
|
|
want: "host:server1",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := hostResourceID(tc.hostID)
|
|
if got != tc.want {
|
|
t.Errorf("hostResourceID(%q) = %q, want %q", tc.hostID, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHostDisplayName tests the hostDisplayName function
|
|
func TestHostDisplayName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
host models.Host
|
|
want string
|
|
}{
|
|
{
|
|
name: "display name preferred",
|
|
host: models.Host{
|
|
ID: "id-123",
|
|
DisplayName: "My Server",
|
|
Hostname: "server.local",
|
|
},
|
|
want: "My Server",
|
|
},
|
|
{
|
|
name: "hostname when no display name",
|
|
host: models.Host{
|
|
ID: "id-123",
|
|
DisplayName: "",
|
|
Hostname: "server.local",
|
|
},
|
|
want: "server.local",
|
|
},
|
|
{
|
|
name: "ID when no display name or hostname",
|
|
host: models.Host{
|
|
ID: "id-123",
|
|
DisplayName: "",
|
|
Hostname: "",
|
|
},
|
|
want: "id-123",
|
|
},
|
|
{
|
|
name: "fallback to Host literal",
|
|
host: models.Host{
|
|
ID: "",
|
|
DisplayName: "",
|
|
Hostname: "",
|
|
},
|
|
want: "Host",
|
|
},
|
|
{
|
|
name: "whitespace display name ignored",
|
|
host: models.Host{
|
|
ID: "id-123",
|
|
DisplayName: " ",
|
|
Hostname: "server.local",
|
|
},
|
|
want: "server.local",
|
|
},
|
|
{
|
|
name: "whitespace hostname ignored",
|
|
host: models.Host{
|
|
ID: "id-123",
|
|
DisplayName: "",
|
|
Hostname: " ",
|
|
},
|
|
want: "id-123",
|
|
},
|
|
{
|
|
name: "display name with whitespace trimmed",
|
|
host: models.Host{
|
|
ID: "id-123",
|
|
DisplayName: " Server Name ",
|
|
Hostname: "server.local",
|
|
},
|
|
want: "Server Name",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := hostDisplayName(tc.host)
|
|
if got != tc.want {
|
|
t.Errorf("hostDisplayName() = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHostInstanceName tests the hostInstanceName function
|
|
func TestHostInstanceName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
host models.Host
|
|
want string
|
|
}{
|
|
{
|
|
name: "platform preferred",
|
|
host: models.Host{
|
|
Platform: "linux",
|
|
OSName: "Ubuntu 22.04",
|
|
},
|
|
want: "linux",
|
|
},
|
|
{
|
|
name: "os name when no platform",
|
|
host: models.Host{
|
|
Platform: "",
|
|
OSName: "Ubuntu 22.04",
|
|
},
|
|
want: "Ubuntu 22.04",
|
|
},
|
|
{
|
|
name: "fallback to Host Agent",
|
|
host: models.Host{
|
|
Platform: "",
|
|
OSName: "",
|
|
},
|
|
want: "Host Agent",
|
|
},
|
|
{
|
|
name: "whitespace platform ignored",
|
|
host: models.Host{
|
|
Platform: " ",
|
|
OSName: "Windows Server",
|
|
},
|
|
want: "Windows Server",
|
|
},
|
|
{
|
|
name: "whitespace os name ignored",
|
|
host: models.Host{
|
|
Platform: "",
|
|
OSName: " ",
|
|
},
|
|
want: "Host Agent",
|
|
},
|
|
{
|
|
name: "platform with whitespace trimmed",
|
|
host: models.Host{
|
|
Platform: " darwin ",
|
|
OSName: "macOS",
|
|
},
|
|
want: "darwin",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := hostInstanceName(tc.host)
|
|
if got != tc.want {
|
|
t.Errorf("hostInstanceName() = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSanitizeHostComponent tests the sanitizeHostComponent function
|
|
func TestSanitizeHostComponent(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
// Empty/whitespace
|
|
{
|
|
name: "empty returns unknown",
|
|
input: "",
|
|
want: "unknown",
|
|
},
|
|
{
|
|
name: "whitespace only returns unknown",
|
|
input: " ",
|
|
want: "unknown",
|
|
},
|
|
|
|
// Basic strings
|
|
{
|
|
name: "lowercase passes through",
|
|
input: "myhost",
|
|
want: "myhost",
|
|
},
|
|
{
|
|
name: "uppercase converted to lowercase",
|
|
input: "MYHOST",
|
|
want: "myhost",
|
|
},
|
|
{
|
|
name: "mixed case normalized",
|
|
input: "MyHost",
|
|
want: "myhost",
|
|
},
|
|
{
|
|
name: "numbers preserved",
|
|
input: "host123",
|
|
want: "host123",
|
|
},
|
|
|
|
// Special characters become hyphens
|
|
{
|
|
name: "spaces become hyphen",
|
|
input: "my host",
|
|
want: "my-host",
|
|
},
|
|
{
|
|
name: "multiple spaces become single hyphen",
|
|
input: "my host",
|
|
want: "my-host",
|
|
},
|
|
{
|
|
name: "underscores become hyphen",
|
|
input: "my_host",
|
|
want: "my-host",
|
|
},
|
|
{
|
|
name: "slashes become hyphen",
|
|
input: "mnt/data",
|
|
want: "mnt-data",
|
|
},
|
|
{
|
|
name: "dots become hyphen",
|
|
input: "host.local",
|
|
want: "host-local",
|
|
},
|
|
{
|
|
name: "mixed special chars",
|
|
input: "host.local/data_01",
|
|
want: "host-local-data-01",
|
|
},
|
|
|
|
// Trimming leading/trailing hyphens
|
|
{
|
|
name: "leading special chars trimmed",
|
|
input: "--host",
|
|
want: "host",
|
|
},
|
|
{
|
|
name: "trailing special chars trimmed",
|
|
input: "host--",
|
|
want: "host",
|
|
},
|
|
{
|
|
name: "both ends trimmed",
|
|
input: "/host/",
|
|
want: "host",
|
|
},
|
|
|
|
// Only special chars
|
|
{
|
|
name: "only special chars returns unknown",
|
|
input: "@#$%",
|
|
want: "unknown",
|
|
},
|
|
{
|
|
name: "only hyphens returns unknown",
|
|
input: "---",
|
|
want: "unknown",
|
|
},
|
|
|
|
// Real-world examples
|
|
{
|
|
name: "linux mount path",
|
|
input: "/mnt/storage",
|
|
want: "mnt-storage",
|
|
},
|
|
{
|
|
name: "device path",
|
|
input: "/dev/sda1",
|
|
want: "dev-sda1",
|
|
},
|
|
{
|
|
name: "nvme device",
|
|
input: "nvme0n1p1",
|
|
want: "nvme0n1p1",
|
|
},
|
|
{
|
|
name: "IP address",
|
|
input: "192.168.1.1",
|
|
want: "192-168-1-1",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := sanitizeHostComponent(tc.input)
|
|
if got != tc.want {
|
|
t.Errorf("sanitizeHostComponent(%q) = %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSanitizeRAIDDevice tests the sanitizeRAIDDevice function
|
|
func TestSanitizeRAIDDevice(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{
|
|
name: "with dev prefix",
|
|
input: "/dev/md0",
|
|
want: "md0",
|
|
},
|
|
{
|
|
name: "without dev prefix",
|
|
input: "md0",
|
|
want: "md0",
|
|
},
|
|
{
|
|
name: "nvme device",
|
|
input: "/dev/nvme0n1",
|
|
want: "nvme0n1",
|
|
},
|
|
{
|
|
name: "sda device",
|
|
input: "/dev/sda",
|
|
want: "sda",
|
|
},
|
|
{
|
|
name: "empty returns unknown",
|
|
input: "",
|
|
want: "unknown",
|
|
},
|
|
{
|
|
name: "only dev prefix",
|
|
input: "/dev/",
|
|
want: "unknown",
|
|
},
|
|
{
|
|
name: "md device with partition",
|
|
input: "/dev/md127p1",
|
|
want: "md127p1",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := sanitizeRAIDDevice(tc.input)
|
|
if got != tc.want {
|
|
t.Errorf("sanitizeRAIDDevice(%q) = %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHostDiskResourceID tests the hostDiskResourceID function
|
|
func TestHostDiskResourceID(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
host models.Host
|
|
disk models.Disk
|
|
wantID string
|
|
wantNamePart string // Part of the name to check
|
|
}{
|
|
{
|
|
name: "mountpoint preferred",
|
|
host: models.Host{ID: "host-123"},
|
|
disk: models.Disk{
|
|
Mountpoint: "/mnt/data",
|
|
Device: "/dev/sda1",
|
|
},
|
|
wantID: "host:host-123/disk:mnt-data",
|
|
wantNamePart: "/mnt/data",
|
|
},
|
|
{
|
|
name: "device when no mountpoint",
|
|
host: models.Host{ID: "host-123"},
|
|
disk: models.Disk{
|
|
Mountpoint: "",
|
|
Device: "/dev/sda1",
|
|
},
|
|
wantID: "host:host-123/disk:dev-sda1",
|
|
wantNamePart: "/dev/sda1",
|
|
},
|
|
{
|
|
name: "fallback to disk literal",
|
|
host: models.Host{ID: "host-123"},
|
|
disk: models.Disk{
|
|
Mountpoint: "",
|
|
Device: "",
|
|
},
|
|
wantID: "host:host-123/disk:disk",
|
|
wantNamePart: "disk",
|
|
},
|
|
{
|
|
name: "root mount sanitizes to unknown",
|
|
host: models.Host{ID: "host-123"},
|
|
disk: models.Disk{
|
|
Mountpoint: "/",
|
|
Device: "/dev/sda1",
|
|
},
|
|
wantID: "host:host-123/disk:unknown",
|
|
wantNamePart: "/",
|
|
},
|
|
{
|
|
name: "whitespace mountpoint uses device",
|
|
host: models.Host{ID: "host-123"},
|
|
disk: models.Disk{
|
|
Mountpoint: " ",
|
|
Device: "/dev/sda1",
|
|
},
|
|
wantID: "host:host-123/disk:dev-sda1",
|
|
wantNamePart: "/dev/sda1",
|
|
},
|
|
{
|
|
name: "includes host display name",
|
|
host: models.Host{
|
|
ID: "host-123",
|
|
DisplayName: "My Server",
|
|
},
|
|
disk: models.Disk{
|
|
Mountpoint: "/data",
|
|
},
|
|
wantID: "host:host-123/disk:data",
|
|
wantNamePart: "My Server",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
gotID, gotName := hostDiskResourceID(tc.host, tc.disk)
|
|
if gotID != tc.wantID {
|
|
t.Errorf("hostDiskResourceID() ID = %q, want %q", gotID, tc.wantID)
|
|
}
|
|
if !strings.Contains(gotName, tc.wantNamePart) {
|
|
t.Errorf("hostDiskResourceID() Name = %q, want to contain %q", gotName, tc.wantNamePart)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestIsMonitorOnlyAlert tests the isMonitorOnlyAlert function
|
|
func TestIsMonitorOnlyAlert(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
alert *Alert
|
|
want bool
|
|
}{
|
|
{
|
|
name: "nil alert returns false",
|
|
alert: nil,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "nil metadata returns false",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: nil,
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "empty metadata returns false",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "no monitorOnly key returns false",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"other": "value"},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "monitorOnly bool true returns true",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"monitorOnly": true},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "monitorOnly bool false returns false",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"monitorOnly": false},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "monitorOnly string 'true' returns true",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"monitorOnly": "true"},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "monitorOnly string 'TRUE' returns true (case insensitive)",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"monitorOnly": "TRUE"},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "monitorOnly string 'True' returns true (case insensitive)",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"monitorOnly": "True"},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "monitorOnly string 'false' returns false",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"monitorOnly": "false"},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "monitorOnly string 'yes' returns false (not 'true')",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"monitorOnly": "yes"},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "monitorOnly int value returns false (not bool or string)",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"monitorOnly": 1},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "monitorOnly nil value returns false",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"monitorOnly": nil},
|
|
},
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := isMonitorOnlyAlert(tc.alert)
|
|
if got != tc.want {
|
|
t.Errorf("isMonitorOnlyAlert() = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestQuietHoursCategoryForAlert tests the quietHoursCategoryForAlert function
|
|
func TestQuietHoursCategoryForAlert(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
alert *Alert
|
|
want string
|
|
}{
|
|
// Nil alert
|
|
{
|
|
name: "nil alert returns empty",
|
|
alert: nil,
|
|
want: "",
|
|
},
|
|
|
|
// Performance metrics
|
|
{
|
|
name: "cpu type returns performance",
|
|
alert: &Alert{Type: "cpu"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "memory type returns performance",
|
|
alert: &Alert{Type: "memory"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "disk type returns performance",
|
|
alert: &Alert{Type: "disk"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "diskRead type returns performance",
|
|
alert: &Alert{Type: "diskRead"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "diskWrite type returns performance",
|
|
alert: &Alert{Type: "diskWrite"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "networkIn type returns performance",
|
|
alert: &Alert{Type: "networkIn"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "networkOut type returns performance",
|
|
alert: &Alert{Type: "networkOut"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "temperature type returns performance",
|
|
alert: &Alert{Type: "temperature"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "queue-depth returns performance",
|
|
alert: &Alert{Type: "queue-depth"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "queue-deferred returns performance",
|
|
alert: &Alert{Type: "queue-deferred"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "queue-hold returns performance",
|
|
alert: &Alert{Type: "queue-hold"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "message-age returns performance",
|
|
alert: &Alert{Type: "message-age"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "docker-container-health returns performance",
|
|
alert: &Alert{Type: "docker-container-health"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "docker-container-restart-loop returns performance",
|
|
alert: &Alert{Type: "docker-container-restart-loop"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "docker-container-oom-kill returns performance",
|
|
alert: &Alert{Type: "docker-container-oom-kill"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "docker-container-memory-limit returns performance",
|
|
alert: &Alert{Type: "docker-container-memory-limit"},
|
|
want: "performance",
|
|
},
|
|
|
|
// Storage metrics
|
|
{
|
|
name: "usage type returns storage",
|
|
alert: &Alert{Type: "usage"},
|
|
want: "storage",
|
|
},
|
|
{
|
|
name: "disk-health returns storage",
|
|
alert: &Alert{Type: "disk-health"},
|
|
want: "storage",
|
|
},
|
|
{
|
|
name: "disk-wearout returns storage",
|
|
alert: &Alert{Type: "disk-wearout"},
|
|
want: "storage",
|
|
},
|
|
{
|
|
name: "zfs-pool-state returns storage",
|
|
alert: &Alert{Type: "zfs-pool-state"},
|
|
want: "storage",
|
|
},
|
|
{
|
|
name: "zfs-pool-errors returns storage",
|
|
alert: &Alert{Type: "zfs-pool-errors"},
|
|
want: "storage",
|
|
},
|
|
{
|
|
name: "zfs-device returns storage",
|
|
alert: &Alert{Type: "zfs-device"},
|
|
want: "storage",
|
|
},
|
|
|
|
// Offline metrics
|
|
{
|
|
name: "connectivity type returns offline",
|
|
alert: &Alert{Type: "connectivity"},
|
|
want: "offline",
|
|
},
|
|
{
|
|
name: "offline type returns offline",
|
|
alert: &Alert{Type: "offline"},
|
|
want: "offline",
|
|
},
|
|
{
|
|
name: "powered-off type returns offline",
|
|
alert: &Alert{Type: "powered-off"},
|
|
want: "offline",
|
|
},
|
|
{
|
|
name: "docker-host-offline returns offline",
|
|
alert: &Alert{Type: "docker-host-offline"},
|
|
want: "offline",
|
|
},
|
|
|
|
// Docker container prefix handling
|
|
{
|
|
name: "docker-container-state returns offline",
|
|
alert: &Alert{Type: "docker-container-state"},
|
|
want: "offline",
|
|
},
|
|
{
|
|
name: "docker-container-cpu returns performance (prefix match)",
|
|
alert: &Alert{Type: "docker-container-cpu"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "docker-container-disk returns performance (prefix match)",
|
|
alert: &Alert{Type: "docker-container-disk"},
|
|
want: "performance",
|
|
},
|
|
|
|
// Unknown types
|
|
{
|
|
name: "unknown type returns empty",
|
|
alert: &Alert{Type: "unknown-type"},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "empty type returns empty",
|
|
alert: &Alert{Type: ""},
|
|
want: "",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := quietHoursCategoryForAlert(tc.alert)
|
|
if got != tc.want {
|
|
t.Errorf("quietHoursCategoryForAlert(%v) = %q, want %q", tc.alert, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCanonicalResourceTypeKeys tests the canonicalResourceTypeKeys function
|
|
func TestCanonicalResourceTypeKeys(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
resourceType string
|
|
want []string
|
|
}{
|
|
// Guest types
|
|
{
|
|
name: "guest returns guest",
|
|
resourceType: "guest",
|
|
want: []string{"guest"},
|
|
},
|
|
{
|
|
name: "qemu returns guest",
|
|
resourceType: "qemu",
|
|
want: []string{"guest"},
|
|
},
|
|
{
|
|
name: "vm returns guest",
|
|
resourceType: "vm",
|
|
want: []string{"guest"},
|
|
},
|
|
{
|
|
name: "ct returns guest",
|
|
resourceType: "ct",
|
|
want: []string{"guest"},
|
|
},
|
|
{
|
|
name: "container returns guest",
|
|
resourceType: "container",
|
|
want: []string{"guest"},
|
|
},
|
|
{
|
|
name: "lxc returns guest",
|
|
resourceType: "lxc",
|
|
want: []string{"guest"},
|
|
},
|
|
|
|
// Docker container types
|
|
{
|
|
name: "docker returns docker and guest",
|
|
resourceType: "docker",
|
|
want: []string{"docker", "guest"},
|
|
},
|
|
{
|
|
name: "docker container with space returns docker and guest",
|
|
resourceType: "docker container",
|
|
want: []string{"docker", "guest"},
|
|
},
|
|
{
|
|
name: "dockercontainer returns docker and guest",
|
|
resourceType: "dockercontainer",
|
|
want: []string{"docker", "guest"},
|
|
},
|
|
|
|
// Docker host types
|
|
{
|
|
name: "docker host with space returns dockerhost, docker, node",
|
|
resourceType: "docker host",
|
|
want: []string{"dockerhost", "docker", "node"},
|
|
},
|
|
{
|
|
name: "dockerhost returns dockerhost, docker, node",
|
|
resourceType: "dockerhost",
|
|
want: []string{"dockerhost", "docker", "node"},
|
|
},
|
|
|
|
// Node type
|
|
{
|
|
name: "node returns node",
|
|
resourceType: "node",
|
|
want: []string{"node"},
|
|
},
|
|
|
|
// PBS types
|
|
{
|
|
name: "pbs returns pbs and node",
|
|
resourceType: "pbs",
|
|
want: []string{"pbs", "node"},
|
|
},
|
|
{
|
|
name: "pbs server with space returns pbs and node",
|
|
resourceType: "pbs server",
|
|
want: []string{"pbs", "node"},
|
|
},
|
|
{
|
|
name: "pbsserver returns pbs and node",
|
|
resourceType: "pbsserver",
|
|
want: []string{"pbs", "node"},
|
|
},
|
|
|
|
// Storage type
|
|
{
|
|
name: "storage returns storage",
|
|
resourceType: "storage",
|
|
want: []string{"storage"},
|
|
},
|
|
|
|
// Case insensitivity
|
|
{
|
|
name: "GUEST uppercase returns guest",
|
|
resourceType: "GUEST",
|
|
want: []string{"guest"},
|
|
},
|
|
{
|
|
name: "Docker mixed case returns docker and guest",
|
|
resourceType: "Docker",
|
|
want: []string{"docker", "guest"},
|
|
},
|
|
{
|
|
name: "NODE uppercase returns node",
|
|
resourceType: "NODE",
|
|
want: []string{"node"},
|
|
},
|
|
|
|
// Whitespace handling
|
|
{
|
|
name: "guest with leading whitespace",
|
|
resourceType: " guest",
|
|
want: []string{"guest"},
|
|
},
|
|
{
|
|
name: "guest with trailing whitespace",
|
|
resourceType: "guest ",
|
|
want: []string{"guest"},
|
|
},
|
|
{
|
|
name: "guest with surrounding whitespace",
|
|
resourceType: " guest ",
|
|
want: []string{"guest"},
|
|
},
|
|
|
|
// Unknown types return self
|
|
{
|
|
name: "unknown type returns itself",
|
|
resourceType: "custom",
|
|
want: []string{"custom"},
|
|
},
|
|
{
|
|
name: "pmg returns itself as unknown",
|
|
resourceType: "pmg",
|
|
want: []string{"pmg"},
|
|
},
|
|
|
|
// Empty and whitespace-only
|
|
{
|
|
name: "empty string returns empty slice",
|
|
resourceType: "",
|
|
want: []string{},
|
|
},
|
|
{
|
|
name: "whitespace only returns empty slice",
|
|
resourceType: " ",
|
|
want: []string{},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := canonicalResourceTypeKeys(tc.resourceType)
|
|
|
|
// Check length
|
|
if len(got) != len(tc.want) {
|
|
t.Errorf("canonicalResourceTypeKeys(%q) = %v (len %d), want %v (len %d)",
|
|
tc.resourceType, got, len(got), tc.want, len(tc.want))
|
|
return
|
|
}
|
|
|
|
// Check each element
|
|
for i, v := range got {
|
|
if v != tc.want[i] {
|
|
t.Errorf("canonicalResourceTypeKeys(%q)[%d] = %q, want %q",
|
|
tc.resourceType, i, v, tc.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEnsureValidHysteresis tests the ensureValidHysteresis function
|
|
func TestEnsureValidHysteresis(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
threshold *HysteresisThreshold
|
|
metricName string
|
|
wantTrigger float64
|
|
wantClear float64
|
|
expectChange bool
|
|
}{
|
|
{
|
|
name: "nil threshold does nothing",
|
|
threshold: nil,
|
|
metricName: "cpu",
|
|
expectChange: false,
|
|
},
|
|
{
|
|
name: "valid threshold unchanged",
|
|
threshold: &HysteresisThreshold{Trigger: 90, Clear: 85},
|
|
metricName: "cpu",
|
|
wantTrigger: 90,
|
|
wantClear: 85,
|
|
expectChange: false,
|
|
},
|
|
{
|
|
name: "clear < trigger is valid",
|
|
threshold: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
metricName: "memory",
|
|
wantTrigger: 80,
|
|
wantClear: 70,
|
|
expectChange: false,
|
|
},
|
|
{
|
|
name: "clear == trigger is auto-fixed",
|
|
threshold: &HysteresisThreshold{Trigger: 90, Clear: 90},
|
|
metricName: "disk",
|
|
wantTrigger: 90,
|
|
wantClear: 85, // 90 - 5 = 85
|
|
expectChange: true,
|
|
},
|
|
{
|
|
name: "clear > trigger is auto-fixed",
|
|
threshold: &HysteresisThreshold{Trigger: 80, Clear: 90},
|
|
metricName: "network",
|
|
wantTrigger: 80,
|
|
wantClear: 75, // 80 - 5 = 75
|
|
expectChange: true,
|
|
},
|
|
{
|
|
name: "auto-fix clamps clear to 0 for low trigger",
|
|
threshold: &HysteresisThreshold{Trigger: 3, Clear: 5},
|
|
metricName: "low",
|
|
wantTrigger: 3,
|
|
wantClear: 0, // 3 - 5 = -2, clamped to 0
|
|
expectChange: true,
|
|
},
|
|
{
|
|
name: "trigger at 5 with invalid clear",
|
|
threshold: &HysteresisThreshold{Trigger: 5, Clear: 10},
|
|
metricName: "edge",
|
|
wantTrigger: 5,
|
|
wantClear: 0, // 5 - 5 = 0
|
|
expectChange: true,
|
|
},
|
|
{
|
|
name: "zero trigger with positive clear is skipped (disabled)",
|
|
threshold: &HysteresisThreshold{Trigger: 0, Clear: 5},
|
|
metricName: "zero",
|
|
wantTrigger: 0,
|
|
wantClear: 5, // Disabled thresholds are left as-is
|
|
expectChange: false, // No change — disabled threshold skipped
|
|
},
|
|
{
|
|
name: "both zero triggers skipped (disabled)",
|
|
threshold: &HysteresisThreshold{Trigger: 0, Clear: 0},
|
|
metricName: "disabled",
|
|
wantTrigger: 0,
|
|
wantClear: 0,
|
|
expectChange: false, // No change — disabled threshold skipped
|
|
},
|
|
{
|
|
name: "large trigger with equal clear",
|
|
threshold: &HysteresisThreshold{Trigger: 100, Clear: 100},
|
|
metricName: "max",
|
|
wantTrigger: 100,
|
|
wantClear: 95, // 100 - 5 = 95
|
|
expectChange: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if tc.threshold == nil {
|
|
// Just ensure it doesn't panic
|
|
ensureValidHysteresis(nil, tc.metricName)
|
|
return
|
|
}
|
|
|
|
// Make a copy to check if it changed
|
|
originalTrigger := tc.threshold.Trigger
|
|
originalClear := tc.threshold.Clear
|
|
|
|
ensureValidHysteresis(tc.threshold, tc.metricName)
|
|
|
|
if tc.threshold.Trigger != tc.wantTrigger {
|
|
t.Errorf("Trigger = %v, want %v", tc.threshold.Trigger, tc.wantTrigger)
|
|
}
|
|
if tc.threshold.Clear != tc.wantClear {
|
|
t.Errorf("Clear = %v, want %v", tc.threshold.Clear, tc.wantClear)
|
|
}
|
|
|
|
// Verify expectChange matches reality
|
|
changed := tc.threshold.Trigger != originalTrigger || tc.threshold.Clear != originalClear
|
|
if changed != tc.expectChange {
|
|
t.Errorf("expectChange = %v, but changed = %v", tc.expectChange, changed)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCloneThreshold tests the cloneThreshold function
|
|
func TestCloneThreshold(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
threshold *HysteresisThreshold
|
|
}{
|
|
{
|
|
name: "nil threshold returns nil",
|
|
threshold: nil,
|
|
},
|
|
{
|
|
name: "basic threshold is cloned",
|
|
threshold: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
},
|
|
{
|
|
name: "zero values threshold",
|
|
threshold: &HysteresisThreshold{Trigger: 0, Clear: 0},
|
|
},
|
|
{
|
|
name: "large values threshold",
|
|
threshold: &HysteresisThreshold{Trigger: 100, Clear: 95},
|
|
},
|
|
{
|
|
name: "fractional values threshold",
|
|
threshold: &HysteresisThreshold{Trigger: 85.5, Clear: 80.25},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
result := cloneThreshold(tc.threshold)
|
|
|
|
if tc.threshold == nil {
|
|
if result != nil {
|
|
t.Errorf("cloneThreshold(nil) = %v, want nil", result)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Result should not be nil
|
|
if result == nil {
|
|
t.Fatalf("cloneThreshold() returned nil for non-nil input")
|
|
}
|
|
|
|
// Result should be a different pointer
|
|
if result == tc.threshold {
|
|
t.Error("cloneThreshold() returned same pointer, not a clone")
|
|
}
|
|
|
|
// Values should match
|
|
if result.Trigger != tc.threshold.Trigger {
|
|
t.Errorf("cloneThreshold().Trigger = %v, want %v", result.Trigger, tc.threshold.Trigger)
|
|
}
|
|
if result.Clear != tc.threshold.Clear {
|
|
t.Errorf("cloneThreshold().Clear = %v, want %v", result.Clear, tc.threshold.Clear)
|
|
}
|
|
|
|
// Modifying clone should not affect original
|
|
result.Trigger = 999
|
|
result.Clear = 888
|
|
if tc.threshold.Trigger == 999 || tc.threshold.Clear == 888 {
|
|
t.Error("modifying clone affected original threshold")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCloneStringPtr tests the cloneStringPtr function
|
|
func TestCloneStringPtr(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
value *string
|
|
}{
|
|
{
|
|
name: "nil returns nil",
|
|
value: nil,
|
|
},
|
|
{
|
|
name: "empty string is cloned",
|
|
value: strPtr(""),
|
|
},
|
|
{
|
|
name: "basic string is cloned",
|
|
value: strPtr("hello"),
|
|
},
|
|
{
|
|
name: "string with spaces",
|
|
value: strPtr("hello world"),
|
|
},
|
|
{
|
|
name: "unicode string",
|
|
value: strPtr("こんにちは"),
|
|
},
|
|
{
|
|
name: "long string",
|
|
value: strPtr(strings.Repeat("a", 1000)),
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
result := cloneStringPtr(tc.value)
|
|
|
|
if tc.value == nil {
|
|
if result != nil {
|
|
t.Errorf("cloneStringPtr(nil) = %v, want nil", result)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Result should not be nil
|
|
if result == nil {
|
|
t.Fatalf("cloneStringPtr() returned nil for non-nil input")
|
|
}
|
|
|
|
// Result should be a different pointer
|
|
if result == tc.value {
|
|
t.Error("cloneStringPtr() returned same pointer, not a clone")
|
|
}
|
|
|
|
// Values should match
|
|
if *result != *tc.value {
|
|
t.Errorf("cloneStringPtr() value = %q, want %q", *result, *tc.value)
|
|
}
|
|
|
|
// Modifying clone should not affect original
|
|
originalValue := *tc.value
|
|
*result = "modified"
|
|
if *tc.value != originalValue {
|
|
t.Error("modifying clone affected original string")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// strPtr is a helper to create a string pointer
|
|
func strPtr(s string) *string {
|
|
return &s
|
|
}
|
|
|
|
// TestCloneThresholdConfig tests the cloneThresholdConfig function
|
|
func TestCloneThresholdConfig(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
config ThresholdConfig
|
|
}{
|
|
{
|
|
name: "empty config",
|
|
config: ThresholdConfig{},
|
|
},
|
|
{
|
|
name: "config with CPU threshold",
|
|
config: ThresholdConfig{
|
|
CPU: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
},
|
|
},
|
|
{
|
|
name: "config with all thresholds",
|
|
config: ThresholdConfig{
|
|
CPU: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
Memory: &HysteresisThreshold{Trigger: 85, Clear: 75},
|
|
Disk: &HysteresisThreshold{Trigger: 90, Clear: 85},
|
|
DiskRead: &HysteresisThreshold{Trigger: 50, Clear: 40},
|
|
DiskWrite: &HysteresisThreshold{Trigger: 50, Clear: 40},
|
|
NetworkIn: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
NetworkOut: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
Temperature: &HysteresisThreshold{Trigger: 70, Clear: 60},
|
|
Usage: &HysteresisThreshold{Trigger: 85, Clear: 75},
|
|
},
|
|
},
|
|
{
|
|
name: "config with note",
|
|
config: ThresholdConfig{
|
|
CPU: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
Note: strPtr("Test note for this config"),
|
|
},
|
|
},
|
|
{
|
|
name: "config with disabled flag",
|
|
config: ThresholdConfig{
|
|
Disabled: true,
|
|
CPU: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
},
|
|
},
|
|
{
|
|
name: "config with disable connectivity flag",
|
|
config: ThresholdConfig{
|
|
DisableConnectivity: true,
|
|
Memory: &HysteresisThreshold{Trigger: 90, Clear: 80},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
result := cloneThresholdConfig(tc.config)
|
|
|
|
// Check disabled flags
|
|
if result.Disabled != tc.config.Disabled {
|
|
t.Errorf("Disabled = %v, want %v", result.Disabled, tc.config.Disabled)
|
|
}
|
|
if result.DisableConnectivity != tc.config.DisableConnectivity {
|
|
t.Errorf("DisableConnectivity = %v, want %v", result.DisableConnectivity, tc.config.DisableConnectivity)
|
|
}
|
|
|
|
// Check that threshold pointers are different but values match
|
|
checkThresholdClone(t, "CPU", result.CPU, tc.config.CPU)
|
|
checkThresholdClone(t, "Memory", result.Memory, tc.config.Memory)
|
|
checkThresholdClone(t, "Disk", result.Disk, tc.config.Disk)
|
|
checkThresholdClone(t, "DiskRead", result.DiskRead, tc.config.DiskRead)
|
|
checkThresholdClone(t, "DiskWrite", result.DiskWrite, tc.config.DiskWrite)
|
|
checkThresholdClone(t, "NetworkIn", result.NetworkIn, tc.config.NetworkIn)
|
|
checkThresholdClone(t, "NetworkOut", result.NetworkOut, tc.config.NetworkOut)
|
|
checkThresholdClone(t, "Temperature", result.Temperature, tc.config.Temperature)
|
|
checkThresholdClone(t, "Usage", result.Usage, tc.config.Usage)
|
|
|
|
// Check Note cloning
|
|
if tc.config.Note == nil {
|
|
if result.Note != nil {
|
|
t.Errorf("Note should be nil")
|
|
}
|
|
} else {
|
|
if result.Note == nil {
|
|
t.Errorf("Note should not be nil")
|
|
} else if result.Note == tc.config.Note {
|
|
t.Error("Note pointer should be different")
|
|
} else if *result.Note != *tc.config.Note {
|
|
t.Errorf("Note value = %q, want %q", *result.Note, *tc.config.Note)
|
|
}
|
|
}
|
|
|
|
// Verify modifying clone doesn't affect original
|
|
if result.CPU != nil {
|
|
result.CPU.Trigger = 999
|
|
if tc.config.CPU != nil && tc.config.CPU.Trigger == 999 {
|
|
t.Error("modifying cloned CPU affected original")
|
|
}
|
|
}
|
|
if result.Note != nil {
|
|
*result.Note = "modified"
|
|
if tc.config.Note != nil && *tc.config.Note == "modified" {
|
|
t.Error("modifying cloned Note affected original")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// checkThresholdClone is a helper to verify a threshold was properly cloned
|
|
func checkThresholdClone(t *testing.T, name string, result, original *HysteresisThreshold) {
|
|
t.Helper()
|
|
if original == nil {
|
|
if result != nil {
|
|
t.Errorf("%s should be nil", name)
|
|
}
|
|
return
|
|
}
|
|
if result == nil {
|
|
t.Errorf("%s should not be nil", name)
|
|
return
|
|
}
|
|
if result == original {
|
|
t.Errorf("%s pointer should be different", name)
|
|
}
|
|
if result.Trigger != original.Trigger {
|
|
t.Errorf("%s.Trigger = %v, want %v", name, result.Trigger, original.Trigger)
|
|
}
|
|
if result.Clear != original.Clear {
|
|
t.Errorf("%s.Clear = %v, want %v", name, result.Clear, original.Clear)
|
|
}
|
|
}
|
|
|
|
// TestEnsureHysteresisThreshold tests the ensureHysteresisThreshold function
|
|
func TestEnsureHysteresisThreshold(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
threshold *HysteresisThreshold
|
|
wantTrigger float64
|
|
wantClear float64
|
|
}{
|
|
{
|
|
name: "nil threshold returns nil",
|
|
threshold: nil,
|
|
},
|
|
{
|
|
name: "threshold with valid clear unchanged",
|
|
threshold: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
wantTrigger: 80,
|
|
wantClear: 70,
|
|
},
|
|
{
|
|
name: "threshold with zero clear gets default",
|
|
threshold: &HysteresisThreshold{Trigger: 80, Clear: 0},
|
|
wantTrigger: 80,
|
|
wantClear: 75, // 80 - 5
|
|
},
|
|
{
|
|
name: "threshold with negative clear gets default",
|
|
threshold: &HysteresisThreshold{Trigger: 80, Clear: -10},
|
|
wantTrigger: 80,
|
|
wantClear: 75, // 80 - 5
|
|
},
|
|
{
|
|
name: "low trigger value",
|
|
threshold: &HysteresisThreshold{Trigger: 10, Clear: 0},
|
|
wantTrigger: 10,
|
|
wantClear: 5, // 10 - 5
|
|
},
|
|
{
|
|
name: "trigger at 5 with zero clear",
|
|
threshold: &HysteresisThreshold{Trigger: 5, Clear: 0},
|
|
wantTrigger: 5,
|
|
wantClear: 0, // 5 - 5 = 0
|
|
},
|
|
{
|
|
name: "trigger below 5 with zero clear",
|
|
threshold: &HysteresisThreshold{Trigger: 3, Clear: 0},
|
|
wantTrigger: 3,
|
|
wantClear: -2, // 3 - 5 = -2 (function doesn't clamp)
|
|
},
|
|
{
|
|
name: "trigger at 100 with zero clear",
|
|
threshold: &HysteresisThreshold{Trigger: 100, Clear: 0},
|
|
wantTrigger: 100,
|
|
wantClear: 95, // 100 - 5
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
result := ensureHysteresisThreshold(tc.threshold)
|
|
|
|
if tc.threshold == nil {
|
|
if result != nil {
|
|
t.Errorf("ensureHysteresisThreshold(nil) = %v, want nil", result)
|
|
}
|
|
return
|
|
}
|
|
|
|
if result == nil {
|
|
t.Fatal("ensureHysteresisThreshold returned nil for non-nil input")
|
|
}
|
|
|
|
// Note: function modifies in place, so result == tc.threshold
|
|
if result.Trigger != tc.wantTrigger {
|
|
t.Errorf("Trigger = %v, want %v", result.Trigger, tc.wantTrigger)
|
|
}
|
|
if result.Clear != tc.wantClear {
|
|
t.Errorf("Clear = %v, want %v", result.Clear, tc.wantClear)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParsePulseTags tests the parsePulseTags function
|
|
func TestParsePulseTags(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
tags []string
|
|
want pulseTagSettings
|
|
}{
|
|
{
|
|
name: "nil tags",
|
|
tags: nil,
|
|
want: pulseTagSettings{},
|
|
},
|
|
{
|
|
name: "empty tags",
|
|
tags: []string{},
|
|
want: pulseTagSettings{},
|
|
},
|
|
{
|
|
name: "no pulse tags",
|
|
tags: []string{"production", "web-server", "ubuntu"},
|
|
want: pulseTagSettings{},
|
|
},
|
|
{
|
|
name: "pulse-no-alerts tag",
|
|
tags: []string{"pulse-no-alerts"},
|
|
want: pulseTagSettings{Suppress: true},
|
|
},
|
|
{
|
|
name: "pulse-monitor-only tag",
|
|
tags: []string{"pulse-monitor-only"},
|
|
want: pulseTagSettings{MonitorOnly: true},
|
|
},
|
|
{
|
|
name: "pulse-relaxed tag",
|
|
tags: []string{"pulse-relaxed"},
|
|
want: pulseTagSettings{Relaxed: true},
|
|
},
|
|
{
|
|
name: "all pulse tags",
|
|
tags: []string{"pulse-no-alerts", "pulse-monitor-only", "pulse-relaxed"},
|
|
want: pulseTagSettings{Suppress: true, MonitorOnly: true, Relaxed: true},
|
|
},
|
|
{
|
|
name: "mixed tags with pulse tags",
|
|
tags: []string{"production", "pulse-no-alerts", "web-server", "pulse-relaxed"},
|
|
want: pulseTagSettings{Suppress: true, Relaxed: true},
|
|
},
|
|
{
|
|
name: "uppercase pulse tag",
|
|
tags: []string{"PULSE-NO-ALERTS"},
|
|
want: pulseTagSettings{Suppress: true},
|
|
},
|
|
{
|
|
name: "mixed case pulse tag",
|
|
tags: []string{"Pulse-Monitor-Only"},
|
|
want: pulseTagSettings{MonitorOnly: true},
|
|
},
|
|
{
|
|
name: "pulse tag with whitespace",
|
|
tags: []string{" pulse-no-alerts "},
|
|
want: pulseTagSettings{Suppress: true},
|
|
},
|
|
{
|
|
name: "pulse tag with leading whitespace",
|
|
tags: []string{" pulse-relaxed"},
|
|
want: pulseTagSettings{Relaxed: true},
|
|
},
|
|
{
|
|
name: "pulse tag with trailing whitespace",
|
|
tags: []string{"pulse-monitor-only "},
|
|
want: pulseTagSettings{MonitorOnly: true},
|
|
},
|
|
{
|
|
name: "similar but not pulse tag",
|
|
tags: []string{"pulse-alerts", "pulse-monitor", "pulse"},
|
|
want: pulseTagSettings{},
|
|
},
|
|
{
|
|
name: "pulse tag substring does not match",
|
|
tags: []string{"mypulse-no-alerts", "pulse-no-alerts-extra"},
|
|
want: pulseTagSettings{},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
result := parsePulseTags(tc.tags)
|
|
|
|
if result.Suppress != tc.want.Suppress {
|
|
t.Errorf("Suppress = %v, want %v", result.Suppress, tc.want.Suppress)
|
|
}
|
|
if result.MonitorOnly != tc.want.MonitorOnly {
|
|
t.Errorf("MonitorOnly = %v, want %v", result.MonitorOnly, tc.want.MonitorOnly)
|
|
}
|
|
if result.Relaxed != tc.want.Relaxed {
|
|
t.Errorf("Relaxed = %v, want %v", result.Relaxed, tc.want.Relaxed)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNormalizeMetricTimeThresholds tests the normalizeMetricTimeThresholds function
|
|
func TestNormalizeMetricTimeThresholds(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input map[string]map[string]int
|
|
want map[string]map[string]int
|
|
}{
|
|
{
|
|
name: "nil input returns nil",
|
|
input: nil,
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "empty input returns nil",
|
|
input: map[string]map[string]int{},
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "valid input is normalized",
|
|
input: map[string]map[string]int{
|
|
"guest": {"cpu": 60, "memory": 120},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"guest": {"cpu": 60, "memory": 120},
|
|
},
|
|
},
|
|
{
|
|
name: "keys are lowercased",
|
|
input: map[string]map[string]int{
|
|
"GUEST": {"CPU": 60, "MEMORY": 120},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"guest": {"cpu": 60, "memory": 120},
|
|
},
|
|
},
|
|
{
|
|
name: "keys are trimmed",
|
|
input: map[string]map[string]int{
|
|
" guest ": {" cpu ": 60},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"guest": {"cpu": 60},
|
|
},
|
|
},
|
|
{
|
|
name: "negative delays are dropped",
|
|
input: map[string]map[string]int{
|
|
"guest": {"cpu": 60, "memory": -1},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"guest": {"cpu": 60},
|
|
},
|
|
},
|
|
{
|
|
name: "zero delay is valid",
|
|
input: map[string]map[string]int{
|
|
"guest": {"cpu": 0},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"guest": {"cpu": 0},
|
|
},
|
|
},
|
|
{
|
|
name: "empty type key is dropped",
|
|
input: map[string]map[string]int{
|
|
"": {"cpu": 60},
|
|
"guest": {"memory": 120},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"guest": {"memory": 120},
|
|
},
|
|
},
|
|
{
|
|
name: "whitespace-only type key is dropped",
|
|
input: map[string]map[string]int{
|
|
" ": {"cpu": 60},
|
|
"guest": {"memory": 120},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"guest": {"memory": 120},
|
|
},
|
|
},
|
|
{
|
|
name: "empty metric key is dropped",
|
|
input: map[string]map[string]int{
|
|
"guest": {"": 60, "cpu": 120},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"guest": {"cpu": 120},
|
|
},
|
|
},
|
|
{
|
|
name: "empty metrics map is dropped",
|
|
input: map[string]map[string]int{
|
|
"guest": {},
|
|
"node": {"cpu": 60},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"node": {"cpu": 60},
|
|
},
|
|
},
|
|
{
|
|
name: "all invalid results in nil",
|
|
input: map[string]map[string]int{
|
|
"": {"cpu": 60},
|
|
"guest": {"": 60, "memory": -1},
|
|
},
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "multiple resource types",
|
|
input: map[string]map[string]int{
|
|
"guest": {"cpu": 60, "memory": 120},
|
|
"node": {"disk": 30},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"guest": {"cpu": 60, "memory": 120},
|
|
"node": {"disk": 30},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
result := NormalizeMetricTimeThresholds(tc.input)
|
|
|
|
if tc.want == nil {
|
|
if result != nil {
|
|
t.Errorf("normalizeMetricTimeThresholds() = %v, want nil", result)
|
|
}
|
|
return
|
|
}
|
|
|
|
if result == nil {
|
|
t.Fatalf("normalizeMetricTimeThresholds() = nil, want %v", tc.want)
|
|
}
|
|
|
|
// Check all expected keys exist with correct values
|
|
for typeKey, metrics := range tc.want {
|
|
resultMetrics, exists := result[typeKey]
|
|
if !exists {
|
|
t.Errorf("missing type key %q", typeKey)
|
|
continue
|
|
}
|
|
for metricKey, delay := range metrics {
|
|
resultDelay, exists := resultMetrics[metricKey]
|
|
if !exists {
|
|
t.Errorf("missing metric key %q for type %q", metricKey, typeKey)
|
|
continue
|
|
}
|
|
if resultDelay != delay {
|
|
t.Errorf("result[%q][%q] = %d, want %d", typeKey, metricKey, resultDelay, delay)
|
|
}
|
|
}
|
|
// Check no extra metric keys
|
|
if len(resultMetrics) != len(metrics) {
|
|
t.Errorf("result[%q] has %d keys, want %d", typeKey, len(resultMetrics), len(metrics))
|
|
}
|
|
}
|
|
// Check no extra type keys
|
|
if len(result) != len(tc.want) {
|
|
t.Errorf("result has %d type keys, want %d", len(result), len(tc.want))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetThresholdForMetric tests the getThresholdForMetric function
|
|
func TestGetThresholdForMetric(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
cpuThreshold := &HysteresisThreshold{Trigger: 80, Clear: 70}
|
|
memoryThreshold := &HysteresisThreshold{Trigger: 85, Clear: 75}
|
|
diskThreshold := &HysteresisThreshold{Trigger: 90, Clear: 85}
|
|
diskReadThreshold := &HysteresisThreshold{Trigger: 50, Clear: 40}
|
|
diskWriteThreshold := &HysteresisThreshold{Trigger: 55, Clear: 45}
|
|
networkInThreshold := &HysteresisThreshold{Trigger: 70, Clear: 60}
|
|
networkOutThreshold := &HysteresisThreshold{Trigger: 75, Clear: 65}
|
|
temperatureThreshold := &HysteresisThreshold{Trigger: 65, Clear: 55}
|
|
usageThreshold := &HysteresisThreshold{Trigger: 88, Clear: 78}
|
|
|
|
config := ThresholdConfig{
|
|
CPU: cpuThreshold,
|
|
Memory: memoryThreshold,
|
|
Disk: diskThreshold,
|
|
DiskRead: diskReadThreshold,
|
|
DiskWrite: diskWriteThreshold,
|
|
NetworkIn: networkInThreshold,
|
|
NetworkOut: networkOutThreshold,
|
|
Temperature: temperatureThreshold,
|
|
Usage: usageThreshold,
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
metricType string
|
|
want *HysteresisThreshold
|
|
}{
|
|
{"cpu", "cpu", cpuThreshold},
|
|
{"memory", "memory", memoryThreshold},
|
|
{"disk", "disk", diskThreshold},
|
|
{"diskRead", "diskRead", diskReadThreshold},
|
|
{"diskWrite", "diskWrite", diskWriteThreshold},
|
|
{"networkIn", "networkIn", networkInThreshold},
|
|
{"networkOut", "networkOut", networkOutThreshold},
|
|
{"temperature", "temperature", temperatureThreshold},
|
|
{"usage", "usage", usageThreshold},
|
|
{"unknown metric", "unknown", nil},
|
|
{"empty metric", "", nil},
|
|
{"case sensitive - CPU", "CPU", nil},
|
|
{"case sensitive - Memory", "Memory", nil},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
result := getThresholdForMetric(config, tc.metricType)
|
|
if result != tc.want {
|
|
t.Errorf("getThresholdForMetric(%q) = %v, want %v", tc.metricType, result, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetThresholdForMetric_EmptyConfig tests getThresholdForMetric with empty config
|
|
func TestGetThresholdForMetric_EmptyConfig(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
config := ThresholdConfig{}
|
|
|
|
metricTypes := []string{"cpu", "memory", "disk", "diskRead", "diskWrite", "networkIn", "networkOut", "temperature", "usage"}
|
|
|
|
for _, metricType := range metricTypes {
|
|
t.Run(metricType, func(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
result := getThresholdForMetric(config, metricType)
|
|
if result != nil {
|
|
t.Errorf("getThresholdForMetric(%q) with empty config = %v, want nil", metricType, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetThresholdForMetricFromConfig tests the getThresholdForMetricFromConfig function
|
|
func TestGetThresholdForMetricFromConfig(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
config ThresholdConfig
|
|
metricType string
|
|
wantNil bool
|
|
wantTrigger float64
|
|
wantClear float64
|
|
}{
|
|
{
|
|
name: "empty config returns nil",
|
|
config: ThresholdConfig{},
|
|
metricType: "cpu",
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "cpu threshold returned",
|
|
config: ThresholdConfig{
|
|
CPU: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
},
|
|
metricType: "cpu",
|
|
wantTrigger: 80,
|
|
wantClear: 70,
|
|
},
|
|
{
|
|
name: "memory threshold returned",
|
|
config: ThresholdConfig{
|
|
Memory: &HysteresisThreshold{Trigger: 85, Clear: 75},
|
|
},
|
|
metricType: "memory",
|
|
wantTrigger: 85,
|
|
wantClear: 75,
|
|
},
|
|
{
|
|
name: "disk threshold returned",
|
|
config: ThresholdConfig{
|
|
Disk: &HysteresisThreshold{Trigger: 90, Clear: 85},
|
|
},
|
|
metricType: "disk",
|
|
wantTrigger: 90,
|
|
wantClear: 85,
|
|
},
|
|
{
|
|
name: "diskRead threshold returned",
|
|
config: ThresholdConfig{
|
|
DiskRead: &HysteresisThreshold{Trigger: 50, Clear: 40},
|
|
},
|
|
metricType: "diskRead",
|
|
wantTrigger: 50,
|
|
wantClear: 40,
|
|
},
|
|
{
|
|
name: "diskWrite threshold returned",
|
|
config: ThresholdConfig{
|
|
DiskWrite: &HysteresisThreshold{Trigger: 55, Clear: 45},
|
|
},
|
|
metricType: "diskWrite",
|
|
wantTrigger: 55,
|
|
wantClear: 45,
|
|
},
|
|
{
|
|
name: "networkIn threshold returned",
|
|
config: ThresholdConfig{
|
|
NetworkIn: &HysteresisThreshold{Trigger: 70, Clear: 60},
|
|
},
|
|
metricType: "networkIn",
|
|
wantTrigger: 70,
|
|
wantClear: 60,
|
|
},
|
|
{
|
|
name: "networkOut threshold returned",
|
|
config: ThresholdConfig{
|
|
NetworkOut: &HysteresisThreshold{Trigger: 75, Clear: 65},
|
|
},
|
|
metricType: "networkOut",
|
|
wantTrigger: 75,
|
|
wantClear: 65,
|
|
},
|
|
{
|
|
name: "temperature threshold returned",
|
|
config: ThresholdConfig{
|
|
Temperature: &HysteresisThreshold{Trigger: 65, Clear: 55},
|
|
},
|
|
metricType: "temperature",
|
|
wantTrigger: 65,
|
|
wantClear: 55,
|
|
},
|
|
{
|
|
name: "usage threshold returned",
|
|
config: ThresholdConfig{
|
|
Usage: &HysteresisThreshold{Trigger: 88, Clear: 78},
|
|
},
|
|
metricType: "usage",
|
|
wantTrigger: 88,
|
|
wantClear: 78,
|
|
},
|
|
{
|
|
name: "unknown metric returns nil",
|
|
config: ThresholdConfig{
|
|
CPU: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
},
|
|
metricType: "unknown",
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "threshold with zero clear gets default hysteresis",
|
|
config: ThresholdConfig{
|
|
CPU: &HysteresisThreshold{Trigger: 80, Clear: 0},
|
|
},
|
|
metricType: "cpu",
|
|
wantTrigger: 80,
|
|
wantClear: 75, // 80 - 5 default margin
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
result := getThresholdForMetricFromConfig(tc.config, tc.metricType)
|
|
|
|
if tc.wantNil {
|
|
if result != nil {
|
|
t.Errorf("getThresholdForMetricFromConfig() = %v, want nil", result)
|
|
}
|
|
return
|
|
}
|
|
|
|
if result == nil {
|
|
t.Fatalf("getThresholdForMetricFromConfig() = nil, want non-nil")
|
|
}
|
|
|
|
if result.Trigger != tc.wantTrigger {
|
|
t.Errorf("Trigger = %v, want %v", result.Trigger, tc.wantTrigger)
|
|
}
|
|
if result.Clear != tc.wantClear {
|
|
t.Errorf("Clear = %v, want %v", result.Clear, tc.wantClear)
|
|
}
|
|
})
|
|
}
|
|
}
|