1.x updates
This commit is contained in:
126
tests/unit/test_banister.py
Normal file
126
tests/unit/test_banister.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Tests for Banister CTL/ATL/TSB and the forecast splice helper.
|
||||
|
||||
Two things to lock down:
|
||||
- the EWMA math itself, via a closed-form impulse-response check.
|
||||
- the forecast splice, which must produce the same output as feeding the
|
||||
whole series at once (i.e. no double-counting on the boundary day).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from openrun.model import banister, banister_forecast
|
||||
|
||||
|
||||
def _daily_zero_load(start: str, days: int) -> pd.Series:
|
||||
idx = pd.date_range(start, periods=days, freq="D")
|
||||
return pd.Series(0.0, index=idx)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# banister() — closed-form correctness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_banister_impulse_decays_exponentially() -> None:
|
||||
"""Single load on day 0, zeros after. The recursion
|
||||
CTL[i] = CTL[i-1]*decay + load[i]*(1-decay)
|
||||
with decay = exp(-1/tau) yields
|
||||
CTL[i] = L * (1-decay) * decay**i.
|
||||
"""
|
||||
load = _daily_zero_load("2026-01-01", 50)
|
||||
load.iloc[0] = 100.0
|
||||
|
||||
pmc = banister(load, ctl_tau=42.0, atl_tau=7.0)
|
||||
decay_ctl = math.exp(-1 / 42)
|
||||
decay_atl = math.exp(-1 / 7)
|
||||
|
||||
for i in (0, 1, 7, 21, 42):
|
||||
expected_ctl = 100.0 * (1 - decay_ctl) * decay_ctl**i
|
||||
expected_atl = 100.0 * (1 - decay_atl) * decay_atl**i
|
||||
assert pmc["CTL"].iloc[i] == pytest.approx(expected_ctl, rel=1e-9)
|
||||
assert pmc["ATL"].iloc[i] == pytest.approx(expected_atl, rel=1e-9)
|
||||
|
||||
|
||||
def test_banister_steady_state_approaches_input() -> None:
|
||||
"""Constant input L converges to CTL=L; after `tau` days CTL ≈ L·(1-1/e)."""
|
||||
load = pd.Series(100.0, index=pd.date_range("2026-01-01", periods=200, freq="D"))
|
||||
pmc = banister(load, ctl_tau=42.0, atl_tau=7.0)
|
||||
assert pmc["CTL"].iloc[41] == pytest.approx(100.0 * (1 - 1 / math.e), rel=1e-3)
|
||||
# After ~5τ days (200/42 ≈ 4.76), CTL is within 1% of the steady-state value.
|
||||
assert pmc["CTL"].iloc[-1] == pytest.approx(100.0, rel=0.01)
|
||||
|
||||
|
||||
def test_banister_rest_days_filled_with_zero() -> None:
|
||||
"""Sparse input (only some dates) must be reindexed daily with zeros so
|
||||
both EWMAs continue to decay on rest days."""
|
||||
idx = pd.to_datetime(["2026-01-01", "2026-01-10"])
|
||||
load = pd.Series([100.0, 0.0], index=idx)
|
||||
pmc = banister(load, ctl_tau=42.0, atl_tau=7.0)
|
||||
assert len(pmc) == 10 # 2026-01-01 through 2026-01-10 inclusive
|
||||
|
||||
|
||||
def test_banister_empty_input_returns_empty_frame() -> None:
|
||||
out = banister(pd.Series(dtype=float))
|
||||
assert list(out.columns) == ["CTL", "ATL", "TSB"]
|
||||
assert len(out) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# banister_forecast() — splice + invariant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_banister_forecast_matches_full_history() -> None:
|
||||
"""Splitting a full series into (history, future) and running through the
|
||||
forecast helper produces the identical PMC frame to feeding the whole
|
||||
series at once. This is the load-bearing invariant."""
|
||||
rng = np.random.default_rng(seed=42)
|
||||
idx = pd.date_range("2026-01-01", periods=120, freq="D")
|
||||
full = pd.Series(rng.integers(0, 150, size=120).astype(float), index=idx)
|
||||
|
||||
cutoff = idx[60]
|
||||
history = full[full.index <= cutoff]
|
||||
future = full[full.index > cutoff]
|
||||
|
||||
pmc_full = banister(full)
|
||||
pmc_spliced = banister_forecast(history, future, today=cutoff)
|
||||
|
||||
pd.testing.assert_frame_equal(pmc_full, pmc_spliced)
|
||||
|
||||
|
||||
def test_banister_forecast_default_today_is_history_max() -> None:
|
||||
history = pd.Series([10.0, 20.0, 30.0],
|
||||
index=pd.date_range("2026-01-01", periods=3, freq="D"))
|
||||
future = pd.Series([5.0, 5.0],
|
||||
index=pd.date_range("2026-01-04", periods=2, freq="D"))
|
||||
|
||||
out_default = banister_forecast(history, future)
|
||||
out_explicit = banister_forecast(history, future, today="2026-01-03")
|
||||
pd.testing.assert_frame_equal(out_default, out_explicit)
|
||||
|
||||
|
||||
def test_banister_forecast_drops_future_dates_at_or_before_today() -> None:
|
||||
"""A future row dated ≤ today must be dropped — history is authoritative for that day."""
|
||||
history = pd.Series([100.0],
|
||||
index=pd.date_range("2026-01-03", periods=1, freq="D"))
|
||||
# Future contains an "overlapping" entry at today that would otherwise sum with history.
|
||||
future = pd.Series([999.0, 5.0],
|
||||
index=pd.to_datetime(["2026-01-03", "2026-01-04"]))
|
||||
|
||||
pmc = banister_forecast(history, future, today="2026-01-03")
|
||||
# Day 0 should reflect history's 100, not the 999 from future.
|
||||
decay_ctl = math.exp(-1 / 42)
|
||||
assert pmc["CTL"].iloc[0] == pytest.approx(100.0 * (1 - decay_ctl), rel=1e-9)
|
||||
|
||||
|
||||
def test_banister_forecast_extends_through_future_end() -> None:
|
||||
history = pd.Series([10.0],
|
||||
index=pd.date_range("2026-01-01", periods=1, freq="D"))
|
||||
future = pd.Series([5.0] * 5,
|
||||
index=pd.date_range("2026-01-02", periods=5, freq="D"))
|
||||
pmc = banister_forecast(history, future)
|
||||
assert pmc.index.max() == pd.Timestamp("2026-01-06")
|
||||
227
tests/unit/test_fit_linker.py
Normal file
227
tests/unit/test_fit_linker.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""Tests for `openrun.ingest.fit_linker`.
|
||||
|
||||
We don't exercise the FIT-parsing path here — the linker's responsibilities at
|
||||
the DB boundary are (1) match a session timestamp to an activity, (2) store
|
||||
absolute paths, (3) rewrite them when an export moves, and (4) refuse to
|
||||
silently overwrite an already-linked activity. All four are testable without
|
||||
a real .fit fixture.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from openrun.ingest.fit_linker import (
|
||||
_match_activity,
|
||||
link,
|
||||
record_link,
|
||||
relink,
|
||||
)
|
||||
|
||||
|
||||
def _seed_activity(conn, aid: int, when: datetime) -> None:
|
||||
conn.execute(
|
||||
"INSERT INTO activities (activity_id, start_time_gmt, raw, fetched_at) VALUES (?, ?, '{}', 'now')",
|
||||
(aid, when.astimezone(timezone.utc).isoformat().replace("+00:00", "")),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _row(conn, aid: int) -> tuple[int, str]:
|
||||
return conn.execute(
|
||||
"SELECT activity_id, fit_path FROM activity_fit_files WHERE activity_id = ?",
|
||||
(aid,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def test_record_link_stores_absolute_path(tmp_conn, tmp_path: Path) -> None:
|
||||
"""A relative path passed to record_link is resolved to absolute on disk."""
|
||||
fit = tmp_path / "sub" / "run.fit"
|
||||
fit.parent.mkdir(parents=True)
|
||||
fit.write_bytes(b"") # contents irrelevant — record_link doesn't parse
|
||||
|
||||
# Pass a relative path on purpose to verify resolution.
|
||||
rel = fit.relative_to(tmp_path)
|
||||
cwd_before = Path.cwd()
|
||||
import os
|
||||
os.chdir(tmp_path)
|
||||
try:
|
||||
record_link(tmp_conn, 1001, Path(rel))
|
||||
finally:
|
||||
os.chdir(cwd_before)
|
||||
tmp_conn.commit()
|
||||
|
||||
stored = _row(tmp_conn, 1001)["fit_path"]
|
||||
assert Path(stored).is_absolute(), f"expected absolute, got {stored}"
|
||||
assert Path(stored) == fit.resolve()
|
||||
|
||||
|
||||
def test_record_link_is_idempotent(tmp_conn, tmp_path: Path) -> None:
|
||||
fit = tmp_path / "run.fit"
|
||||
fit.write_bytes(b"")
|
||||
record_link(tmp_conn, 42, fit)
|
||||
record_link(tmp_conn, 42, fit)
|
||||
tmp_conn.commit()
|
||||
|
||||
count = tmp_conn.execute(
|
||||
"SELECT COUNT(*) FROM activity_fit_files WHERE activity_id = 42"
|
||||
).fetchone()[0]
|
||||
assert count == 1
|
||||
|
||||
|
||||
def test_relink_rewrites_paths_by_basename(tmp_conn, tmp_path: Path) -> None:
|
||||
"""After moving an export, `relink` should update stored absolute paths to the new root."""
|
||||
old_root = tmp_path / "old"
|
||||
new_root = tmp_path / "new"
|
||||
(old_root / "fit").mkdir(parents=True)
|
||||
(new_root / "fit").mkdir(parents=True)
|
||||
|
||||
# Two activities, one of which will move; one filename has no counterpart in new_root.
|
||||
fit_a_old = old_root / "fit" / "a.fit"
|
||||
fit_b_old = old_root / "fit" / "b.fit"
|
||||
fit_a_old.write_bytes(b"")
|
||||
fit_b_old.write_bytes(b"")
|
||||
record_link(tmp_conn, 1, fit_a_old)
|
||||
record_link(tmp_conn, 2, fit_b_old)
|
||||
tmp_conn.commit()
|
||||
|
||||
fit_a_new = new_root / "fit" / "a.fit"
|
||||
fit_a_new.write_bytes(b"")
|
||||
# Intentionally leave `b.fit` absent from new_root to test the unmatched path.
|
||||
|
||||
updated, unmatched = relink(tmp_conn, new_root)
|
||||
assert updated == 1
|
||||
assert unmatched == 1
|
||||
|
||||
a_after = _row(tmp_conn, 1)["fit_path"]
|
||||
b_after = _row(tmp_conn, 2)["fit_path"]
|
||||
assert Path(a_after) == fit_a_new.resolve()
|
||||
# Unmatched row left untouched, still pointing at the old (now non-existent) location.
|
||||
assert Path(b_after) == fit_b_old.resolve()
|
||||
|
||||
|
||||
def test_relink_rejects_non_directory(tmp_conn, tmp_path: Path) -> None:
|
||||
missing = tmp_path / "does_not_exist"
|
||||
try:
|
||||
relink(tmp_conn, missing)
|
||||
except FileNotFoundError as exc:
|
||||
assert "not a directory" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected FileNotFoundError for missing relink root")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _match_activity — pure function, no DB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_match_activity_within_tolerance() -> None:
|
||||
index = {1000: 42, 2000: 99}
|
||||
keys = sorted(index)
|
||||
assert _match_activity(1030, keys, index, tolerance_s=60) == 42
|
||||
|
||||
|
||||
def test_match_activity_picks_closest() -> None:
|
||||
"""Two candidates both within tolerance → closest one wins."""
|
||||
index = {1000: 42, 1050: 99}
|
||||
keys = sorted(index)
|
||||
# 1030 is 30s after 1000 and 20s before 1050 → picks 1050
|
||||
assert _match_activity(1030, keys, index, tolerance_s=60) == 99
|
||||
|
||||
|
||||
def test_match_activity_outside_tolerance_returns_none() -> None:
|
||||
index = {1000: 42}
|
||||
keys = sorted(index)
|
||||
assert _match_activity(1500, keys, index, tolerance_s=60) is None
|
||||
|
||||
|
||||
def test_match_activity_empty_index() -> None:
|
||||
assert _match_activity(1000, [], {}, tolerance_s=60) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# link() collision handling — uses fit_iter injection so we never parse a FIT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_link_writes_match(tmp_conn, tmp_path: Path) -> None:
|
||||
when = datetime(2026, 5, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
_seed_activity(tmp_conn, aid=7, when=when)
|
||||
|
||||
fit = tmp_path / "good.fit"
|
||||
fit.write_bytes(b"")
|
||||
|
||||
summary = link(
|
||||
export_root=tmp_path,
|
||||
conn=tmp_conn,
|
||||
dry_run=False,
|
||||
min_size_kb=0,
|
||||
tolerance_s=60,
|
||||
fit_iter=[(fit, when)],
|
||||
)
|
||||
|
||||
assert summary == {"linked": 1, "unmatched": 0, "parse_failed": 0, "collisions": 0}
|
||||
row = tmp_conn.execute(
|
||||
"SELECT fit_path FROM activity_fit_files WHERE activity_id = 7"
|
||||
).fetchone()
|
||||
assert Path(row["fit_path"]) == fit.resolve()
|
||||
|
||||
|
||||
def test_link_warns_and_skips_on_collision(tmp_conn, tmp_path: Path, capsys) -> None:
|
||||
"""Two FITs whose session times both match the *same* activity:
|
||||
first wins, second is reported as a collision and not written."""
|
||||
when = datetime(2026, 5, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
_seed_activity(tmp_conn, aid=7, when=when)
|
||||
|
||||
first = tmp_path / "first.fit"
|
||||
second = tmp_path / "second.fit"
|
||||
first.write_bytes(b"")
|
||||
second.write_bytes(b"")
|
||||
|
||||
summary = link(
|
||||
export_root=tmp_path,
|
||||
conn=tmp_conn,
|
||||
dry_run=False,
|
||||
min_size_kb=0,
|
||||
tolerance_s=60,
|
||||
# Both FITs report the same session start → both match aid=7.
|
||||
fit_iter=[(first, when), (second, when)],
|
||||
)
|
||||
|
||||
assert summary["linked"] == 1
|
||||
assert summary["collisions"] == 1
|
||||
|
||||
# The first FIT's path must still be in the table — second must not have clobbered.
|
||||
stored = tmp_conn.execute(
|
||||
"SELECT fit_path FROM activity_fit_files WHERE activity_id = 7"
|
||||
).fetchone()["fit_path"]
|
||||
assert Path(stored) == first.resolve()
|
||||
|
||||
# Collision is reported on stderr with both filenames for diagnosis.
|
||||
err = capsys.readouterr().err
|
||||
assert "collision" in err
|
||||
assert "second.fit" in err
|
||||
assert "first.fit" in err
|
||||
|
||||
|
||||
def test_link_counts_parse_failures_and_unmatched(tmp_conn, tmp_path: Path) -> None:
|
||||
when = datetime(2026, 5, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
_seed_activity(tmp_conn, aid=7, when=when)
|
||||
|
||||
far_off = datetime(2030, 1, 1, tzinfo=timezone.utc)
|
||||
parse_fail = tmp_path / "broken.fit"
|
||||
orphan = tmp_path / "orphan.fit"
|
||||
parse_fail.write_bytes(b"")
|
||||
orphan.write_bytes(b"")
|
||||
|
||||
summary = link(
|
||||
export_root=tmp_path,
|
||||
conn=tmp_conn,
|
||||
dry_run=False,
|
||||
min_size_kb=0,
|
||||
tolerance_s=60,
|
||||
fit_iter=[(parse_fail, None), (orphan, far_off)],
|
||||
)
|
||||
|
||||
assert summary["parse_failed"] == 1
|
||||
assert summary["unmatched"] == 1
|
||||
assert summary["linked"] == 0
|
||||
61
tests/unit/test_garmin_export.py
Normal file
61
tests/unit/test_garmin_export.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Tests for `openrun.ingest.garmin_export`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from openrun.ingest.garmin_export import _activity_id_from_filename
|
||||
|
||||
|
||||
def test_activity_id_from_filename_connect_style() -> None:
|
||||
# Connect export: <id>_<activity_name>.fit
|
||||
assert _activity_id_from_filename("12345678_morning_run") == 12345678
|
||||
|
||||
|
||||
def test_activity_id_from_filename_takeout_style() -> None:
|
||||
# Takeout: <email>_<upload_id>.fit (email is the literal prefix Garmin uses)
|
||||
assert _activity_id_from_filename("o@o00.io_132001424015") == 132001424015
|
||||
|
||||
|
||||
def test_activity_id_from_filename_takeout_with_year_prefix() -> None:
|
||||
# Some Takeout names embed a date prefix; we want the upload-id (longer chunk), not the year.
|
||||
assert _activity_id_from_filename("2024_132001424015") == 132001424015
|
||||
|
||||
|
||||
def test_activity_id_from_filename_none_when_no_digit_chunk() -> None:
|
||||
assert _activity_id_from_filename("trainingPlan") is None
|
||||
assert _activity_id_from_filename("just_words_here") is None
|
||||
|
||||
|
||||
def test_activity_id_from_filename_short_digit_chunk_rejected() -> None:
|
||||
# <8 digits is treated as not-an-activity-id (filters out year-like 2024, etc).
|
||||
assert _activity_id_from_filename("2024_run") is None
|
||||
|
||||
|
||||
def test_main_hint_printed_for_takeout_named_fits(tmp_path: Path) -> None:
|
||||
"""End-to-end through main(): a FIT whose stem has no activity-id chunk
|
||||
triggers the 'run openrun-link-fit' hint in the summary."""
|
||||
export = tmp_path / "export"
|
||||
export.mkdir()
|
||||
# A Takeout-style filename — has a long digit chunk, but the chunk is
|
||||
# the upload id (not the activity id), and we have no activities seeded.
|
||||
# Even though the regex picks it up, the activity-not-found branch returns 0.
|
||||
# To exercise the *no-id-in-filename* branch we use a clearly nameless FIT:
|
||||
(export / "trainingPlan.fit").write_bytes(b"")
|
||||
|
||||
# Run via a child process so we don't touch the real DB. We point
|
||||
# openrun at an isolated workspace via OPENRUN_CONFIG.
|
||||
cfg = tmp_path / "openrun.toml"
|
||||
cfg.write_text(f'[db]\npath = "{tmp_path / "test.db"}"\n')
|
||||
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "openrun.ingest.garmin_export", str(export)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={"OPENRUN_CONFIG": str(cfg), "PATH": __import__("os").environ.get("PATH", "")},
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert "FITs skipped" in proc.stdout
|
||||
assert "openrun-link-fit" in proc.stdout
|
||||
25
tests/unit/test_model.py
Normal file
25
tests/unit/test_model.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Tests for pure helpers in `openrun.model`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from openrun.model import _resolve_fit_path
|
||||
|
||||
|
||||
def test_resolve_fit_path_returns_existing_absolute_path(tmp_path: Path) -> None:
|
||||
fit = tmp_path / "sub" / "run.fit"
|
||||
fit.parent.mkdir(parents=True)
|
||||
fit.write_bytes(b"")
|
||||
assert _resolve_fit_path(str(fit)) == fit
|
||||
|
||||
|
||||
def test_resolve_fit_path_missing_raises_with_relink_hint(tmp_path: Path) -> None:
|
||||
missing = tmp_path / "gone.fit"
|
||||
with pytest.raises(FileNotFoundError) as exc_info:
|
||||
_resolve_fit_path(str(missing))
|
||||
msg = str(exc_info.value)
|
||||
assert "--relink" in msg, f"expected hint about --relink, got: {msg!r}"
|
||||
assert str(missing) in msg
|
||||
63
tests/unit/test_plots.py
Normal file
63
tests/unit/test_plots.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Smoke tests for `openrun.plots`.
|
||||
|
||||
We deliberately don't visual-regress matplotlib output — see ROADMAP test
|
||||
conventions. Each test asserts only structural properties: bar count, axis
|
||||
labels, "no data" sentinel behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg") # must be set before pyplot import
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from openrun.plots import plot_fit_decoupling
|
||||
|
||||
|
||||
def _synthetic_records(duration_s: int = 1800, decel_at: int = 900) -> pd.DataFrame:
|
||||
"""A FIT-records-like frame: constant HR, but speed drops halfway through.
|
||||
With 2 segments, the second-half efficiency must be lower → positive decoupling.
|
||||
"""
|
||||
elapsed = np.arange(duration_s, dtype=float)
|
||||
speed = np.where(elapsed < decel_at, 3.0, 2.5)
|
||||
hr = np.full(duration_s, 150.0)
|
||||
return pd.DataFrame({"elapsed_s": elapsed, "speed_mps": speed, "heart_rate": hr})
|
||||
|
||||
|
||||
def test_plot_fit_decoupling_returns_axes_with_n_bars() -> None:
|
||||
records = _synthetic_records()
|
||||
ax = plot_fit_decoupling(records, segments=4)
|
||||
|
||||
bars = [p for p in ax.patches if hasattr(p, "get_height")]
|
||||
assert len(bars) == 4
|
||||
assert ax.get_ylabel() == "decoupling (%)"
|
||||
plt.close(ax.figure)
|
||||
|
||||
|
||||
def test_plot_fit_decoupling_segments_default_is_two() -> None:
|
||||
records = _synthetic_records()
|
||||
ax = plot_fit_decoupling(records)
|
||||
bars = [p for p in ax.patches if hasattr(p, "get_height")]
|
||||
assert len(bars) == 2
|
||||
plt.close(ax.figure)
|
||||
|
||||
|
||||
def test_plot_fit_decoupling_handles_empty_records() -> None:
|
||||
"""Empty records frame → 'no usable records' sentinel, not a crash."""
|
||||
empty = pd.DataFrame(columns=["elapsed_s", "speed_mps", "heart_rate"])
|
||||
ax = plot_fit_decoupling(empty)
|
||||
texts = [t.get_text() for t in ax.texts]
|
||||
assert any("no usable records" in t for t in texts)
|
||||
plt.close(ax.figure)
|
||||
|
||||
|
||||
def test_plot_fit_decoupling_respects_caller_axes() -> None:
|
||||
"""When the caller passes ax=, no new figure is created."""
|
||||
fig, ax = plt.subplots()
|
||||
out = plot_fit_decoupling(_synthetic_records(), ax=ax)
|
||||
assert out is ax
|
||||
plt.close(fig)
|
||||
116
tests/unit/test_race_plan.py
Normal file
116
tests/unit/test_race_plan.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Tests for race-plan helpers: `personal_records` and `calibrate_tl_per_km`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from openrun.model import calibrate_tl_per_km, personal_records
|
||||
|
||||
|
||||
def _act(aid: int, dist_km: float, pace: float, when: str = "2026-01-01") -> dict:
|
||||
"""Build a single-activity dict matching `load_activities` output columns."""
|
||||
duration_s = pace * 60.0 * dist_km
|
||||
return {
|
||||
"activity_id": aid,
|
||||
"start_time_local": pd.Timestamp(when),
|
||||
"distance_km": dist_km,
|
||||
"pace_min_per_km": pace,
|
||||
"moving_duration_s": duration_s,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# personal_records
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_personal_records_picks_fastest_in_bin() -> None:
|
||||
"""Three 10K candidates: returned row must be the one with the lowest pace."""
|
||||
acts = pd.DataFrame([
|
||||
_act(1, 10.0, pace=5.0),
|
||||
_act(2, 10.2, pace=4.5), # winner
|
||||
_act(3, 9.8, pace=4.8),
|
||||
])
|
||||
pr = personal_records(acts, distance_bins_km=(10.0,))
|
||||
assert len(pr) == 1
|
||||
assert pr.loc[0, "activity_id"] == 2
|
||||
assert pr.loc[0, "pace_min_per_km"] == 4.5
|
||||
|
||||
|
||||
def test_personal_records_respects_tolerance_band() -> None:
|
||||
"""±5% band on 10K = [9.5, 10.5]. An 11K activity is too far out to qualify."""
|
||||
acts = pd.DataFrame([
|
||||
_act(1, 10.0, pace=5.0),
|
||||
_act(2, 11.0, pace=3.5), # faster but outside tolerance
|
||||
])
|
||||
pr = personal_records(acts, distance_bins_km=(10.0,))
|
||||
assert pr.loc[0, "activity_id"] == 1
|
||||
|
||||
|
||||
def test_personal_records_skips_bins_with_no_qualifying_activity() -> None:
|
||||
acts = pd.DataFrame([_act(1, 5.0, pace=5.0)])
|
||||
pr = personal_records(acts, distance_bins_km=(5.0, 10.0, 21.0975))
|
||||
assert list(pr["bin_km"]) == [5.0]
|
||||
|
||||
|
||||
def test_personal_records_ignores_nan_pace() -> None:
|
||||
acts = pd.DataFrame([
|
||||
{**_act(1, 10.0, pace=5.0), "pace_min_per_km": np.nan}, # masked by loader
|
||||
_act(2, 10.0, pace=5.5),
|
||||
])
|
||||
pr = personal_records(acts, distance_bins_km=(10.0,))
|
||||
assert pr.loc[0, "activity_id"] == 2
|
||||
|
||||
|
||||
def test_personal_records_empty_input_returns_empty_frame() -> None:
|
||||
pr = personal_records(pd.DataFrame())
|
||||
assert pr.empty
|
||||
assert set(pr.columns) >= {"bin_km", "activity_id", "pace_min_per_km"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# calibrate_tl_per_km
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _insert_activity(conn, aid: int, distance_m: float, training_load: float,
|
||||
activity_type: str = "running", when: str = "2026-05-01") -> None:
|
||||
conn.execute(
|
||||
"""INSERT INTO activities
|
||||
(activity_id, start_time_local, activity_type, distance_m, training_load, raw, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, '{}', 'now')""",
|
||||
(aid, when, activity_type, distance_m, training_load),
|
||||
)
|
||||
|
||||
|
||||
def test_calibrate_tl_per_km_returns_median_iqr(tmp_conn) -> None:
|
||||
"""Median of synthetic tl/km values equals the constructed truth."""
|
||||
# 5 runs, each 10 km, with training loads chosen so tl/km = 8, 9, 10, 11, 12.
|
||||
for i, tl_per_km in enumerate([8.0, 9.0, 10.0, 11.0, 12.0], start=1):
|
||||
_insert_activity(tmp_conn, aid=i, distance_m=10_000, training_load=10 * tl_per_km)
|
||||
tmp_conn.commit()
|
||||
|
||||
result = calibrate_tl_per_km(tmp_conn, lookback_days=None)
|
||||
assert result["n"] == 5
|
||||
assert result["median"] == 10.0
|
||||
assert result["q1"] == 9.0
|
||||
assert result["q3"] == 11.0
|
||||
|
||||
|
||||
def test_calibrate_tl_per_km_excludes_short_distance(tmp_conn) -> None:
|
||||
_insert_activity(tmp_conn, aid=1, distance_m=10_000, training_load=100)
|
||||
_insert_activity(tmp_conn, aid=2, distance_m=500, training_load=50) # too short
|
||||
tmp_conn.commit()
|
||||
assert calibrate_tl_per_km(tmp_conn, lookback_days=None)["n"] == 1
|
||||
|
||||
|
||||
def test_calibrate_tl_per_km_excludes_other_activity_types(tmp_conn) -> None:
|
||||
_insert_activity(tmp_conn, aid=1, distance_m=10_000, training_load=100, activity_type="running")
|
||||
_insert_activity(tmp_conn, aid=2, distance_m=10_000, training_load=100, activity_type="cycling")
|
||||
tmp_conn.commit()
|
||||
assert calibrate_tl_per_km(tmp_conn, lookback_days=None)["n"] == 1
|
||||
|
||||
|
||||
def test_calibrate_tl_per_km_returns_nan_when_empty(tmp_conn) -> None:
|
||||
result = calibrate_tl_per_km(tmp_conn, lookback_days=None)
|
||||
assert result["n"] == 0
|
||||
assert np.isnan(result["median"])
|
||||
106
tests/unit/test_setup.py
Normal file
106
tests/unit/test_setup.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Tests for `openrun.setup.init_workspace` — idempotency, state reporting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from openrun.config import Config
|
||||
from openrun.setup import EXPECTED_TABLES, init_workspace
|
||||
|
||||
|
||||
def _cfg(tmp_path: Path) -> Config:
|
||||
"""A Config that points at an isolated tmp_path DB."""
|
||||
return Config(db_path=tmp_path / "test.db")
|
||||
|
||||
|
||||
def _empty_secrets(tmp_path: Path) -> Path:
|
||||
"""A non-existent secrets dir under tmp_path — guarantees tests never read the real one."""
|
||||
return tmp_path / ".secrets"
|
||||
|
||||
|
||||
def test_first_run_creates_db(tmp_path: Path) -> None:
|
||||
cfg = _cfg(tmp_path)
|
||||
status = init_workspace(config=cfg, secrets_dir=_empty_secrets(tmp_path))
|
||||
|
||||
assert not status.db_existed_before
|
||||
assert cfg.db_path.exists()
|
||||
assert status.is_ready()
|
||||
assert set(EXPECTED_TABLES).issubset(status.tables_present)
|
||||
|
||||
|
||||
def test_second_run_is_idempotent(tmp_path: Path) -> None:
|
||||
cfg = _cfg(tmp_path)
|
||||
secrets = _empty_secrets(tmp_path)
|
||||
|
||||
first = init_workspace(config=cfg, secrets_dir=secrets)
|
||||
second = init_workspace(config=cfg, secrets_dir=secrets)
|
||||
|
||||
assert not first.db_existed_before
|
||||
assert second.db_existed_before
|
||||
assert first.tables_present == second.tables_present
|
||||
# Idempotent: re-running mutates nothing, so all counts stay zero.
|
||||
assert all(n == 0 for n in second.table_counts.values())
|
||||
|
||||
|
||||
def test_preserves_existing_data(tmp_path: Path) -> None:
|
||||
cfg = _cfg(tmp_path)
|
||||
secrets = _empty_secrets(tmp_path)
|
||||
|
||||
init_workspace(config=cfg, secrets_dir=secrets)
|
||||
|
||||
# Insert one row through a raw connection; init_workspace must not touch it.
|
||||
conn = sqlite3.connect(cfg.db_path)
|
||||
conn.execute(
|
||||
"INSERT INTO activities (activity_id, raw, fetched_at) VALUES (1, '{}', 'now')"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
status = init_workspace(config=cfg, secrets_dir=secrets)
|
||||
assert status.db_existed_before
|
||||
assert status.table_counts["activities"] == 1
|
||||
|
||||
|
||||
def test_reports_auth_tokens(tmp_path: Path) -> None:
|
||||
cfg = _cfg(tmp_path)
|
||||
secrets = tmp_path / ".secrets"
|
||||
|
||||
missing = init_workspace(config=cfg, secrets_dir=secrets)
|
||||
assert not missing.auth_tokens_present
|
||||
|
||||
secrets.mkdir()
|
||||
(secrets / "oauth1_token.json").write_text("{}")
|
||||
|
||||
present = init_workspace(config=cfg, secrets_dir=secrets)
|
||||
assert present.auth_tokens_present
|
||||
|
||||
|
||||
def test_reports_sync_state(tmp_path: Path) -> None:
|
||||
cfg = _cfg(tmp_path)
|
||||
secrets = _empty_secrets(tmp_path)
|
||||
|
||||
init_workspace(config=cfg, secrets_dir=secrets)
|
||||
conn = sqlite3.connect(cfg.db_path)
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (key, value, updated_at) VALUES (?, ?, datetime('now'))",
|
||||
("last_sync_utc", "2026-05-18T10:30:00"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
status = init_workspace(config=cfg, secrets_dir=secrets)
|
||||
assert status.last_sync_utc == "2026-05-18T10:30:00"
|
||||
assert status.last_ingest_utc is None
|
||||
|
||||
|
||||
def test_status_serialises_to_json(tmp_path: Path) -> None:
|
||||
"""`to_dict()` output must be JSON-encodable for `--json` CLI usage."""
|
||||
import json
|
||||
|
||||
status = init_workspace(config=_cfg(tmp_path), secrets_dir=_empty_secrets(tmp_path))
|
||||
blob = json.dumps(status.to_dict(), default=str)
|
||||
parsed = json.loads(blob)
|
||||
assert parsed["db_path"].endswith("test.db")
|
||||
assert parsed["db_existed_before"] is False
|
||||
assert "activities" in parsed["tables_present"]
|
||||
68
tests/unit/test_sleep_stages.py
Normal file
68
tests/unit/test_sleep_stages.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Tests for `load_sleep_stages` — percentage invariants + edge cases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from openrun.model import load_sleep_stages
|
||||
|
||||
|
||||
def _seed(conn, date: str, deep: float, light: float, rem: float,
|
||||
awake: float = 0.0, score: float | None = None) -> None:
|
||||
conn.execute(
|
||||
"""INSERT INTO daily_sleep
|
||||
(calendar_date, deep_s, light_s, rem_s, awake_s, sleep_score, raw, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, '{}', 'now')""",
|
||||
(date, deep, light, rem, awake, score),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def test_sleep_stages_percentages_sum_to_one(tmp_conn) -> None:
|
||||
"""deep + light + rem percentages always sum to 1.0 (the load-bearing invariant)."""
|
||||
_seed(tmp_conn, "2026-05-01", deep=3600, light=10800, rem=5400) # 1h / 3h / 1.5h
|
||||
_seed(tmp_conn, "2026-05-02", deep=1800, light=9000, rem=3600) # 0.5h / 2.5h / 1h
|
||||
|
||||
df = load_sleep_stages(tmp_conn)
|
||||
total_pct = df["deep_pct"] + df["light_pct"] + df["rem_pct"]
|
||||
np.testing.assert_allclose(total_pct.values, [1.0, 1.0], rtol=1e-12)
|
||||
|
||||
|
||||
def test_sleep_stages_percentages_match_seconds(tmp_conn) -> None:
|
||||
_seed(tmp_conn, "2026-05-01", deep=3600, light=10800, rem=5400)
|
||||
row = load_sleep_stages(tmp_conn).iloc[0]
|
||||
total = 3600 + 10800 + 5400
|
||||
assert row["deep_pct"] == pytest.approx(3600 / total)
|
||||
assert row["light_pct"] == pytest.approx(10800 / total)
|
||||
assert row["rem_pct"] == pytest.approx(5400 / total)
|
||||
|
||||
|
||||
def test_sleep_stages_awake_pct_uses_time_in_bed(tmp_conn) -> None:
|
||||
"""awake_pct = awake / (asleep + awake), not /total nor /asleep alone."""
|
||||
# 4h asleep + 1h awake → awake_pct = 1/5 = 0.2
|
||||
_seed(tmp_conn, "2026-05-01", deep=3600, light=7200, rem=3600, awake=3600)
|
||||
row = load_sleep_stages(tmp_conn).iloc[0]
|
||||
assert row["awake_pct"] == pytest.approx(0.2)
|
||||
|
||||
|
||||
def test_sleep_stages_sleep_hours_excludes_awake(tmp_conn) -> None:
|
||||
_seed(tmp_conn, "2026-05-01", deep=3600, light=7200, rem=3600, awake=999)
|
||||
row = load_sleep_stages(tmp_conn).iloc[0]
|
||||
# 1 + 2 + 1 = 4 hours.
|
||||
assert row["sleep_hours"] == pytest.approx(4.0)
|
||||
|
||||
|
||||
def test_sleep_stages_zero_sleep_yields_nan_pct(tmp_conn) -> None:
|
||||
"""Recorded night with no asleep time (data glitch) → NaN percentages, no DivisionByZero."""
|
||||
_seed(tmp_conn, "2026-05-01", deep=0, light=0, rem=0, awake=1800)
|
||||
row = load_sleep_stages(tmp_conn).iloc[0]
|
||||
assert np.isnan(row["deep_pct"])
|
||||
assert np.isnan(row["light_pct"])
|
||||
assert np.isnan(row["rem_pct"])
|
||||
|
||||
|
||||
def test_sleep_stages_empty_returns_empty_frame(tmp_conn) -> None:
|
||||
df = load_sleep_stages(tmp_conn)
|
||||
assert df.empty
|
||||
35
tests/unit/test_smoke.py
Normal file
35
tests/unit/test_smoke.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Smoke test — verifies the test scaffolding itself works.
|
||||
|
||||
If this passes, `uv run pytest` is wired up, the `tmp_conn` fixture builds a
|
||||
DB from `openrun.db.SCHEMA`, and the schema covers every table that loaders
|
||||
in `openrun.model` reference.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
EXPECTED_TABLES = frozenset({
|
||||
"activities",
|
||||
"activity_splits",
|
||||
"activity_fit_files",
|
||||
"activity_time_in_zone",
|
||||
"daily_steps",
|
||||
"daily_sleep",
|
||||
"daily_stress",
|
||||
"daily_hrv",
|
||||
"daily_body_battery",
|
||||
"daily_intensity_minutes",
|
||||
"daily_resting_hr",
|
||||
"sync_state",
|
||||
})
|
||||
|
||||
|
||||
def test_tmp_conn_applies_full_schema(tmp_conn) -> None:
|
||||
tables = {
|
||||
row[0]
|
||||
for row in tmp_conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
}
|
||||
missing = EXPECTED_TABLES - tables
|
||||
assert not missing, f"schema is missing expected tables: {sorted(missing)}"
|
||||
74
tests/unit/test_weekly_tiz.py
Normal file
74
tests/unit/test_weekly_tiz.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Tests for `weekly_time_in_zone`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from openrun.model import weekly_time_in_zone
|
||||
|
||||
|
||||
def _seed(conn, aid: int, when: str, *, atype: str = "running",
|
||||
z1=0.0, z2=0.0, z3=0.0, z4=0.0, z5=0.0) -> None:
|
||||
conn.execute(
|
||||
"""INSERT INTO activities
|
||||
(activity_id, start_time_local, activity_type, raw, fetched_at)
|
||||
VALUES (?, ?, ?, '{}', 'now')""",
|
||||
(aid, when, atype),
|
||||
)
|
||||
conn.execute(
|
||||
"""INSERT INTO activity_time_in_zone
|
||||
(activity_id, z1_s, z2_s, z3_s, z4_s, z5_s, total_s, source, computed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'fit', 'now')""",
|
||||
(aid, z1, z2, z3, z4, z5, z1 + z2 + z3 + z4 + z5),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def test_weekly_tiz_sums_within_week(tmp_conn) -> None:
|
||||
"""Three running activities in the same ISO week sum into one row."""
|
||||
# 2026-05-04 is Mon, 2026-05-06 Wed, 2026-05-10 Sun — all the same Mon-anchored week.
|
||||
_seed(tmp_conn, 1, "2026-05-04 06:00:00", z1=600, z2=1200)
|
||||
_seed(tmp_conn, 2, "2026-05-06 06:00:00", z2=300, z3=900)
|
||||
_seed(tmp_conn, 3, "2026-05-10 06:00:00", z4=400)
|
||||
|
||||
out = weekly_time_in_zone(tmp_conn)
|
||||
assert len(out) == 1
|
||||
row = out.iloc[0]
|
||||
assert row["z1_s"] == 600
|
||||
assert row["z2_s"] == 1500
|
||||
assert row["z3_s"] == 900
|
||||
assert row["z4_s"] == 400
|
||||
assert row["z5_s"] == 0
|
||||
assert row["total_s"] == 600 + 1200 + 300 + 900 + 400
|
||||
assert row["n_activities"] == 3
|
||||
|
||||
|
||||
def test_weekly_tiz_splits_across_weeks(tmp_conn) -> None:
|
||||
_seed(tmp_conn, 1, "2026-05-04 06:00:00", z2=1000) # week of 2026-05-04
|
||||
_seed(tmp_conn, 2, "2026-05-12 06:00:00", z3=2000) # week of 2026-05-11
|
||||
out = weekly_time_in_zone(tmp_conn)
|
||||
assert len(out) == 2
|
||||
assert out.index.min() == pd.Timestamp("2026-05-04")
|
||||
assert out.index.max() == pd.Timestamp("2026-05-11")
|
||||
|
||||
|
||||
def test_weekly_tiz_filters_by_activity_type(tmp_conn) -> None:
|
||||
_seed(tmp_conn, 1, "2026-05-04 06:00:00", atype="running", z2=1000)
|
||||
_seed(tmp_conn, 2, "2026-05-04 18:00:00", atype="cycling", z2=2000)
|
||||
out = weekly_time_in_zone(tmp_conn)
|
||||
assert out.iloc[0]["z2_s"] == 1000 # cycling excluded by default
|
||||
|
||||
|
||||
def test_weekly_tiz_date_window(tmp_conn) -> None:
|
||||
_seed(tmp_conn, 1, "2026-04-01 06:00:00", z2=100)
|
||||
_seed(tmp_conn, 2, "2026-05-04 06:00:00", z2=500)
|
||||
_seed(tmp_conn, 3, "2026-06-01 06:00:00", z2=900)
|
||||
out = weekly_time_in_zone(tmp_conn, start="2026-05-01", end="2026-05-31")
|
||||
assert len(out) == 1
|
||||
assert out.iloc[0]["z2_s"] == 500
|
||||
|
||||
|
||||
def test_weekly_tiz_empty_returns_empty_frame_with_columns(tmp_conn) -> None:
|
||||
out = weekly_time_in_zone(tmp_conn)
|
||||
assert out.empty
|
||||
assert {"z1_s", "z2_s", "z3_s", "z4_s", "z5_s", "total_s", "n_activities"} <= set(out.columns)
|
||||
Reference in New Issue
Block a user