226 lines
7.7 KiB
Python
226 lines
7.7 KiB
Python
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
|