334 lines
13 KiB
Python
334 lines
13 KiB
Python
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
from sqlalchemy import event
|
|
from test_app import login
|
|
|
|
from app.display import persist_analysis
|
|
from app.effort_segments import analyze
|
|
from app.extensions import db
|
|
from app.models import Activity
|
|
from app.stats import explore, parse_query, query_text
|
|
from app.stats_dsl import QueryError
|
|
from app.stats_presets import PRESETS
|
|
|
|
pytest_plugins = ("test_app",)
|
|
|
|
|
|
def parsed(text):
|
|
return parse_query({"query": text})
|
|
|
|
|
|
def activity(key, instant, hr=140, owner=1, samples=None):
|
|
return Activity(
|
|
owner_id=owner,
|
|
source_key=key,
|
|
title=key,
|
|
sport="running",
|
|
started_at=instant,
|
|
distance_m=10000,
|
|
duration_s=3300,
|
|
elevation_m=30,
|
|
avg_hr=hr,
|
|
samples=samples or [],
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("preset", PRESETS, ids=[p["name"] for p in PRESETS])
|
|
def test_presets_roundtrip_and_execute(app, preset):
|
|
q = parsed(preset["query"])
|
|
assert parsed(query_text(q)) == q
|
|
with app.app_context():
|
|
result = explore(1, "Australia/Sydney", q)
|
|
assert result["source"] == q["source"]
|
|
client = app.test_client()
|
|
login(client)
|
|
assert client.get("/stats", query_string={"query": preset["query"]}).status_code == 200
|
|
|
|
|
|
def test_library_crud_pin_privacy_and_live_results(app):
|
|
client = app.test_client()
|
|
assert client.get("/stats/analyses").status_code == 302
|
|
login(client)
|
|
source = "runs\nduring all time\nsummarize distance\nby month\ncompare none"
|
|
response = client.post(
|
|
"/stats/analyses",
|
|
json={
|
|
"name": "Volume",
|
|
"query": source,
|
|
"pinned": True,
|
|
"collection": "Training",
|
|
"notes": "Check consistency",
|
|
},
|
|
)
|
|
assert response.status_code == 201
|
|
row = response.json["analysis"]
|
|
assert row["pinned"] and row["collection"] == "Training"
|
|
analysis_id = row["id"]
|
|
assert client.get("/stats/analyses").json["analyses"][0]["id"] == analysis_id
|
|
assert client.get(f"/stats?analysis={analysis_id}").status_code == 200
|
|
assert b"Volume" in client.get("/dashboard").data
|
|
result = client.get(f"/stats/analyses/{analysis_id}/results")
|
|
assert result.json["results"]["count"] == 1
|
|
assert "no-store" in result.headers["Cache-Control"]
|
|
with app.app_context():
|
|
db.session.add(activity("new", datetime(2026, 9, 29, tzinfo=UTC)))
|
|
db.session.commit()
|
|
assert client.get(f"/stats/analyses/{analysis_id}/results").json["results"]["count"] == 2
|
|
other = app.test_client()
|
|
login(other, "other")
|
|
assert other.get("/stats/analyses").json["analyses"] == []
|
|
for method, path in (
|
|
("get", f"/stats?analysis={analysis_id}"),
|
|
("get", f"/stats/analyses/{analysis_id}/results"),
|
|
("patch", f"/stats/analyses/{analysis_id}"),
|
|
("delete", f"/stats/analyses/{analysis_id}"),
|
|
):
|
|
assert getattr(other, method)(path, json={"name": "Stolen"}).status_code == 404
|
|
assert other.post("/stats/analyses/order", json={"ids": [analysis_id]}).status_code == 400
|
|
assert (
|
|
client.patch(
|
|
f"/stats/analyses/{analysis_id}", json={"width": "full", "view": "value"}
|
|
).json["analysis"]["width"]
|
|
== "full"
|
|
)
|
|
assert (
|
|
client.post("/stats/analyses/order", json={"ids": [analysis_id, analysis_id]}).status_code
|
|
== 400
|
|
)
|
|
assert client.post("/stats/analyses/order", json={"ids": [analysis_id]}).status_code == 200
|
|
assert client.delete(f"/stats/analyses/{analysis_id}").status_code == 204
|
|
assert client.get(f"/stats/analyses/{analysis_id}/results").status_code == 404
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
None,
|
|
[],
|
|
{"name": ""},
|
|
{"name": "x", "query": "runs"},
|
|
{"name": "x", "query": "runs\nsummarize pace", "pinned": "yes"},
|
|
{"name": "x", "query": "runs\nsummarize pace", "width": []},
|
|
],
|
|
)
|
|
def test_library_rejects_invalid_payloads(app, payload):
|
|
client = app.test_client()
|
|
login(client)
|
|
assert client.post("/stats/analyses", json=payload).status_code == 400
|
|
|
|
|
|
def test_library_mutations_require_csrf(app):
|
|
client = app.test_client()
|
|
login(client)
|
|
app.config["WTF_CSRF_ENABLED"] = True
|
|
assert (
|
|
client.post(
|
|
"/stats/analyses", json={"name": "x", "query": "runs\nsummarize pace"}
|
|
).status_code
|
|
== 400
|
|
)
|
|
assert client.post("/stats/analyses/order", json={"ids": []}).status_code == 400
|
|
|
|
|
|
def test_named_summary_with_commas_quotes_and_reference_roundtrip():
|
|
q = parsed('runs\nsummarize weighted_mean (heart_rate) as "HR, \\"easy\\""')
|
|
assert q["labels"] == {"weighted_mean(heart_rate)": 'HR, "easy"'}
|
|
assert parsed(query_text(q)) == q
|
|
ref = parsed("runs\nsummarize heart_rate\nreference heart_rate = 150 bpm")
|
|
assert ref["reference"]["value"] == 150
|
|
assert parsed(query_text(ref)) == ref
|
|
|
|
|
|
def test_histogram_drilldown_and_quartiles_cover_exact_inputs(app):
|
|
with app.app_context():
|
|
db.session.add_all(
|
|
[
|
|
activity("ten", datetime(2026, 9, 29, tzinfo=UTC)),
|
|
activity("ten-more", datetime(2026, 9, 28, tzinfo=UTC)),
|
|
]
|
|
)
|
|
db.session.commit()
|
|
q = parsed(
|
|
"runs\nduring 2026-09-01 to 2026-09-30\nsummarize distance\nby all\ncompare none\nchart histogram"
|
|
)
|
|
result = explore(1, "Australia/Sydney", q)
|
|
histogram = result["chart"]["series"][0]["histogram"]
|
|
ids = []
|
|
for bucket, count in zip(histogram["buckets"], histogram["counts"], strict=True):
|
|
selected = explore(1, "Australia/Sydney", q, selected_bucket=bucket)
|
|
assert selected["evidence_count"] == count
|
|
ids.extend(row["id"] for row in selected["rows"])
|
|
assert len(ids) == len(set(ids)) == 3
|
|
q["chart"] = "box"
|
|
box = explore(1, "Australia/Sydney", q)["chart"]["series"][0]["boxes"][0]
|
|
assert box == {"low": 5, "q1": 7.5, "median": 10, "q3": 10, "high": 10, "count": 3}
|
|
|
|
|
|
def test_lap_source_does_not_inherit_parent_sensor_or_terrain(app):
|
|
with app.app_context():
|
|
q = parsed(
|
|
"laps\nduring 2026-09-30 to 2026-09-30\nsummarize pace, heart_rate, elevation\nby run\ncompare none"
|
|
)
|
|
result = explore(1, "Australia/Sydney", q)
|
|
assert result["count"] == 1
|
|
assert result["rows"][0]["raw_values"] == [360, 140, None]
|
|
assert result["cards"][2]["coverage"] == 0
|
|
|
|
|
|
def test_matched_baseline_flags_reset_at_missing_bucket(app):
|
|
with app.app_context():
|
|
start = datetime(2026, 9, 7, tzinfo=UTC)
|
|
rows = [
|
|
activity(f"base-{i}", start - timedelta(days=20 - i), 139 + i % 3) for i in range(12)
|
|
]
|
|
# An unmatched fast run and another owner's run cannot affect the baseline.
|
|
fast = activity("fast", start - timedelta(days=4), 200)
|
|
fast.duration_s = 2400
|
|
rows.extend([fast, activity("other-owner", start - timedelta(days=3), 230, owner=2)])
|
|
for week in (0, 1, 3, 4):
|
|
rows.extend(
|
|
activity(f"high-{week}-{i}", start + timedelta(days=week * 7 + i), 160)
|
|
for i in range(3)
|
|
)
|
|
db.session.add_all(rows)
|
|
db.session.commit()
|
|
q = parsed(
|
|
"runs\nduring 2026-09-07 to 2026-10-11\nwhere pace between 5:20 and 5:40 /km\nsummarize weighted_mean(heart_rate)\nby week\ncompare none\nbaseline previous 30 days\nflag above baseline 1.5 sd min 3 runs sustained 2 buckets"
|
|
)
|
|
result = explore(1, "Australia/Sydney", q)
|
|
assert result["insights"][0]["count"] == 12
|
|
assert result["insights"][0]["baseline"] == 140
|
|
assert result["chart"]["series"][0]["baseline"]["flags"] == [
|
|
False,
|
|
True,
|
|
False,
|
|
False,
|
|
True,
|
|
]
|
|
assert len(result["insights"][0]["flagged"]) == 2
|
|
|
|
|
|
def test_rolling_window_uses_history_before_filters(app):
|
|
with app.app_context():
|
|
db.session.add_all(
|
|
[
|
|
activity("before", datetime(2026, 9, 1, tzinfo=UTC), 120),
|
|
activity("target", datetime(2026, 9, 8, tzinfo=UTC), 160),
|
|
]
|
|
)
|
|
db.session.commit()
|
|
q = parsed(
|
|
"runs\nduring 2026-09-08 to 2026-09-08\nlet hr_14 = rolling_mean(heart_rate, 14 days)\nwhere heart_rate >= 150 bpm\nsummarize hr_14\nby run\ncompare none"
|
|
)
|
|
assert explore(1, "Australia/Sydney", q)["rows"][0]["raw_values"] == [140]
|
|
assert parsed(query_text(q)) == q
|
|
|
|
|
|
def test_cohorts_partition_without_double_counting(app):
|
|
with app.app_context():
|
|
db.session.add(activity("hard", datetime(2026, 9, 29, tzinfo=UTC), 160))
|
|
db.session.commit()
|
|
q = parsed(
|
|
'runs\nduring 2026-09-01 to 2026-09-30\nsummarize sum(distance)\nby all\ncompare none\ncohort "Easy" where heart_rate <= 145 bpm\ncohort "All HR" where heart_rate >= 100 bpm\nchart stacked'
|
|
)
|
|
assert parsed(query_text(q)) == q
|
|
cohorts = explore(1, "Australia/Sydney", q)["chart"]["cohorts"]
|
|
assert [c["values"] for c in cohorts] == [[5], [10], [None]]
|
|
|
|
|
|
def recording(hr_first=140, hr_second=160):
|
|
return [
|
|
{
|
|
"time": i,
|
|
"distance": i * 3,
|
|
"speed": 3,
|
|
"heart_rate": hr_first if i <= 300 else hr_second,
|
|
"elevation": 10,
|
|
}
|
|
for i in range(601)
|
|
]
|
|
|
|
|
|
def test_segments_and_drift_full_resolution():
|
|
effort, segments = analyze(recording())
|
|
assert len(segments) == 2
|
|
assert [s["duration"] for s in segments] == [300, 300]
|
|
assert [s["heart_rate"] for s in segments] == [140, 160]
|
|
assert effort["pace_variability"] == pytest.approx(0)
|
|
assert effort["heart_rate_drift"] == pytest.approx(12.5)
|
|
assert effort["sensor_coverage"] == 100
|
|
missing = recording()
|
|
for row in missing[200:]:
|
|
row["heart_rate"] = None
|
|
row.pop("elevation")
|
|
summary, _ = analyze(missing)
|
|
assert summary["heart_rate_drift"] is None
|
|
assert summary["elevation"] is None
|
|
assert summary["sensor_coverage"] < 40
|
|
reset = [{"time": 0, "distance": 10}, {"time": 11, "distance": 50}, {"time": 12, "distance": 0}]
|
|
assert analyze(reset)[1] == []
|
|
|
|
|
|
def test_segment_queries_use_compact_owner_scoped_data(app):
|
|
with app.app_context():
|
|
run = activity("recorded", datetime(2026, 9, 29, tzinfo=UTC), samples=recording())
|
|
other = activity(
|
|
"secret", datetime(2026, 9, 29, tzinfo=UTC), owner=2, samples=recording(200, 220)
|
|
)
|
|
db.session.add_all([run, other])
|
|
persist_analysis(run)
|
|
persist_analysis(other)
|
|
db.session.commit()
|
|
queries = []
|
|
|
|
def capture(_conn, _cursor, statement, _params, _context, _many):
|
|
queries.append(statement)
|
|
|
|
event.listen(db.engine, "before_cursor_execute", capture)
|
|
try:
|
|
q = parsed(
|
|
"segments\nduring 2026-09-29 to 2026-09-29\nwhere pace_variability <= 5 %\nand sensor_coverage >= 80 %\nsummarize heart_rate, pace\nby run\ncompare none"
|
|
)
|
|
result = explore(1, "Australia/Sydney", q)
|
|
finally:
|
|
event.remove(db.engine, "before_cursor_execute", capture)
|
|
assert result["count"] == 2
|
|
assert {row["id"] for row in result["rows"]} == {run.id}
|
|
assert {row["raw_values"][0] for row in result["rows"]} == {140, 160}
|
|
assert all("activity.samples" not in query for query in queries)
|
|
assert parsed(query_text(q)) == q
|
|
|
|
|
|
def test_previous_year_comparison(app):
|
|
with app.app_context():
|
|
db.session.add(activity("prior-year", datetime(2025, 9, 30, tzinfo=UTC), 150))
|
|
db.session.commit()
|
|
q = parsed(
|
|
"runs\nduring 2026-09-01 to 2026-09-30\nsummarize heart_rate\nby month\ncompare previous year"
|
|
)
|
|
assert parsed(query_text(q)) == q
|
|
result = explore(1, "Australia/Sydney", q)
|
|
assert result["prior_count"] == 1
|
|
assert result["cards"][0]["previous"] == "150.0"
|
|
assert result["previous_end"].isoformat() == "2025-09-30"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"source",
|
|
[
|
|
"runs\nsummarize pace\nflag above baseline 2 sd min 3 runs sustained 2 buckets",
|
|
"runs\nsummarize pace\nbaseline previous 1 days",
|
|
"runs\nsummarize pace\nby weekday\nbaseline previous 90 days",
|
|
"runs\nsummarize pace\nchart heatmap",
|
|
"runs\nsummarize pace\nchart stacked",
|
|
"runs\nsummarize pace\ncolor by distance",
|
|
"runs\nsummarize pace\nreference heart_rate = 150 bpm",
|
|
"segments\nsummarize rolling_7d_distance",
|
|
"runs\nlet bad = rolling_mean(password, 10 days)\nsummarize bad",
|
|
],
|
|
)
|
|
def test_invalid_analysis_features(source):
|
|
with pytest.raises(QueryError):
|
|
parsed(source)
|