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)