Add saved analysis workspace and live dashboard statistics
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
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)
|
||||
+3
-1
@@ -389,7 +389,9 @@ def test_import_and_refresh_persist_efforts_in_the_run_transaction(app):
|
||||
row = db.session.get(Activity, row_id)
|
||||
assert row.efforts_version == EFFORTS_VERSION
|
||||
assert row.best_efforts[0]["seconds"] == 300
|
||||
assert row.analysis_version == 1
|
||||
from app.display import ANALYSIS_VERSION
|
||||
|
||||
assert row.analysis_version == ANALYSIS_VERSION
|
||||
assert row.analysis_summary["summary"]["samples"] == 2
|
||||
assert activity_from_fit(1, "test:new", [doc]) is None
|
||||
doc["record"][1]["timestamp"] = "2026-09-30T10:04:00Z"
|
||||
|
||||
@@ -38,7 +38,12 @@ def test_persisted_summaries_equal_full_resolution_and_raw_data_is_untouched():
|
||||
expected = analyse(row)
|
||||
persist_analysis(row)
|
||||
assert row.analysis_version == ANALYSIS_VERSION
|
||||
assert row.analysis_summary == {key: expected[key] for key in ("summary", "terrain", "laps")}
|
||||
for key in ("terrain", "laps"):
|
||||
assert row.analysis_summary[key] == expected[key]
|
||||
for key, value in expected["summary"].items():
|
||||
assert row.analysis_summary["summary"][key] == value
|
||||
assert row.analysis_summary["segments"]
|
||||
assert "heart_rate_drift" in row.analysis_summary["summary"]
|
||||
assert row.samples == original
|
||||
assert len(row.display_data["chart"]) == CHART_LIMIT
|
||||
assert row.display_data["chart"][0]["distance"] == 0
|
||||
|
||||
Reference in New Issue
Block a user