Files
espresso_frame/server/tests/test_battery_estimate.py
T
tfaour 31adc34a19
Build and push server image / test (push) Successful in 1m18s
Build and push server image / build-and-push (push) Successful in 2m12s
Add a real pytest suite, gating the CI build
64 tests covering: auth/setup and the CSRF gate, the "owner adds their
own data, anyone linked can mute it" permission pattern shared across
calendar-select/tasks-source/whiteboard-source, migration correctness
(fresh install, idempotent re-run, expected columns), battery estimate
outlier rejection, calendar_feed's fetch/merge/partial-failure handling,
webdav_client's fetch/list-directory, the whiteboard force-refresh
throttle bypass and browse endpoint, and render-size invariants across
calendar views/orientations.

No DB/HTTP fixtures need Docker, Node, or a real Immich/CalDAV/WebDAV
server -- a fresh temp SQLite file plus a couple of small local HTTP
servers as test doubles cover it all. Table data is wiped and reseeded
between tests rather than relying on SQLAlchemy's transaction-rollback
isolation pattern, which needs a pysqlite event-listener workaround
app/db.py's engine doesn't have and has no reason to gain just for tests.

Wired into .gitea/workflows/server-docker-build.yml as its own job that
build-and-push now depends on, so a failing suite blocks the image push
rather than just running alongside it for show.
2026-07-23 18:51:54 -04:00

72 lines
2.5 KiB
Python

"""_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