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.
72 lines
2.5 KiB
Python
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
|