Add running route planner with quality comparison and GPX export

This commit is contained in:
Peter Stockings
2026-10-03 00:33:41 +10:00
parent a06189849e
commit 52943cd05b
12 changed files with 899 additions and 0 deletions
+225
View File
@@ -0,0 +1,225 @@
from copy import deepcopy
from datetime import UTC, datetime, timedelta
from xml.etree import ElementTree
import pytest
from app import create_app
from app.extensions import db
from app.models import PlannedRoute, User
from app.planner_engine import PlannerError, analyse, generate, validate
@pytest.fixture
def app(tmp_path):
app = create_app(
{
"TESTING": True,
"SQLALCHEMY_DATABASE_URI": "sqlite://",
"SECRET_KEY": "planner-test",
"WTF_CSRF_ENABLED": False,
"RATELIMIT_ENABLED": False,
"LOCAL_SETUP": False,
"ORS_API_KEY": "test-key",
"DATA_ROOT": str(tmp_path),
}
)
with app.app_context():
db.create_all()
for name in ("runner", "other"):
user = User(username=name, email=f"{name}@example.test", display_name=name)
user.set_password("a-long-test-password")
db.session.add(user)
db.session.commit()
yield app
def login(client, name="runner"):
client.post(
"/login", data={"email": f"{name}@example.test", "password": "a-long-test-password"}
)
def preferences():
return {"start": [151, -33], "mode": "loop", "distance": 5, "hill": "rolling"}
def feature():
return {
"geometry": {
"type": "LineString",
"coordinates": [[151, -33, 10], [151.001, -33, 30], [151, -33, 10]],
},
"properties": {
"summary": {"distance": 5100},
"ascent": 20,
"extras": {
"waytype": {
"summary": [{"value": 7, "distance": 4080}, {"value": 3, "distance": 1020}]
},
"surface": {
"summary": [{"value": 3, "distance": 4590}, {"value": 0, "distance": 510}]
},
},
},
}
@pytest.mark.parametrize(
"change",
[
{"distance": float("nan")},
{"distance": 51},
{"start": [151, 91]},
{"start": [True, -33]},
{"hill": "invalid"},
{"mode": "point"},
{"surface": "invalid"},
{"distance": False},
{"mode": []},
{"avoid_repeats": "false"},
],
)
def test_validation(change):
with pytest.raises(PlannerError):
validate(preferences() | change)
def test_point_distance_rejected():
with pytest.raises(PlannerError, match="straight-line"):
validate(preferences() | {"mode": "point", "end": [152, -33]})
def test_quality_and_missing_data():
plan = analyse(feature(), validate(preferences()))
assert plan["quality"] == {
"paths_pct": 80,
"roads_pct": 20,
"paved_pct": 90,
"unknown_surface_pct": 10,
"repeats_pct": 50,
}
assert plan["within_target"]
assert len(plan["profile"]) == 3
assert plan["profile"][-1][0] == 5100
missing = feature()
missing["properties"].pop("extras")
missing["properties"].pop("ascent")
for coordinate in missing["geometry"]["coordinates"]:
coordinate.pop()
plan = analyse(missing, validate(preferences()))
assert plan["quality"]["paths_pct"] is None
assert plan["quality"]["unknown_surface_pct"] is None
assert plan["ascent_m"] is None
assert plan["profile"] == []
def test_provider_float_category_ids():
item = feature()
for extra in item["properties"]["extras"].values():
for row in extra["summary"]:
row["value"] = float(row["value"])
plan = analyse(item, validate(preferences()))
assert plan["quality"]["paths_pct"] == 80
assert plan["quality"]["roads_pct"] == 20
assert plan["quality"]["paved_pct"] == 90
assert plan["quality"]["unknown_surface_pct"] == 10
def test_ranking_and_provider_contract(monkeypatch):
calls = []
def fake(body, key):
calls.append(deepcopy(body))
item = feature()
item["properties"]["summary"]["distance"] = [7000, 5050, 5300][len(calls) - 1]
item["geometry"]["coordinates"][1][0] += len(calls) / 100
return {"features": [item]}
monkeypatch.setattr("app.planner_engine.provider_request", fake)
routes, errors = generate(validate(preferences() | {"priority": "distance"}), "test")
assert len(routes) == 3 and not errors
assert routes[0]["distance_m"] == 5050
assert [x["options"]["round_trip"]["seed"] for x in calls] == [1, 2, 3]
assert all(x["elevation"] and x["extra_info"] == ["surface", "waytype"] for x in calls)
def test_point_requests_and_duplicates(monkeypatch):
calls = []
def fake(body, key):
calls.append(body)
return {"features": [feature()]}
monkeypatch.setattr("app.planner_engine.provider_request", fake)
routes, _ = generate(validate(preferences() | {"mode": "point", "end": [151.01, -33]}), "test")
assert len(routes) == 1
assert [len(body["coordinates"]) for body in calls] == [2, 3, 3]
assert all("options" not in body for body in calls)
def test_generate_save_export_delete_and_ownership(app, monkeypatch):
monkeypatch.setattr("app.planner_engine.provider_request", lambda *_: {"features": [feature()]})
client = app.test_client()
assert client.get("/routes").status_code == 302
login(client)
assert client.get("/routes").status_code == 200
response = client.post("/routes/generate", json=preferences())
assert response.status_code == 200
route_id = response.json["routes"][0]["id"]
assert client.get(f"/routes/{route_id}/gpx").status_code == 404
assert (
client.post(f"/routes/{route_id}/save", json={"title": "Morning & hills"}).status_code
== 200
)
exported = client.get(f"/routes/{route_id}/gpx")
assert exported.status_code == 200
document = ElementTree.fromstring(exported.data)
ns = {"g": "http://www.topografix.com/GPX/1/1"}
assert document.find("g:trk/g:name", ns).text == "Morning & hills"
assert len(document.findall(".//g:trkpt", ns)) == 3
assert document.find(".//g:trkpt", ns).attrib == {"lat": "-33.0", "lon": "151.0"}
other = app.test_client()
login(other, "other")
for suffix in ("", "/gpx"):
assert other.get(f"/routes/{route_id}{suffix}").status_code == 404
for suffix in ("save", "delete"):
assert (
other.post(f"/routes/{route_id}/{suffix}", json={"title": "stolen"}).status_code == 404
)
assert b"Morning & hills" not in other.get("/routes").data
assert client.post(f"/routes/{route_id}/delete", json={}).status_code == 200
assert client.get(f"/routes/{route_id}").status_code == 404
def test_configuration_failure_and_csrf(app):
app.config["ORS_API_KEY"] = None
client = app.test_client()
login(client)
assert b"not connected yet" in client.get("/routes").data
response = client.post("/routes/generate", json=preferences())
assert response.status_code == 400 and "ORS_API_KEY" in response.json["error"]
app.config["WTF_CSRF_ENABLED"] = True
assert client.post("/routes/generate", json=preferences()).status_code == 400
def test_old_candidates_cleanup_and_saved_preservation(app, monkeypatch):
monkeypatch.setattr("app.planner_engine.provider_request", lambda *_: {"features": [feature()]})
with app.app_context():
for saved in (False, True):
db.session.add(
PlannedRoute(
owner_id=1,
title="old",
saved=saved,
created_at=datetime.now(UTC) - timedelta(days=2),
payload={},
)
)
db.session.commit()
client = app.test_client()
login(client)
assert client.post("/routes/generate", json=preferences()).status_code == 200
with app.app_context():
assert db.session.get(PlannedRoute, 1) is None
assert db.session.get(PlannedRoute, 2).saved