Replace fixed DSL fields with parameterized metric expressions

This commit is contained in:
Peter Stockings
2026-10-04 10:24:12 +11:00
parent 418e03583a
commit d660b86bb0
18 changed files with 402 additions and 137 deletions
+2 -2
View File
@@ -324,10 +324,10 @@ def test_previous_year_comparison(app):
"runs\nsummarize pace\nchart stacked",
"runs\nsummarize pace\ncolor by distance",
"runs\nsummarize pace\nreference heart_rate = 150 bpm",
"segments\nsummarize rolling_7d_distance",
"segments\nsummarize rolling_sum(distance, 7 days)",
"runs\nlet bad = rolling_mean(password, 10 days)\nsummarize bad",
],
)
def test_invalid_analysis_features(source):
with pytest.raises(QueryError):
parsed(source)
parsed(source)
+3 -3
View File
@@ -63,14 +63,14 @@ def test_predecessor_and_rolling_use_history_before_filters(app):
db.session.commit()
result = analyze(
"during 2026-09-09 to 2026-09-09\nwhere distance >= 5 km\n"
"summarize gap_since_previous_run, rolling_7d_distance, rolling_7d_count, run_day_streak\nby run\ncompare none"
"summarize gap_since_previous_run, rolling_sum(distance, 7 days), rolling_count(distance, 7 days), run_day_streak\nby run\ncompare none"
)
assert result["count"] == 1
assert result["rows"][0]["raw_values"] == [1, 11, 2, 2]
assert result["rows"][0]["previous_run"]["title"] == "intermediate"
# The exact 168-hour boundary is excluded from the rolling window.
result = analyze(
"during 2026-09-08 to 2026-09-08\nsummarize rolling_7d_distance\nby run\ncompare none"
"during 2026-09-08 to 2026-09-08\nsummarize rolling_sum(distance, 7 days)\nby run\ncompare none"
)
assert result["rows"][0]["raw_values"] == [1]
@@ -265,4 +265,4 @@ def test_sensor_zero_missing_and_initial_gap_coverage(app):
result = analyze(
"during 2026-09-01 to 2026-09-01\nsummarize count(gap_since_previous_run), count(heart_rate), count()\nby all\ncompare none"
)
assert result["groups"][0]["raw_values"] == [0, 0, 1]
assert result["groups"][0]["raw_values"] == [0, 0, 1]
+113
View File
@@ -0,0 +1,113 @@
from datetime import UTC, datetime
import pytest
from app.analysis_migration import migrate, rewrite
from app.extensions import db
from app.models import Activity, SavedAnalysis, User
from app.stats import METRICS, explore, parse_query, query_text
from app.stats_dsl import QueryError
pytest_plugins = ('test_app',)
def parsed(text):
return parse_query({'query': text})
def test_removed_fields_and_clause_are_rejected():
removed = ['best_5km', 'best_10km', 'days_since_long_run', 'days_since_run',
'rolling_7d_distance', 'rolling_28d_count']
for field in removed:
assert field not in METRICS
with pytest.raises(QueryError):
parsed(f'runs\nsummarize {field}')
with pytest.raises(QueryError):
parsed('days\nlong run distance >= 10 km\nsummarize distance')
def test_expressions_in_filters_sort_calculations_and_named_summaries(app):
text = ('runs\nduring all time\nlet doubled = best_time(5000 m) * 2\n'
'where best_time(5000 m) < 31 min\n'
'summarize min(best_time(5000 m)) as "best_time(5 km)", doubled\n'
'sort by best_time(5000 m) asc\nby run\ncompare none')
q = parsed(text)
canonical = query_text(q)
assert 'analysis_expr_' not in canonical
assert 'as "best_time(5 km)"' in canonical
assert parsed(canonical) == q
with app.app_context():
row = db.session.get(Activity, 1)
row.best_efforts = [{'distance': 5000, 'seconds': 1800}]
db.session.commit()
result = explore(1, 'UTC', q)
assert result['count'] == 1
assert [c['value'] for c in result['cards']] == ['30:00', '60:00']
def test_multiple_since_conditions_use_individual_runs_and_prior_history(app):
with app.app_context():
db.session.query(Activity).delete()
db.session.add_all([
Activity(owner_id=owner, source_key=f'{owner}-{index}', title='Race ( A)',
sport='running', started_at=datetime(2026, 9, day, tzinfo=UTC),
distance_m=km * 1000, duration_s=1800, avg_hr=140)
for index, (owner, day, km) in enumerate([(1, 1, 20), (1, 3, 10), (1, 4, 6), (1, 4, 6), (2, 5, 30)])
])
db.session.commit()
q = parsed('days\nduring 2026-09-04 to 2026-09-05\nwhere distance >= 0 km\n'
'summarize latest(days_since_run(distance >= 10 km)), '
'latest(days_since_run(distance >= 20 km)), latest(days_since_run())\n'
'by day\ncompare none')
result = explore(1, 'UTC', q)
assert [g['raw_values'] for g in result['groups']] == [[1, 3, 0], [2, 4, 1]]
q = parsed('days\nduring 2026-09-05 to 2026-09-05\n'
'summarize latest(days_since_run(title contains "Race ( A)"))\n'
'by day\ncompare none')
assert parsed(query_text(q)) == q
assert explore(1, 'UTC', q)['groups'][0]['raw_values'] == [1]
@pytest.mark.parametrize('expression', ['best_time(3 km)', 'rolling_sum(distance, 0 days)',
'rolling_mean(unknown, 14 days)', 'days_since_run(distance >= 10 bpm)'])
def test_invalid_expression_reports_original_line(expression):
with pytest.raises(QueryError) as error:
parsed(f'days\nduring last 16 weeks\nsummarize latest({expression})')
assert error.value.line == 3
def test_migration_preserves_labels_settings_other_owners_and_is_idempotent(app):
old = ('days\nduring last 16 weeks\nlong run distance >= 20 km\n'
'summarize latest(days_since_long_run) as "days_since_long_run", '
'latest(rolling_7d_count)\nby day\ncompare none')
with app.app_context():
own = SavedAnalysis(owner_id=1, name='Continuity', query=old, pinned=True,
position=7, width='full', notes='Keep these notes')
other = SavedAnalysis(owner_id=2, name='Other owner', query=old)
db.session.add_all([own, other])
db.session.commit()
assert migrate(db.session.get(User, 1)) == 1
assert 'days_since_run(distance >= 20 km)' in own.query
assert 'rolling_sum(sessions, 7 days)' in own.query
assert 'as "days_since_long_run"' in own.query
assert own.pinned and own.position == 7 and own.width == 'full'
assert own.notes == 'Keep these notes' and other.query == old
assert migrate(db.session.get(User, 1)) == 0
assert rewrite(own.query) == own.query
def test_daily_rolling_session_count_includes_multiple_runs_and_rest_days(app):
with app.app_context():
db.session.query(Activity).delete()
db.session.add_all([
Activity(owner_id=1, source_key=str(index), title='Run', sport='running',
started_at=datetime(2026, 9, 1, index, tzinfo=UTC),
distance_m=5000, duration_s=1800)
for index in range(2)
])
db.session.commit()
q = parsed('days\nduring 2026-09-02 to 2026-09-02\n'
'summarize latest(rolling_sum(sessions, 28 days))\nby day\ncompare none')
result = explore(1, 'UTC', q)
assert result['groups'][0]['raw_values'] == [2]
assert result['cards'][0]['unit'] == 'runs'
+3 -3
View File
@@ -128,7 +128,7 @@ def test_daily_windows_include_rest_days_and_prior_history(app):
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"
"summarize latest(rolling_sum(distance, 7 days)), latest(run_day_streak), latest(days_since_run(distance >= 10 km))\n"
"by day\ncompare none")
result = explore(1, "UTC", q)
assert len(result["groups"]) == 8
@@ -137,7 +137,7 @@ def test_daily_windows_include_rest_days_and_prior_history(app):
assert result["cards"][0]["value"] == "5.0"
assert result["rows"][0]["id"] is None
assert result["coverage"]["sessions"] == 2
q["long_run"] = 20
q = parsed(query_text(q).replace("distance >= 10 km", "distance >= 20 km"))
assert explore(1, "UTC", q)["cards"][-1]["value"] == "—"
@@ -183,7 +183,7 @@ def test_efforts_and_performance_metrics_preserve_missing_distances(app):
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")
q = parsed("runs\nduring all time\nsummarize min(best_time(5 km)), min(best_time(10 km))\nby month\ncompare none")
assert [c["value"] for c in explore(1, "UTC", q)["cards"]] == ["30:00", "—"]