pulse-lets-go/internal/engine/engine_test.go
2026-06-25 14:27:41 +07:00

341 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package engine
import (
"testing"
"time"
"github.com/pulse-lets-go/internal/config"
"github.com/pulse-lets-go/internal/models"
)
func defaultCfg() *config.Config {
return &config.Config{
StaleThresholdSec: 20,
Scoring: config.ScoringConfig{
IdleCPUMin: 5.0,
CallFailureRateLethal: 15.0,
Weights: config.ScoringWeights{
CallScore: 0.40,
LoadScore: 0.30,
IdleScore: 0.20,
FailScore: 0.10,
},
},
}
}
func freshMetric(nodeID string) *models.NodeMetric {
return &models.NodeMetric{
NodeID: nodeID,
TS: time.Now().Unix(),
Status: "ok",
ActiveCalls: 42,
MaxCalls: 250,
IdleCPU: 35,
LoadAvg: 0.85,
CallFailureRate: 0.5,
}
}
func TestNewEngine(t *testing.T) {
eng := NewEngine(defaultCfg())
if eng == nil {
t.Fatal("ожидался engine, получен nil")
}
}
func TestUpdateMetric_CreatesNode(t *testing.T) {
eng := NewEngine(defaultCfg())
m := freshMetric("pbx-01")
ns := eng.UpdateMetric(m)
if ns.NodeID != "pbx-01" {
t.Errorf("ожидался node_id=pbx-01, получен %s", ns.NodeID)
}
if len(eng.nodes) != 1 {
t.Errorf("ожидалась 1 нода, получено %d", len(eng.nodes))
}
}
func TestUpdateMetric_PreservesDisabled(t *testing.T) {
eng := NewEngine(defaultCfg())
m := freshMetric("pbx-01")
eng.UpdateMetric(m)
// Отключаем ноду вручную
eng.ToggleNode("pbx-01", true, "test")
eng.UpdateMetric(m)
ns, _ := eng.GetNodeInfo("pbx-01")
if !ns.Disabled {
t.Error("ожидалось disabled=true")
}
if ns.DisabledReason != "test" {
t.Errorf("ожидалась причина 'test', получена '%s'", ns.DisabledReason)
}
// Включаем обратно
eng.ToggleNode("pbx-01", false, "")
eng.UpdateMetric(m)
ns, _ = eng.GetNodeInfo("pbx-01")
if ns.Disabled {
t.Error("ожидалось disabled=false")
}
}
func TestScore_LethalDisabled(t *testing.T) {
eng := NewEngine(defaultCfg())
m := freshMetric("pbx-01")
eng.UpdateMetric(m)
eng.ToggleNode("pbx-01", true, "maintenance")
ns, _ := eng.GetNodeInfo("pbx-01")
if ns.Score != -100 {
t.Errorf("disabled нода должна иметь score=-100, получен %.0f", ns.Score)
}
}
func TestScore_LethalStatusNotOk(t *testing.T) {
eng := NewEngine(defaultCfg())
m := freshMetric("pbx-01")
m.Status = "down"
eng.UpdateMetric(m)
ns, _ := eng.GetNodeInfo("pbx-01")
if ns.Score != -100 {
t.Errorf("status==down нода должна иметь score=-100, получен %.0f", ns.Score)
}
}
func TestScore_LethalStale(t *testing.T) {
eng := NewEngine(defaultCfg())
m := freshMetric("pbx-01")
// Устанавливаем ts на 30 секунд назад (stale_threshold=20)
m.TS = time.Now().Unix() - 30
eng.UpdateMetric(m)
ns, _ := eng.GetNodeInfo("pbx-01")
if ns.Score != -100 {
t.Errorf("stale нода должна иметь score=-100, получен %.0f", ns.Score)
}
}
func TestScore_LethalMaxCalls(t *testing.T) {
eng := NewEngine(defaultCfg())
m := freshMetric("pbx-01")
m.ActiveCalls = 250
m.MaxCalls = 250
eng.UpdateMetric(m)
ns, _ := eng.GetNodeInfo("pbx-01")
if ns.Score != -100 {
t.Errorf("нода с исчерпанной ёмкостью должна иметь score=-100, получен %.0f", ns.Score)
}
}
func TestScore_LethalIdleCPU(t *testing.T) {
eng := NewEngine(defaultCfg())
m := freshMetric("pbx-01")
m.IdleCPU = 2.0
eng.UpdateMetric(m)
ns, _ := eng.GetNodeInfo("pbx-01")
if ns.Score != -100 {
t.Errorf("нода с idle_cpu < 5 должна иметь score=-100, получен %.0f", ns.Score)
}
}
func TestScore_LethalFailureRate(t *testing.T) {
eng := NewEngine(defaultCfg())
m := freshMetric("pbx-01")
m.CallFailureRate = 20.0
eng.UpdateMetric(m)
ns, _ := eng.GetNodeInfo("pbx-01")
if ns.Score != -100 {
t.Errorf("нода с call_failure_rate > 15 должна иметь score=-100, получен %.0f", ns.Score)
}
}
func TestScore_Healthy(t *testing.T) {
eng := NewEngine(defaultCfg())
m := freshMetric("pbx-01")
ns := eng.UpdateMetric(m)
if ns.Score <= 0 {
t.Errorf("здоровая нода должна иметь положительный score, получен %.0f", ns.Score)
}
}
func TestScore_HealthyFormula(t *testing.T) {
eng := NewEngine(defaultCfg())
m := freshMetric("pbx-01")
m.ActiveCalls = 0
m.MaxCalls = 100
m.IdleCPU = 100
m.LoadAvg = 0
m.CallFailureRate = 0
ns := eng.UpdateMetric(m)
// Все компоненты = 100
// score = 100*0.4 + 100*0.3 + 100*0.2 + 100*0.1 = 100
if ns.Score != 100.0 {
t.Errorf("идеальная нода должна иметь score=100, получен %.1f", ns.Score)
}
}
func TestScore_WeightedFormula(t *testing.T) {
eng := NewEngine(defaultCfg())
m := freshMetric("pbx-01")
m.ActiveCalls = 50
m.MaxCalls = 100
m.IdleCPU = 50
m.LoadAvg = 1.0
m.CallFailureRate = 5.0
ns := eng.UpdateMetric(m)
// call_score = 100 - (50/100 * 100) = 50
// load_score = 100 - (1.0 * 50) = 50
// idle_score = 50
// fail_score = 100 - 5 = 95
// score = 50*0.4 + 50*0.3 + 50*0.2 + 95*0.1 = 20+15+10+9.5 = 54.5
expected := 54.5
if ns.Score < expected-0.1 || ns.Score > expected+0.1 {
t.Errorf("ожидался score=%.1f, получен %.1f", expected, ns.Score)
}
}
func TestGetBestNode_NoNodes(t *testing.T) {
eng := NewEngine(defaultCfg())
_, _, fallback := eng.GetBestNode()
if !fallback {
t.Error("без нод должен быть fallback")
}
}
func TestGetBestNode_OneNode(t *testing.T) {
eng := NewEngine(defaultCfg())
eng.UpdateMetric(freshMetric("pbx-01"))
id, score, fallback := eng.GetBestNode()
if fallback {
t.Error("не должно быть fallback с одной здоровой нодой")
}
if id != "pbx-01" {
t.Errorf("ожидалась pbx-01, получена %s", id)
}
if score <= 0 {
t.Errorf("ожидался положительный score, получен %.0f", score)
}
}
func TestGetBestNode_AllUnhealthy(t *testing.T) {
eng := NewEngine(defaultCfg())
m1 := freshMetric("pbx-01")
m1.Status = "down"
eng.UpdateMetric(m1)
m2 := freshMetric("pbx-02")
m2.ActiveCalls = 500
m2.MaxCalls = 500
eng.UpdateMetric(m2)
_, _, fallback := eng.GetBestNode()
if !fallback {
t.Error("все ноды unhealthy — должен быть fallback")
}
}
func TestGetBestNode_MultipleNodes(t *testing.T) {
eng := NewEngine(defaultCfg())
// pbx-01: 80% загружена (меньше score)
m1 := freshMetric("pbx-01")
m1.ActiveCalls = 200
m1.MaxCalls = 250
eng.UpdateMetric(m1)
// pbx-02: 20% загружена (выше score)
m2 := freshMetric("pbx-02")
m2.ActiveCalls = 20
m2.MaxCalls = 250
eng.UpdateMetric(m2)
id, _, _ := eng.GetBestNode()
if id != "pbx-02" {
t.Errorf("ожидалась pbx-02 (меньше загрузки), получена %s", id)
}
}
func TestGetNodeHistory(t *testing.T) {
eng := NewEngine(defaultCfg())
eng.UpdateMetric(freshMetric("pbx-01"))
// Повторяем обновление для накопления истории
for i := 0; i < 5; i++ {
m := freshMetric("pbx-01")
m.ActiveCalls = i * 10
eng.UpdateMetric(m)
}
history := eng.GetNodeHistory("pbx-01")
if len(history) < 3 {
t.Errorf("ожидалось минимум 3 снапшота, получено %d", len(history))
}
}
func TestGetNodeHistory_NoNode(t *testing.T) {
eng := NewEngine(defaultCfg())
history := eng.GetNodeHistory("nonexistent")
if history != nil {
t.Error("для несуществующей ноды ожидался nil")
}
}
func TestToggleNode_NotFound(t *testing.T) {
eng := NewEngine(defaultCfg())
err := eng.ToggleNode("nonexistent", true, "test")
if err == nil {
t.Error("ожидалась ошибка для несуществующей ноды")
}
}
func TestUpdateConfig(t *testing.T) {
eng := NewEngine(defaultCfg())
m := freshMetric("pbx-01")
eng.UpdateMetric(m)
// Меняем конфиг на более строгий
newCfg := defaultCfg()
newCfg.Scoring.CallFailureRateLethal = 0.1
newCfg.Scoring.IdleCPUMin = 50
eng.UpdateConfig(newCfg)
ns, _ := eng.GetNodeInfo("pbx-01")
if ns.Score != -100 {
t.Errorf("после ужесточения конфига нода должна стать unhealthy, получен score=%.0f", ns.Score)
}
}
func TestClamp(t *testing.T) {
tests := []struct {
v, min, max, expected float64
}{
{50, 0, 100, 50},
{-10, 0, 100, 0},
{150, 0, 100, 100},
{0, 0, 0, 0},
}
for _, tt := range tests {
got := clamp(tt.v, tt.min, tt.max)
if got != tt.expected {
t.Errorf("clamp(%.0f, %.0f, %.0f) = %.0f, ожидалось %.0f",
tt.v, tt.min, tt.max, got, tt.expected)
}
}
}