Add matched training signals, daily recovery and performance analysis
This commit is contained in:
@@ -40,7 +40,7 @@ def test_presets_roundtrip_and_execute(app, preset):
|
||||
assert parsed(query_text(q)) == q
|
||||
with app.app_context():
|
||||
result = explore(1, "Australia/Sydney", q)
|
||||
assert result["source"] == q["source"]
|
||||
assert result["source"] == ("matched runs" if q["reduce"] else q["source"])
|
||||
client = app.test_client()
|
||||
login(client)
|
||||
assert client.get("/stats", query_string={"query": preset["query"]}).status_code == 200
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
from datetime import UTC, date, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import event
|
||||
from test_app import login
|
||||
|
||||
from app.display import persist_analysis
|
||||
from app.extensions import db
|
||||
from app.models import Activity, DailyHealth, SavedAnalysis, User
|
||||
from app.recording_windows import analyze
|
||||
from app.recovery import normalize, sync_health
|
||||
from app.route_benchmarks import similar
|
||||
from app.stats import explore, parse_query, query_text
|
||||
from app.stats_dsl import QueryError
|
||||
from app.training_signals import install, signal_queries
|
||||
from app.training_sources import reduce_runs
|
||||
|
||||
pytest_plugins = ("test_app",)
|
||||
|
||||
|
||||
def parsed(source):
|
||||
return parse_query({"query": source})
|
||||
|
||||
|
||||
def recording(seconds=900, hr=140, origin=1_700_000_000, speed=2.5):
|
||||
return [{"time": origin + i, "distance": speed * i, "elevation": 20,
|
||||
"heart_rate": hr, "cadence": 160, "ground_contact": 300,
|
||||
"stride_length": 1, "speed": speed} for i in range(seconds + 1)]
|
||||
|
||||
|
||||
def add_run(day, distance=5000, duration=1800, **kwargs):
|
||||
row = Activity(owner_id=1, source_key=str(day), title=str(day), sport="running",
|
||||
started_at=datetime.fromisoformat(day).replace(tzinfo=UTC),
|
||||
distance_m=distance, duration_s=duration, avg_hr=140, **kwargs)
|
||||
db.session.add(row)
|
||||
return row
|
||||
|
||||
|
||||
def test_windows_preserve_epoch_offsets_duration_and_sensor_time():
|
||||
units = analyze(recording(905))
|
||||
assert len(units) == 31
|
||||
assert sum(u["duration"] for u in units) == 905
|
||||
assert sum(u["distance"] for u in units) == pytest.approx(2262.5)
|
||||
assert units[20]["elapsed"] == 10
|
||||
assert units[20]["moving_offset"] == 10
|
||||
assert units[-1]["duration"] == 5
|
||||
assert all(u["grade"] == 0 for u in units)
|
||||
assert sum(sum(u["hr_seconds"].values()) for u in units) == 905
|
||||
|
||||
|
||||
def test_windows_do_not_bridge_stops_or_recording_breaks():
|
||||
rows = recording(20)
|
||||
rows += [{**r, "time": r["time"] + 40, "distance": r["distance"] + 50}
|
||||
for r in recording(20)[1:]]
|
||||
units = analyze(rows)
|
||||
assert [u["duration"] for u in units] == [20, 19]
|
||||
assert units[1]["elapsed"] == pytest.approx(41 / 60)
|
||||
rows = recording(60)
|
||||
for r in rows[20:41]:
|
||||
r["distance"] = 50
|
||||
units = analyze(rows)
|
||||
assert all(u["duration"] <= 30 for u in units)
|
||||
assert sum(u["duration"] for u in units) < 60
|
||||
|
||||
|
||||
def test_sensor_artifacts_and_missing_terrain_are_not_filled():
|
||||
rows = recording(30)
|
||||
rows[10]["heart_rate"] = 250
|
||||
for row in rows:
|
||||
row["elevation"] = None
|
||||
unit = analyze(rows)[0]
|
||||
assert unit["sensor_coverage"] < 100
|
||||
assert unit["hr_artifact_percent"] > 0
|
||||
assert "250" not in unit["hr_seconds"]
|
||||
assert unit["grade"] is unit["gap"] is None
|
||||
assert unit["terrain_coverage"] == 0
|
||||
|
||||
|
||||
def test_matched_sessions_are_independent_and_filters_precede_reduction(app):
|
||||
with app.app_context():
|
||||
db.session.query(Activity).delete()
|
||||
first = add_run("2026-09-01", samples=recording(1800, 150))
|
||||
second = add_run("2026-09-02", samples=recording(900, 130))
|
||||
db.session.flush()
|
||||
for row in (first, second):
|
||||
persist_analysis(row)
|
||||
db.session.commit()
|
||||
q = parsed("intervals\nduring 2026-09-01 to 2026-09-03\nreduce by run min 3 min\n"
|
||||
"where elapsed >= 10 min\nand duration >= 25 s\nand grade between -2 and 2 %\n"
|
||||
"summarize mean(heart_rate), count()\nby all\ncompare none")
|
||||
result = explore(1, "UTC", q)
|
||||
assert result["count"] == result["coverage"]["sessions"] == 2
|
||||
assert result["cards"][0]["value"] == "140.0"
|
||||
assert result["coverage"]["minutes"] == 25
|
||||
assert {row["id"] for row in result["rows"]} == {first.id, second.id}
|
||||
q["reduce"] = 6
|
||||
assert explore(1, "UTC", q)["count"] == 1
|
||||
|
||||
|
||||
def unit(activity_id, index, hr=140, speed=2.5, grade=0):
|
||||
values = {"heart_rate": hr, "sensor_coverage": 100, "grade": grade,
|
||||
"elapsed": index / 2, "pace": 1000 / speed}
|
||||
return SimpleNamespace(id=activity_id * 1000 + index, activity_id=activity_id,
|
||||
title="window", parent_title="run", duration_s=30,
|
||||
distance_m=30 * speed, values=values)
|
||||
|
||||
|
||||
def test_post_warmup_decoupling_requires_comparable_halves():
|
||||
rows = [unit(1, i, 130 if i < 20 else 143) for i in range(40)]
|
||||
catalog = {k: None for k in ["heart_rate", "heart_rate_drift", "grade", "pace"]}
|
||||
reduced = reduce_runs(rows, 20, catalog)[0]
|
||||
assert reduced.values["heart_rate_drift"] == pytest.approx(100 / 11)
|
||||
assert reduce_runs(rows[:39], 20, catalog) == []
|
||||
for row in rows[20:]:
|
||||
row.distance_m *= 1.2
|
||||
assert reduce_runs(rows, 20, catalog)[0].values["heart_rate_drift"] is None
|
||||
for row in rows:
|
||||
row.values["grade"] = None
|
||||
assert reduce_runs(rows, 20, catalog)[0].values["heart_rate_drift"] is None
|
||||
|
||||
|
||||
def test_daily_windows_include_rest_days_and_prior_history(app):
|
||||
with app.app_context():
|
||||
db.session.query(Activity).delete()
|
||||
add_run("2026-09-01", 10000)
|
||||
add_run("2026-09-02", 5000)
|
||||
add_run("2026-09-03", 5000)
|
||||
db.session.commit()
|
||||
q = parsed("days\nduring 2026-09-02 to 2026-09-09\n"
|
||||
"summarize latest(rolling_7d_distance), latest(run_day_streak), latest(days_since_long_run)\n"
|
||||
"by day\ncompare none")
|
||||
result = explore(1, "UTC", q)
|
||||
assert len(result["groups"]) == 8
|
||||
assert [g["raw_values"] for g in result["groups"][:3]] == [[15, 2, 1], [20, 3, 2], [20, 0, 3]]
|
||||
assert result["groups"][-1]["raw_values"] == [5, 0, 8]
|
||||
assert result["cards"][0]["value"] == "5.0"
|
||||
assert result["rows"][0]["id"] is None
|
||||
assert result["coverage"]["sessions"] == 2
|
||||
q["long_run"] = 20
|
||||
assert explore(1, "UTC", q)["cards"][-1]["value"] == "—"
|
||||
|
||||
|
||||
def test_daily_timezone_and_latest_missing_value(app):
|
||||
with app.app_context():
|
||||
db.session.query(Activity).delete()
|
||||
add_run("2026-09-01T23:30", 10000)
|
||||
db.session.add_all([DailyHealth(owner_id=1, day=date(2026, 9, 2), values={"resting_hr": 50}),
|
||||
DailyHealth(owner_id=1, day=date(2026, 9, 3), values={"sleep_hours": 8}),
|
||||
DailyHealth(owner_id=2, day=date(2026, 9, 3), values={"resting_hr": 999})])
|
||||
db.session.commit()
|
||||
q = parsed("health\nduring 2026-09-02 to 2026-09-03\nsummarize latest(resting_hr), latest(sleep_hours)\nby day\ncompare none")
|
||||
result = explore(1, "Australia/Sydney", q)
|
||||
assert result["count"] == 2
|
||||
assert [c["value"] for c in result["cards"]] == ["—", "8.0"]
|
||||
assert result["groups"][0]["raw_values"][0] == 50
|
||||
assert result["coverage"]["sessions"] == 1
|
||||
|
||||
|
||||
def test_actual_hr_band_seconds_are_not_window_average_classifications(app):
|
||||
with app.app_context():
|
||||
db.session.query(Activity).delete()
|
||||
row = add_run("2026-09-02", samples=recording(60))
|
||||
for i, sample in enumerate(row.samples):
|
||||
sample["heart_rate"] = 130 if i <= 30 else 150
|
||||
persist_analysis(row)
|
||||
db.session.get(User, 1).hr_bands = [140, 160]
|
||||
db.session.commit()
|
||||
q = parsed("runs\nduring all time\nsummarize sum(lower_hr_minutes), sum(middle_hr_minutes), sum(upper_hr_minutes)\nby all\ncompare none")
|
||||
result = explore(1, "UTC", q)
|
||||
assert result["groups"][0]["raw_values"] == [0.5, 0.5, 0]
|
||||
db.session.get(User, 1).hr_bands = None
|
||||
db.session.commit()
|
||||
assert explore(1, "UTC", q)["groups"][0]["raw_values"] == [None, None, None]
|
||||
|
||||
|
||||
def test_efforts_and_performance_metrics_preserve_missing_distances(app):
|
||||
with app.app_context():
|
||||
row = db.session.get(Activity, 1)
|
||||
row.best_efforts = [{"distance": 1000, "seconds": 301}, {"distance": 5000, "seconds": 1800}]
|
||||
db.session.commit()
|
||||
q = parsed("efforts\nduring all time\nwhere distance = 5 km\nsummarize min(effort_time)\nby all\ncompare none")
|
||||
result = explore(1, "UTC", q)
|
||||
assert result["cards"][0]["value"] == "30:00"
|
||||
assert result["rows"][0]["id"] == 1
|
||||
q = parsed("runs\nduring all time\nsummarize min(best_5km), min(best_10km)\nby month\ncompare none")
|
||||
assert [c["value"] for c in explore(1, "UTC", q)["cards"]] == ["30:00", "—"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("definition", signal_queries(route_id=1), ids=lambda d: d["name"])
|
||||
def test_proposed_queries_roundtrip_execute_and_render(app, definition):
|
||||
q = parsed(definition["query"])
|
||||
assert parsed(query_text(q)) == q
|
||||
with app.app_context():
|
||||
explore(1, "Australia/Sydney", q)
|
||||
client = app.test_client()
|
||||
login(client)
|
||||
assert client.get("/stats", query_string={"query": definition["query"]}).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["runs", "days", "health", "efforts"])
|
||||
def test_reduction_rejects_non_recording_sources(source):
|
||||
with pytest.raises(QueryError):
|
||||
parsed(source + "\nreduce by run min 3 min\nsummarize heart_rate")
|
||||
|
||||
|
||||
def test_queries_never_load_recordings_or_other_accounts(app):
|
||||
with app.app_context():
|
||||
statements = []
|
||||
def capture(_conn, _cursor, statement, _params, _context, _many):
|
||||
statements.append(statement)
|
||||
event.listen(db.engine, "before_cursor_execute", capture)
|
||||
try:
|
||||
for source in ("runs", "intervals", "efforts", "days", "health"):
|
||||
explore(1, "UTC", parsed(source + "\nduring last 16 weeks\nsummarize heart_rate\nby day"))
|
||||
finally:
|
||||
event.remove(db.engine, "before_cursor_execute", capture)
|
||||
assert not any("activity.samples" in sql or "activity.display_data" in sql for sql in statements)
|
||||
|
||||
|
||||
def test_route_matching_keeps_direction_and_rejects_breaks_and_other_owners(app):
|
||||
line = [[[-33, 151], [-33, 151.005], [-33, 151.01]]]
|
||||
assert similar(line, line)
|
||||
assert not similar(line, [list(reversed(line[0]))])
|
||||
assert not similar(line, [line[0][:2], line[0][2:]])
|
||||
with app.app_context():
|
||||
db.session.get(Activity, 1).route_geometry = line
|
||||
other = Activity(owner_id=2, source_key="other-route", title="other", sport="running",
|
||||
started_at=datetime(2026, 9, 30, tzinfo=UTC), route_geometry=line)
|
||||
db.session.add(other)
|
||||
db.session.commit()
|
||||
q = parsed(f"runs\nduring all time\nroute similar to {other.id}\nsummarize pace")
|
||||
with pytest.raises(QueryError, match="own recorded"):
|
||||
explore(1, "UTC", q)
|
||||
|
||||
|
||||
def test_installation_is_idempotent_private_and_keeps_edits(app):
|
||||
with app.app_context():
|
||||
user = db.session.get(User, 1)
|
||||
definitions = signal_queries()
|
||||
assert install(user, definitions) == 12
|
||||
rows = db.session.scalars(db.select(SavedAnalysis).filter_by(owner_id=1)).all()
|
||||
assert sum(r.pinned for r in rows) == 5
|
||||
rows[0].notes = "My annotation"
|
||||
db.session.commit()
|
||||
assert install(user, definitions) == 0
|
||||
assert rows[0].notes == "My annotation"
|
||||
client = app.test_client()
|
||||
login(client, "other")
|
||||
assert client.get("/stats/analyses").json["analyses"] == []
|
||||
|
||||
|
||||
def test_private_context_and_bands_validate_and_do_not_leak(app):
|
||||
client = app.test_client()
|
||||
login(client)
|
||||
assert client.post("/training/bands", data={"lower": 145, "upper": 140}).status_code == 400
|
||||
assert client.post("/training/bands", data={"lower": 140, "upper": 160}).status_code == 302
|
||||
assert client.post("/activities/1/context", data={"perceived_effort": 11}).status_code == 400
|
||||
assert client.post("/activities/1/context", data={"perceived_effort": 7, "fatigue": 3, "notes": "PRIVATE-CONTEXT"}).status_code == 302
|
||||
assert b"PRIVATE-CONTEXT" in client.get("/activities/1").data
|
||||
with app.app_context():
|
||||
db.session.get(Activity, 1).share_token = "test-context-token"
|
||||
db.session.commit()
|
||||
assert b"PRIVATE-CONTEXT" not in app.test_client().get("/shared/runs/test-context-token").data
|
||||
other = app.test_client()
|
||||
login(other, "other")
|
||||
assert other.post("/activities/1/context", data={"notes": "attack"}).status_code == 404
|
||||
app.config["WTF_CSRF_ENABLED"] = True
|
||||
assert client.post("/activities/1/context", data={}).status_code == 400
|
||||
assert client.post("/training/bands", data={}).status_code == 400
|
||||
|
||||
|
||||
def test_recovery_mapping_does_not_turn_provider_missing_sentinels_into_data():
|
||||
assert normalize({"restingHeartRate": None, "averageStressLevel": -1}, {}, {}) == {}
|
||||
assert normalize({"restingHeartRate": 55, "averageStressLevel": 32},
|
||||
{"hrvSummary": {"lastNightAvg": 48}},
|
||||
{"dailySleepDTO": {"sleepTimeSeconds": 28800}}) == {
|
||||
"resting_hr": 55, "stress": 32, "hrv": 48, "sleep_hours": 8}
|
||||
|
||||
|
||||
def test_daily_import_is_bounded_owner_scoped_and_stops_on_failure(app):
|
||||
class API:
|
||||
calls = 0
|
||||
fail = False
|
||||
def get_stats(self, _day):
|
||||
self.calls += 1
|
||||
if self.fail:
|
||||
raise RuntimeError("PROVIDER-SECRET")
|
||||
return {"restingHeartRate": 50}
|
||||
def get_hrv_data(self, _day):
|
||||
self.calls += 1
|
||||
return {"hrvSummary": {"lastNightAvg": 45}}
|
||||
def get_sleep_data(self, _day):
|
||||
self.calls += 1
|
||||
return {"dailySleepDTO": {"sleepTimeSeconds": 28800}}
|
||||
with app.app_context():
|
||||
api = API()
|
||||
user = db.session.get(User, 1)
|
||||
assert sync_health(api, user, days=30, limit=4)["days"] == 4
|
||||
assert api.calls == 12
|
||||
assert db.session.scalar(db.select(db.func.count(DailyHealth.id)).where(DailyHealth.owner_id == 1)) == 4
|
||||
assert db.session.scalar(db.select(db.func.count(DailyHealth.id)).where(DailyHealth.owner_id == 2)) == 0
|
||||
api.fail = True
|
||||
result = sync_health(api, user, days=30, limit=10)
|
||||
assert result["days"] == 1 and result["failures"] == 1
|
||||
assert "PROVIDER-SECRET" not in result["message"]
|
||||
+30
-1
@@ -10,7 +10,36 @@ from garminconnect import GarminConnectAuthenticationError
|
||||
from app import create_app
|
||||
from app.extensions import db
|
||||
from app.models import GarminConnection, User
|
||||
from app.sync import run_due, session_lock
|
||||
from app.sync import cipher, run_due, session_lock
|
||||
|
||||
|
||||
def test_optional_recovery_failure_does_not_break_activity_sync(connected, monkeypatch):
|
||||
app, _client, _api, _sync = connected
|
||||
def failed_health(*_args):
|
||||
raise RuntimeError("PRIVATE-PROVIDER-RESPONSE")
|
||||
monkeypatch.setattr("app.recovery.sync_health", failed_health)
|
||||
with app.app_context():
|
||||
connection = db.session.scalar(db.select(GarminConnection))
|
||||
connection.health_enabled = True
|
||||
db.session.commit()
|
||||
assert run_due() == 1
|
||||
assert connection.failures == 0 and connection.last_success
|
||||
assert "Activity sync completed" in connection.health_message
|
||||
assert "PRIVATE-PROVIDER" not in connection.health_message
|
||||
|
||||
|
||||
def test_recovery_login_failure_preserves_encrypted_session(connected):
|
||||
app, _client, api, _sync = connected
|
||||
api.login.side_effect = RuntimeError("PRIVATE-LOGIN-RESPONSE")
|
||||
with app.app_context():
|
||||
original = db.session.scalar(db.select(GarminConnection)).tokens
|
||||
result = app.test_cli_runner().invoke(args=["backfill-health", "--email", "owner@example.test", "--days", "2"])
|
||||
assert result.exit_code == 1 and "PRIVATE-LOGIN" not in result.output
|
||||
with app.app_context():
|
||||
connection = db.session.scalar(db.select(GarminConnection))
|
||||
assert connection.tokens == original
|
||||
assert not connection.health_enabled
|
||||
assert cipher().decrypt(connection.tokens.encode())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
Reference in New Issue
Block a user