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) } } }