Make stats DSL primary with highlighted editor, completion and live diagnostics
This commit is contained in:
+122
-1
@@ -6,7 +6,8 @@ from test_app import add_history, login
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Activity
|
||||
from app.stats import explore, parse_query, query_text
|
||||
from app.stats import explore, parse_query, period_bounds, query_text
|
||||
from app.stats_dsl import QueryError
|
||||
|
||||
pytest_plugins = ("test_app",)
|
||||
|
||||
@@ -164,3 +165,123 @@ def test_evidence_pagination(app):
|
||||
assert len(second.json["rows"]) == 6
|
||||
assert not {r["id"] for r in response.json["rows"]} & {r["id"] for r in second.json["rows"]}
|
||||
assert client.get("/stats/runs", query_string={**args, "page": 3}).status_code == 400
|
||||
|
||||
|
||||
def test_expanded_dsl_roundtrip_units_comments_and_dates():
|
||||
source = """# My long runs
|
||||
runs
|
||||
during 2026-07-01 to 2026-09-30
|
||||
where distance between 8000 and 20000 m
|
||||
and pace <= 5:30 /km
|
||||
and duration >= 45 min
|
||||
and has cadence
|
||||
and missing power
|
||||
and title contains "River"
|
||||
summarize pace, heart_rate, cadence, duration
|
||||
by day
|
||||
sort by pace asc
|
||||
compare none"""
|
||||
query = parse_query({"query": source})
|
||||
assert query["extra_metrics"] == ["cadence", "duration"]
|
||||
assert query["group"] == "day"
|
||||
assert query["minimum"] == 8
|
||||
assert query["maximum"] == 20
|
||||
assert query["filters"][0]["value"] == 330
|
||||
assert query["filters"][1]["value"] == 0.75
|
||||
assert parse_query({"query": query_text(query)}) == query
|
||||
months = parse_query({"query": "runs\nduring last 1 month\nending 2026-03-30\nsummarize pace"})
|
||||
assert period_bounds(months, "Australia/Sydney")[0].isoformat() == "2026-02-28"
|
||||
days = parse_query({"query": "runs\nduring last 30 days\nsummarize distance"})
|
||||
assert parse_query({"query": query_text(days)}) == days
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("clause", "message"),
|
||||
[
|
||||
("and pace <= 5:99 /km", "seconds"),
|
||||
("and pace <= 5:30 bpm", "Use /km"),
|
||||
("and heart_rate between 150 and 140 bpm", "lower bound"),
|
||||
("and has heart_rae", "heart_rate"),
|
||||
("during last 25 months", "1–24"),
|
||||
("summarize pace, pace", "different metrics"),
|
||||
("sort by owner_id asc", "Unknown metric"),
|
||||
],
|
||||
)
|
||||
def test_source_located_diagnostics(clause, message):
|
||||
source = "runs\n" + clause
|
||||
if not clause.startswith("summarize"):
|
||||
source += "\nsummarize pace"
|
||||
with pytest.raises(QueryError, match=message) as error:
|
||||
parse_query({"query": source})
|
||||
assert error.value.line == 2
|
||||
|
||||
|
||||
def test_live_validation_and_extended_execution(app):
|
||||
client = app.test_client()
|
||||
assert client.post("/stats/validate", json={"query": "runs"}).status_code == 302
|
||||
login(client)
|
||||
assert client.post("/stats/validate", json={"query": "runs"}).json["valid"] is False
|
||||
bad = client.post(
|
||||
"/stats/validate", json={"query": "runs\nand pace <= 5:99 /km\nsummarize pace"}
|
||||
)
|
||||
assert bad.json["diagnostics"][0]["line"] == 2
|
||||
source = """runs
|
||||
during 2026-09-01 to 2026-10-02
|
||||
where distance between 4 and 6 km
|
||||
and pace >= 5:30 /km
|
||||
and duration >= 25 min
|
||||
and title contains "QUIET"
|
||||
and missing cadence
|
||||
summarize pace, heart_rate, distance, duration
|
||||
by day
|
||||
compare none
|
||||
sort by distance desc"""
|
||||
validation = client.post("/stats/validate", json={"query": source})
|
||||
assert validation.json["valid"] is True
|
||||
response = client.get("/stats", query_string={"query": source})
|
||||
assert response.status_code == 200
|
||||
assert b"A quiet morning run" in response.data
|
||||
with app.app_context():
|
||||
result = explore(1, "Australia/Sydney", parse_query({"query": source}))
|
||||
assert result["count"] == 1
|
||||
assert len(result["cards"]) == 4
|
||||
assert client.post("/stats/validate", json={"query": 123}).status_code == 400
|
||||
assert client.post("/stats/validate", json={"query": "a" * 4001}).json["valid"] is False
|
||||
|
||||
|
||||
def test_validation_requires_csrf_and_does_not_read_activities(app):
|
||||
client = app.test_client()
|
||||
login(client)
|
||||
with app.app_context():
|
||||
statements = []
|
||||
|
||||
def record(_conn, _cursor, statement, _parameters, _context, _many):
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(db.engine, "before_cursor_execute", record)
|
||||
engine = db.engine
|
||||
try:
|
||||
response = client.post("/stats/validate", json={"query": "runs\nsummarize pace"})
|
||||
assert response.json["valid"] is True
|
||||
assert not any("FROM activity" in statement for statement in statements)
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", record)
|
||||
app.config["WTF_CSRF_ENABLED"] = True
|
||||
assert client.post("/stats/validate", json={"query": "runs\nsummarize pace"}).status_code == 400
|
||||
|
||||
|
||||
def test_fractional_units_roundtrip_and_strict_sorting(app):
|
||||
query = parse_query(
|
||||
{"query": "runs\nwhere duration >= 1 min\nand distance > 0.123456789 km\nsummarize pace"}
|
||||
)
|
||||
assert parse_query({"query": query_text(query)}) == query
|
||||
with app.app_context():
|
||||
add_history(3)
|
||||
db.session.commit()
|
||||
query = parse_query(
|
||||
{"query": "runs\nduring 2026-09-01 to 2026-10-02\nsummarize pace\nsort by pace asc"}
|
||||
)
|
||||
rows = explore(1, "Australia/Sydney", query)["rows"]
|
||||
assert rows[-1]["id"] == 1
|
||||
query["filters"] = [{"field": "pace", "op": "<", "value": 300}]
|
||||
assert explore(1, "Australia/Sydney", query)["count"] == 0
|
||||
|
||||
Reference in New Issue
Block a user