package ewma import ( "testing" "git.mleku.dev/mleku/dendrite/pkg/ratio" ) func TestEWMAConvergence(t *testing.T) { ew := NewEWMA(10) target := ratio.New(3, 4) // Feed constant value — EWMA should converge to it. for range 100 { ew.Update(target) } diff := ew.Value.Sub(target).Abs() epsilon := ratio.New(1, 1000) if diff.Greater(epsilon) { t.Fatalf("EWMA did not converge: value=%s, target=%s, diff=%s", ew.Value, target, diff) } } func TestEWMAFirstValue(t *testing.T) { ew := NewEWMA(10) val := ratio.New(7, 10) ew.Update(val) if !ew.Value.Equal(val) { t.Fatalf("first update should set value directly: got %s, want %s", ew.Value, val) } } func TestOscillationDetected(t *testing.T) { d := NewDetector(3, 0, 4) // small window, threshold 4 reversals // Alternate high and low inputs to trigger oscillation. detected := false for i := range 200 { var raw, accreted int64 if i%2 == 0 { raw, accreted = 100, 90 // high ratio } else { raw, accreted = 100, 10 // low ratio } if d.Observe(raw, accreted) { detected = true break } } if !detected { t.Fatalf("oscillation not detected after 200 alternating observations (reversals=%d)", d.Reversals) } } func TestNoOscillationMonotonic(t *testing.T) { d := NewDetector(10, 0, 3) // Monotonically rising input — should never trigger. for i := int64(1); i <= 100; i++ { if d.Observe(100, i) { t.Fatalf("oscillation falsely detected at step %d (reversals=%d)", i, d.Reversals) } } } func TestResetClearsReversals(t *testing.T) { d := NewDetector(3, 0, 4) // Drive to oscillation. for i := range 200 { if i%2 == 0 { d.Observe(100, 90) } else { d.Observe(100, 10) } if d.Oscillating() { break } } if !d.Oscillating() { t.Fatal("should be oscillating before reset") } valueBefore := d.EW.Value d.Reset() if d.Oscillating() { t.Fatal("should not be oscillating after reset") } if d.Reversals != 0 { t.Fatalf("reversals not zeroed: %d", d.Reversals) } if !d.EW.Value.Equal(valueBefore) { t.Fatalf("EWMA value changed by reset: was %s, now %s", valueBefore, d.EW.Value) } } func TestMarshalUnmarshal(t *testing.T) { d := NewDetector(5, 0, 3) d.Observe(100, 50) d.Observe(100, 60) d.Observe(100, 40) data, err := d.Marshal() if err != nil { t.Fatalf("marshal: %v", err) } d2, err := UnmarshalDetector(data) if err != nil { t.Fatalf("unmarshal: %v", err) } if !d2.EW.Value.Equal(d.EW.Value) { t.Fatalf("EWMA value mismatch: %s vs %s", d.EW.Value, d2.EW.Value) } if d2.Threshold != d.Threshold { t.Fatalf("threshold mismatch: %d vs %d", d2.Threshold, d.Threshold) } if d2.EW.Count != d.EW.Count { t.Fatalf("count mismatch: %d vs %d", d2.EW.Count, d.EW.Count) } } func TestZeroRawCount(t *testing.T) { d := NewDetector(5, 0, 3) d.Observe(0, 0) if !d.EW.Value.Equal(ratio.Zero) { t.Fatalf("expected zero value for zero raw, got %s", d.EW.Value) } } func TestConvergenceThenOscillation(t *testing.T) { d := NewDetector(5, 0, 4) // Phase 1: converge on 50%. for range 20 { if d.Observe(100, 50) { t.Fatal("should not oscillate during convergence") } } // Phase 2: oscillate around 50%. detected := false for i := range 200 { var accreted int64 if i%2 == 0 { accreted = 60 } else { accreted = 40 } if d.Observe(100, accreted) { detected = true break } } if !detected { t.Fatalf("oscillation not detected after convergence then alternation (reversals=%d)", d.Reversals) } }