stride-running-coach / tests /test_analytics.py
nicholasg1997
update analytics module with improved run analysis and add unit tests
faf25a5
"""Unit tests for the analytics engine.
Run with: uv run pytest
Each stage is tested in isolation so a failure points at the exact function.
Dates are built relative to "now" so the rolling-week buckets always line up
no matter when the suite runs.
"""
from datetime import datetime, timedelta
import pytest
from rate_my_run.analytics import (
Run,
WeekStats,
clean_activities,
group_by_week,
compute_trends,
analyze,
SPIKE_PCT,
BREAK_DAYS,
)
# --- helpers -----------------------------------------------------------------
def _iso(days_ago: float, hour: int = 12) -> str:
"""ISO timestamp `days_ago` days before now, pinned to midday to stay
clear of week-boundary edges."""
d = (datetime.now() - timedelta(days=days_ago)).replace(
hour=hour, minute=0, second=0, microsecond=0
)
return d.isoformat()
def raw_run(days_ago, distance_m, moving_time_s, type="Run"):
return {
"type": type,
"distance": distance_m,
"moving_time": moving_time_s,
"start_date": _iso(days_ago),
}
def week(week_start, num_runs, distance_km, longest_km, pace):
"""Build a WeekStats directly, for testing compute_trends in isolation."""
return WeekStats(week_start, num_runs, distance_km, longest_km, pace)
# --- Run --------------------------------------------------------------------
def test_run_unit_conversions():
run = Run(start=_iso(0), distance_m=5000, moving_time_s=1500)
assert run.distance_km == 5.0
assert run.moving_time_m == 25.0
assert run.pace_min_per_km == pytest.approx(5.0) # 25 min / 5 km
# --- clean_activities -------------------------------------------------------
def test_clean_keeps_only_real_runs():
raw = [
raw_run(1, 5000, 1500), # keep
raw_run(1, 20000, 3600, type="Ride"), # drop: not a run
raw_run(1, 0, 0), # drop: zero distance/time
raw_run(1, 30, 7), # drop: under min_distance (50 m)
]
runs = clean_activities(raw)
assert len(runs) == 1
assert runs[0].distance_m == 5000
def test_clean_min_distance_boundary():
raw = [
raw_run(1, 49, 30), # below 50 m default → drop
raw_run(1, 60, 30), # above 50 m → keep
]
runs = clean_activities(raw)
assert [r.distance_m for r in runs] == [60]
# --- group_by_week ----------------------------------------------------------
def test_group_returns_one_bucket_per_week_newest_last():
runs = clean_activities([raw_run(1, 5000, 1500)])
weeks = group_by_week(runs, num_weeks=8)
assert len(weeks) == 8
# reversed to oldest -> newest, so the last bucket is the most recent
assert weeks[-1].week_start > weeks[0].week_start
def test_group_buckets_runs_into_correct_weeks():
raw = [
raw_run(1, 5000, 1500), raw_run(3, 6000, 1820), raw_run(5, 5200, 1560), # this week
raw_run(8, 5000, 1650), raw_run(11, 5000, 1640), # last week
raw_run(16, 8000, 2700), # 2 weeks ago
]
weeks = group_by_week(clean_activities(raw), num_weeks=8)
this_week, last_week, two_ago = weeks[-1], weeks[-2], weeks[-3]
assert this_week.num_runs == 3
assert this_week.distance_km == pytest.approx(16.2)
assert last_week.num_runs == 2
assert last_week.distance_km == pytest.approx(10.0)
assert two_ago.num_runs == 1
assert two_ago.longest_run_km == pytest.approx(8.0)
def test_group_empty_week_has_none_pace():
weeks = group_by_week(clean_activities([raw_run(1, 5000, 1500)]), num_weeks=8)
# the oldest bucket has no runs
assert weeks[0].num_runs == 0
assert weeks[0].avg_pace_min_per_km is None
def test_group_pace_is_distance_weighted_not_naive_mean():
# 1 km @ 6:00 + 9 km @ 5:00 -> weighted 5.1, naive mean would be 5.5
raw = [raw_run(1, 1000, 360), raw_run(2, 9000, 2700)]
weeks = group_by_week(clean_activities(raw), num_weeks=8)
assert weeks[-1].avg_pace_min_per_km == pytest.approx(5.1)
# --- compute_trends ---------------------------------------------------------
def _padded(*recent):
"""Pad with empty weeks so the list is 8 long, with `recent` as the tail
(oldest -> newest ordering)."""
today = datetime.now().date()
empties = [week(today - timedelta(days=7 * i), 0, 0.0, 0.0, None)
for i in range(8 - len(recent))]
return empties + list(recent)
def test_trends_mileage_spike_and_improving():
weeks = _padded(
week(datetime.now().date() - timedelta(days=14), 1, 8.0, 8.0, 5.6),
week(datetime.now().date() - timedelta(days=7), 2, 10.0, 5.0, 5.5),
week(datetime.now().date(), 3, 16.2, 6.0, 5.0),
)
trends = compute_trends(weeks, days_since_last_run=1)
assert trends["mileage_change_pct"] == pytest.approx(62.0, abs=0.5)
assert trends["pace_trend"] == "improving"
assert "mileage_spike" in trends["signals"]
assert "increasing_consistency" in trends["signals"]
def test_trends_declining_pace():
weeks = _padded(
week(datetime.now().date() - timedelta(days=7), 3, 12.0, 5.0, 5.0),
week(datetime.now().date(), 3, 12.0, 5.0, 6.0), # slower than baseline
)
trends = compute_trends(weeks, days_since_last_run=1)
assert trends["pace_trend"] == "declining"
def test_trends_plateau_within_band():
weeks = _padded(
week(datetime.now().date() - timedelta(days=7), 3, 12.0, 5.0, 5.50),
week(datetime.now().date(), 3, 12.0, 5.0, 5.52), # within PACE_BAND
)
trends = compute_trends(weeks, days_since_last_run=1)
assert trends["pace_trend"] == "plateauing"
def test_trends_mileage_none_when_last_week_empty():
weeks = _padded(
week(datetime.now().date() - timedelta(days=7), 0, 0.0, 0.0, None),
week(datetime.now().date(), 3, 12.0, 5.0, 5.0),
)
trends = compute_trends(weeks, days_since_last_run=1)
assert trends["mileage_change_pct"] is None
assert "mileage_spike" not in trends["signals"]
def test_trends_returning_after_break_signal():
weeks = _padded(week(datetime.now().date(), 1, 5.0, 5.0, 5.0))
trends = compute_trends(weeks, days_since_last_run=BREAK_DAYS + 5)
assert "returning_after_break" in trends["signals"]
def test_trends_potential_fatigue():
# more mileage AND slower -> fatigue
weeks = _padded(
week(datetime.now().date() - timedelta(days=7), 3, 10.0, 5.0, 5.0),
week(datetime.now().date(), 3, 13.0, 5.0, 6.0), # +30% miles, slower
)
trends = compute_trends(weeks, days_since_last_run=1)
assert trends["mileage_change_pct"] > SPIKE_PCT
assert trends["pace_trend"] == "declining"
assert "potential_fatigue" in trends["signals"]
# --- analyze (end to end) ---------------------------------------------------
def test_analyze_end_to_end():
raw = [
raw_run(1, 5000, 1500), raw_run(3, 6000, 1820), raw_run(5, 5200, 1560),
raw_run(8, 5000, 1650), raw_run(11, 5000, 1640),
raw_run(16, 8000, 2700),
raw_run(2, 30, 7), # junk
raw_run(2, 20000, 3600, type="Ride"), # junk
]
summary = analyze(raw)
assert len(summary.weeks) == 8
assert summary.pace_trend == "improving"
assert "mileage_spike" in summary.signals
assert summary.days_since_last_run <= 2
def test_analyze_days_since_uses_most_recent_not_first():
# Regression: most recent run is NOT first in the input list.
raw = [
raw_run(10, 5000, 1500), # older, listed first
raw_run(2, 5000, 1500), # most recent, listed later
]
summary = analyze(raw)
# must reflect the 2-day-ago run, not the 10-day-ago one
assert summary.days_since_last_run <= 3
def test_analyze_handles_no_runs():
summary = analyze([])
assert summary.days_since_last_run == 999
assert all(w.num_runs == 0 for w in summary.weeks)
assert summary.mileage_change_pct is None
assert summary.pace_trend == "insufficient_data"
assert "returning_after_break" in summary.signals # 999 > BREAK_DAYS