"""_reject_outlier_drops -- the outlier-rejection pass in the battery remaining-time estimate (see routers/common.py's battery_estimate_s). Pure function, no DB/HTTP -- (recency_weight, drop_pct) pairs in, filtered pairs out.""" from __future__ import annotations from app.routers.common import _reject_outlier_drops def _steps(drops: list[float]) -> list[tuple[int, float]]: return [(i + 1, d) for i, d in enumerate(drops)] def _avg(steps: list[tuple[int, float]]) -> float: total_weight = sum(w for w, _ in steps) return sum(w * d for w, d in steps) / total_weight def test_no_outlier_keeps_every_step(): steps = _steps([1, 1, 2, 1, 1, 2, 1]) assert _reject_outlier_drops(steps) == steps def test_single_glitch_dip_is_rejected(): """The exact shape reported in production: 18 ordinary 1%-per-wake steps and one spliced-in 26% glitch -- the naive median-based MAD degenerates to 0 here (more than half the steps tie at the median), which used to let the glitch sail straight through untouched.""" normal = [1] * 18 glitchy = normal[:9] + [26] + normal[9:] kept = _reject_outlier_drops(_steps(glitchy)) kept_drops = [d for _, d in kept] assert 26 not in kept_drops assert len(kept) == 18 # the whole point: the estimate should come out the same as if the # glitch had never been recorded at all baseline_avg = _avg(_steps(normal)) filtered_avg = _avg(kept) assert abs(filtered_avg - baseline_avg) < 1e-9 def test_single_glitch_spike_is_rejected(): normal = [2] * 18 glitchy = normal[:5] + [40] + normal[5:] kept = _reject_outlier_drops(_steps(glitchy)) kept_drops = [d for _, d in kept] assert 40 not in kept_drops assert len(kept) == 18 def test_identical_steps_reject_nothing(): """Every step tied at the exact same value -- both the median MAD and the mean-absolute-deviation fallback are 0 here, which is the one case _reject_outlier_drops explicitly bails out of rather than filtering down to nothing.""" steps = _steps([1] * 10) assert _reject_outlier_drops(steps) == steps def test_never_filters_down_to_nothing(): """Even a genuinely bimodal series (half the wakes cheap, half expensive -- not a single-glitch shape at all) shouldn't empty the list; a battery_estimate_s caller treats an empty result as "insufficient data," which a merely-noisy history isn't.""" steps = _steps([1, 1, 1, 1, 10, 10, 10, 10]) kept = _reject_outlier_drops(steps) assert len(kept) > 0