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) } } } func cfgWithEWMA(smoothing float64) *config.Config { cfg := defaultCfg() cfg.Scoring.SmoothingFactor = smoothing cfg.Scoring.LoadAvgMultiplier = 50.0 return cfg } func TestScore_EWMASmoothing(t *testing.T) { eng := NewEngine(cfgWithEWMA(0.3)) // Первый замер: score = rawScore (без сглаживания) m := freshMetric("pbx-01") m.ActiveCalls = 0 m.MaxCalls = 100 m.IdleCPU = 100 m.LoadAvg = 0 m.CallFailureRate = 0 ns1 := eng.UpdateMetric(m) if ns1.Score != 100.0 { t.Errorf("первый замер должен давать raw score=100 без EWMA, получен %.1f", ns1.Score) } // Второй замер: резкое падение качества m2 := freshMetric("pbx-01") m2.ActiveCalls = 50 m2.MaxCalls = 100 m2.IdleCPU = 50 m2.LoadAvg = 1.0 m2.CallFailureRate = 5.0 // raw = 50*0.4 + 50*0.3 + 50*0.2 + 95*0.1 = 54.5 // EWMA: 100*(1-0.3) + 54.5*0.3 = 70 + 16.35 = 86.35 ns2 := eng.UpdateMetric(m2) if ns2.Score < 86.0 || ns2.Score > 86.7 { t.Errorf("EWMA score должен быть ~86.35, получен %.1f", ns2.Score) } // Третий замер: возврат к идеальному состоянию m3 := freshMetric("pbx-01") m3.ActiveCalls = 0 m3.MaxCalls = 100 m3.IdleCPU = 100 m3.LoadAvg = 0 m3.CallFailureRate = 0 // raw = 100 // EWMA: 86.35*(1-0.3) + 100*0.3 = 60.445 + 30 = 90.445 ns3 := eng.UpdateMetric(m3) if ns3.Score < 90.0 || ns3.Score > 90.9 { t.Errorf("EWMA score должен быть ~90.45, получен %.1f", ns3.Score) } } func TestScore_EWMADisabled(t *testing.T) { eng := NewEngine(cfgWithEWMA(0.0)) // smoothing=0 → без сглаживания m := freshMetric("pbx-01") m.ActiveCalls = 50 m.MaxCalls = 100 m.IdleCPU = 50 m.LoadAvg = 1.0 m.CallFailureRate = 5.0 // raw = 54.5 ns1 := eng.UpdateMetric(m) if ns1.Score < 54.4 || ns1.Score > 54.6 { t.Errorf("без EWMA score должен быть raw=54.5, получен %.1f", ns1.Score) } // Повторный замер — без EWMA = тот же raw ns2 := eng.UpdateMetric(m) if ns2.Score < 54.4 || ns2.Score > 54.6 { t.Errorf("без EWMA score не должен меняться, получен %.1f", ns2.Score) } } func TestScore_LoadAvgMultiplier(t *testing.T) { eng := NewEngine(cfgWithEWMA(0.0)) m := freshMetric("pbx-01") m.LoadAvg = 2.0 m.CallFailureRate = 0 // load_score = 100 - (2.0 * 50) = 0 ns := eng.UpdateMetric(m) loadScore := 0.0 callScore := clamp(100.0-(float64(m.ActiveCalls)/float64(m.MaxCalls)*100.0), 0, 100) idleScore := clamp(m.IdleCPU, 0, 100) failScore := clamp(100.0-m.CallFailureRate, 0, 100) expected := callScore*0.40 + loadScore*0.30 + idleScore*0.20 + failScore*0.10 if ns.Score < expected-0.1 || ns.Score > expected+0.1 { t.Errorf("с load_avg_multiplier=50 при load_avg=2.0 load_score должен быть 0, получен score %.1f", ns.Score) } } func TestEvictStaleNodes(t *testing.T) { eng := NewEngine(defaultCfg()) // Добавляем свежую ноду m := freshMetric("pbx-01") eng.UpdateMetric(m) // Добавляем старую ноду (метрика 10 минут назад) m2 := freshMetric("pbx-02") m2.TS = time.Now().Add(-10 * time.Minute).Unix() eng.UpdateMetric(m2) // До эвикции — 2 ноды if len(eng.nodes) != 2 { t.Errorf("ожидалось 2 ноды, получено %d", len(eng.nodes)) } // Эвикция нод старше 5 минут eng.evictStaleNodes(5 * time.Minute) // После эвикции — только pbx-01 должна остаться if len(eng.nodes) != 1 { t.Errorf("после эвикции ожидалась 1 нода, получено %d", len(eng.nodes)) } if _, exists := eng.nodes["pbx-02"]; exists { t.Error("pbx-02 должна быть удалена") } if _, exists := eng.rings["pbx-02"]; exists { t.Error("ring buffer pbx-02 должен быть удалён") } } func TestEvictAllNodes_RecalcBest(t *testing.T) { eng := NewEngine(defaultCfg()) // Только старые ноды m := freshMetric("pbx-01") m.TS = time.Now().Add(-10 * time.Minute).Unix() eng.UpdateMetric(m) eng.evictStaleNodes(5 * time.Minute) // Все ноды удалены — best должен быть пустым id, _, fallback := eng.GetBestNode() if id != "" { t.Errorf("после эвикции всех нод bestNodeID должен быть пустым, получен %s", id) } if !fallback { t.Error("после эвикции всех нод должен быть fallback") } } func cfgWithMode(mode string) *config.Config { cfg := defaultCfg() cfg.Scoring.LoadAvgMultiplier = 50.0 cfg.BalanceMode = mode return cfg } // --- PickWeightedRandom --- func TestPickWeightedRandom_NoNodes(t *testing.T) { eng := NewEngine(cfgWithMode("weighted_random")) eng.SetBalancingMode("weighted_random") id, score, fallback := eng.PickNode() if id != "" { t.Errorf("без нод ожидался пустой id, получен %s", id) } if score != 0 { t.Errorf("без нод ожидался score=0, получен %.1f", score) } if !fallback { t.Error("без нод ожидался fallback") } } func TestPickWeightedRandom_OneHealthy(t *testing.T) { eng := NewEngine(cfgWithMode("weighted_random")) eng.SetBalancingMode("weighted_random") eng.UpdateMetric(freshMetric("pbx-01")) id, score, fallback := eng.PickNode() if id != "pbx-01" { t.Errorf("одна здоровая нода — ожидалась pbx-01, получена %s", id) } if score <= 0 { t.Errorf("здоровая нода должна иметь положительный score, получен %.1f", score) } if fallback { t.Error("одна здоровая нода — fallback не должен быть активен") } } func TestPickWeightedRandom_AllUnhealthy(t *testing.T) { eng := NewEngine(cfgWithMode("weighted_random")) eng.SetBalancingMode("weighted_random") m := freshMetric("pbx-01") m.Status = "down" eng.UpdateMetric(m) id, _, fallback := eng.PickNode() if id == "" { t.Error("все ноды unhealthy — id не должен быть пустым (возвращается best из кэша для fallback)") } if !fallback { t.Error("все ноды unhealthy — ожидался fallback") } } func TestPickWeightedRandom_ExcludesDisabled(t *testing.T) { eng := NewEngine(cfgWithMode("weighted_random")) eng.SetBalancingMode("weighted_random") eng.UpdateMetric(freshMetric("pbx-01")) eng.ToggleNode("pbx-01", true, "test") _, _, fallback := eng.PickNode() if !fallback { t.Error("единственная нода disabled — должен быть fallback") } // Добавляем здоровую ноду — disabled исключается eng.UpdateMetric(freshMetric("pbx-02")) found := false for i := 0; i < 20; i++ { id, _, fb := eng.PickNode() if fb { t.Error("есть здоровая нода — не должно быть fallback") } if id == "pbx-01" { t.Error("disabled нода не должна выбираться") } if id == "pbx-02" { found = true } } if !found { t.Error("здоровая нода pbx-02 должна выбираться") } } func TestPickWeightedRandom_ExcludesStale(t *testing.T) { eng := NewEngine(cfgWithMode("weighted_random")) eng.SetBalancingMode("weighted_random") m := freshMetric("pbx-01") m.TS = time.Now().Unix() - 30 // stale при threshold=20 eng.UpdateMetric(m) id, _, fallback := eng.PickNode() if !fallback { t.Error("stale-нода должна приводить к fallback") } if id == "pbx-01" && !fallback { t.Error("stale-нода не должна выбираться") } } func TestPickWeightedRandom_MultipleCandidates(t *testing.T) { eng := NewEngine(cfgWithMode("weighted_random")) eng.SetBalancingMode("weighted_random") m1 := freshMetric("pbx-01") m1.ActiveCalls = 20 m1.MaxCalls = 100 eng.UpdateMetric(m1) m2 := freshMetric("pbx-02") m2.ActiveCalls = 80 m2.MaxCalls = 100 eng.UpdateMetric(m2) healthyNodes := map[string]bool{} for i := 0; i < 100; i++ { id, _, fallback := eng.PickNode() if fallback { t.Error("есть здоровые ноды — не должно быть fallback") } healthyNodes[id] = true } if !healthyNodes["pbx-01"] { t.Error("pbx-01 должна выбираться при weighted random") } if !healthyNodes["pbx-02"] { t.Error("pbx-02 должна выбираться при weighted random") } } func TestPickWeightedRandom_AllScoreZero(t *testing.T) { eng := NewEngine(cfgWithMode("weighted_random")) eng.SetBalancingMode("weighted_random") // Добавляем ноду с score=0 (на грани capacity) m := freshMetric("pbx-01") m.ActiveCalls = 250 m.MaxCalls = 250 // Это lethal condition, score будет -100 // Нужна нода с score=0 но не lethal — используем высокую нагрузку m.ActiveCalls = 0 m.MaxCalls = 1 // не lethal (active < max) m.LoadAvg = 2.0 // load_score = 100 - 2*50 = 0 m.CallFailureRate = 0 m.IdleCPU = 0 // idle_score = 0 // call_score = 100 - (0/1 * 100) = 100 // load_score = 0 // idle_score = 0 // fail_score = 100 // score = 100*0.4 + 0*0.3 + 0*0.2 + 100*0.1 = 50 ← not zero // Let's make load_avg very high to get score 0 m.LoadAvg = 3.0 // load_score = 100 - 150 = -50 → clamped to 0 m.ActiveCalls = 1 m.MaxCalls = 1 // call_score = 0 // So call_score=0, load_score=0, idle_score=0, fail_score=100 // score = 0*0.4 + 0*0.3 + 0*0.2 + 100*0.1 = 10 ← still not zero since IdleCPU=0 is lethal! // Actually idle_cpu < IdleCPUMin(5) → lethal. So score = -100 // To get score=0 without lethal: set IdleCPU=5 (just above threshold) m.IdleCPU = 5 // idle_score = 5 m.ActiveCalls = 1 m.MaxCalls = 1 // call_score = 0 m.LoadAvg = 2.0 // load_score = 0 m.CallFailureRate = 0 // fail_score = 100 // score = 0*0.4 + 0*0.3 + 5*0.2 + 100*0.1 = 1 + 10 = 11 ← exercise already works // The "all score zero" edge case is theoretical — in practice scores are positive. // We test that PickNode doesn't panic when totalScore=0 scenario is somehow reached. // Let's just verify non-fatal path for very low load m.IdleCPU = 5 m.LoadAvg = 1.95 // load_score = 100 - 97.5 = 2.5 m.ActiveCalls = 99 m.MaxCalls = 100 // call_score = 100 - 99 = 1 eng.UpdateMetric(m) id, _, fallback := eng.PickNode() if id == "" || fallback { t.Errorf("с score>0 не должно быть fallback, id=%s, fallback=%v", id, fallback) } } // --- PickNode (facade) --- func TestPickNode_BestMode(t *testing.T) { eng := NewEngine(cfgWithMode("best")) eng.UpdateMetric(freshMetric("pbx-01")) eng.UpdateMetric(freshMetric("pbx-02")) id, _, fallback := eng.PickNode() if fallback { t.Error("best mode с здоровыми нодами — не должно быть fallback") } if id == "" { t.Error("best mode должен вернуть идентификатор ноды") } } func TestPickNode_WeightedMode(t *testing.T) { eng := NewEngine(cfgWithMode("weighted_random")) eng.UpdateMetric(freshMetric("pbx-01")) id, _, fallback := eng.PickNode() if fallback { t.Error("weighted mode с здоровой нодой — не должно быть fallback") } if id != "pbx-01" { t.Errorf("weighted mode — ожидалась pbx-01, получена %s", id) } } // --- Balancing mode --- func TestSetGetBalancingMode(t *testing.T) { eng := NewEngine(cfgWithMode("best")) if eng.GetBalancingMode() != "best" { t.Errorf("ожидался mode=best, получен %s", eng.GetBalancingMode()) } eng.SetBalancingMode("weighted_random") if eng.GetBalancingMode() != "weighted_random" { t.Errorf("ожидался mode=weighted_random, получен %s", eng.GetBalancingMode()) } } func TestNewEngine_BalanceModeDefault(t *testing.T) { // Без BalanceMode в конфиге (backward compat) cfg := defaultCfg() cfg.BalanceMode = "" eng := NewEngine(cfg) if eng.GetBalancingMode() != "weighted_random" { t.Errorf("пустой balance_mode должен дефолтиться на weighted_random, получен %s", eng.GetBalancingMode()) } }