Make public transport routes discoverable with shareable search
Add one-click examples and route search by number or metadata, with transport type, location and map-area filters. Preserve search filters and bounds in shareable URLs and restore searches on reload and browser navigation. Validate with 91 passing tests, including desktop and mobile browser coverage, and strict mypy checks. Closes #2
This commit is contained in:
parent
29afb4f115
commit
a79b0a7098
7 changed files with 919 additions and 12 deletions
388
tests/test_pt_search.py
Normal file
388
tests/test_pt_search.py
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
"""Offline tests for public transport relation search and its JSON endpoint."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import responses
|
||||
|
||||
from osm_geojson.pt import core
|
||||
|
||||
|
||||
def relation(relation_id: int = 1, **tags: str) -> dict[str, Any]:
|
||||
"""Build a minimal tagged Overpass relation."""
|
||||
return {"type": "relation", "id": relation_id, "tags": {"type": "route", "route": "bus", **tags}}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def app(monkeypatch: Any) -> Any:
|
||||
"""Load the web application independently of Python's import path."""
|
||||
spec = importlib.util.spec_from_file_location("pt_web_app", Path(__file__).parents[1] / "web/app.py")
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
monkeypatch.setitem(sys.modules, spec.name, module)
|
||||
spec.loader.exec_module(module)
|
||||
module.app.config["TESTING"] = True
|
||||
return module.app
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_search_metadata_and_query() -> None:
|
||||
"""Return routes and masters, exclude other relations, and prefer exact refs."""
|
||||
responses.post(core.OVERPASS_URL, json={"elements": [
|
||||
relation(2, name="Airport shuttle", ref="X1", operator="City Bus", **{"from": "Airport", "to": "Centre"}),
|
||||
relation(3, name="M11", type="route_master", route_master="subway", ref="M11"),
|
||||
relation(1, name="Metro M11", route="subway", ref="M11"),
|
||||
relation(4, route="hiking"),
|
||||
{"type": "node", "id": 5},
|
||||
]})
|
||||
result = core.search_routes(" M11 ")
|
||||
assert [item["id"] for item in result["routes"]] == [3, 1, 2]
|
||||
assert result["routes"][0]["type"] == "route_master"
|
||||
assert result["routes"][2]["operator"] == "City Bus"
|
||||
assert result["truncated"] is False
|
||||
request = responses.calls[0].request
|
||||
query = parse_qs(request.body)["data"][0]
|
||||
assert '["ref"~"^M11$",i]' in query
|
||||
assert '["route_master"~' in query
|
||||
assert 'out tags 51;' in query
|
||||
assert request.headers["User-Agent"] == "osm-pt-geojson/1.0"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_literal_search_and_mode_filter() -> None:
|
||||
"""User text stays a literal regex and cannot inject Overpass selectors."""
|
||||
responses.post(core.OVERPASS_URL, json={"elements": []})
|
||||
text = 'İstanbul [A.*] "\\);out;'
|
||||
assert core.search_routes(text, "subway")["routes"] == []
|
||||
query = parse_qs(responses.calls[0].request.body)["data"][0]
|
||||
escaped = 'İstanbul \\[A\\.\\*\\] "\\\\\\);out;'
|
||||
assert f'["name"~{json.dumps(escaped, ensure_ascii=False)},i]' in query
|
||||
assert '["route"~"^(subway)$"]' in query
|
||||
assert '["route_master"~"^(subway)$"]' in query
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query,mode", [("", ""), ("x", ""), ("x" * 101, ""), ("a\nb", ""), ("M11", "hiking")])
|
||||
@responses.activate
|
||||
def test_invalid_search(query: str, mode: str) -> None:
|
||||
"""Reject invalid input without contacting Overpass."""
|
||||
with pytest.raises(core.OsmError) as error:
|
||||
core.search_routes(query, mode)
|
||||
assert error.value.status_code == 400
|
||||
assert len(responses.calls) == 0
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_search_limit() -> None:
|
||||
"""Tell the UI when more than fifty relations matched."""
|
||||
responses.post(core.OVERPASS_URL, json={"elements": [relation(i) for i in range(51)]})
|
||||
result = core.search_routes("Airport")
|
||||
assert len(result["routes"]) == 50
|
||||
assert result["truncated"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [{"remark": "runtime error: Query timed out", "elements": []}, {}, []])
|
||||
@responses.activate
|
||||
def test_incomplete_search(payload: Any) -> None:
|
||||
"""A timed-out or malformed response must not look like an empty result."""
|
||||
responses.post(core.OVERPASS_URL, json=payload)
|
||||
with pytest.raises(core.OsmError) as error:
|
||||
core.search_routes("M11")
|
||||
assert error.value.status_code == 502
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [requests.Timeout("timeout"), "invalid JSON", 429, 504])
|
||||
@responses.activate
|
||||
def test_upstream_search_failure(failure: Any) -> None:
|
||||
"""Network, rate-limit and decoding failures become library errors."""
|
||||
if isinstance(failure, int):
|
||||
responses.post(core.OVERPASS_URL, status=failure)
|
||||
else:
|
||||
responses.post(core.OVERPASS_URL, body=failure)
|
||||
with pytest.raises(core.OsmError) as error:
|
||||
core.search_routes("M11")
|
||||
assert error.value.status_code == 502
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_search_api(app: Any) -> None:
|
||||
"""The endpoint handles results, empty matches, invalid inputs and upstream errors."""
|
||||
responses.post(core.OVERPASS_URL, json={"elements": [relation(15083963, name="M11", ref="M11")]})
|
||||
client = app.test_client()
|
||||
result = client.get("/api/search?q=M11&mode=subway")
|
||||
assert result.status_code == 200
|
||||
assert result.json["routes"][0]["id"] == 15083963
|
||||
responses.post(core.OVERPASS_URL, json={"elements": []})
|
||||
assert client.get("/api/search?q=Unknown").json == {"routes": [], "truncated": False}
|
||||
for path in ("/api/search", "/api/search?q=M11&mode=hiking"):
|
||||
result = client.get(path)
|
||||
assert result.status_code == 400
|
||||
assert result.json["error"] == "invalid_query"
|
||||
responses.post(core.OVERPASS_URL, status=503)
|
||||
result = client.get("/api/search?q=M11")
|
||||
assert result.status_code == 502
|
||||
assert result.json["error"] == "osm_error"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [1280, 390])
|
||||
def test_search_browser(app: Any, width: int, monkeypatch: Any) -> None:
|
||||
"""Search results load routes on desktop and mobile, with safe text and errors."""
|
||||
import threading
|
||||
from werkzeug.serving import make_server
|
||||
|
||||
browser_api = pytest.importorskip("playwright.sync_api")
|
||||
monkeypatch.setitem(app.view_functions["route_page"].__globals__, "fetch_relation_name", lambda _: "Metro M11")
|
||||
server = make_server("127.0.0.1", 0, app)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
with browser_api.sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch()
|
||||
page = browser.new_page(viewport={"width": width, "height": 844})
|
||||
errors: list[str] = []
|
||||
location_requests: list[str] = []
|
||||
page.on("pageerror", lambda error: errors.append(str(error)))
|
||||
page.on('request', lambda request: location_requests.append(request.url) if '/api/locations?' in request.url else None)
|
||||
page.route("**/tile.openstreetmap.org/**", lambda route: route.abort())
|
||||
page.route("**/api/locations?*", lambda route: route.fulfill(json={"locations": [
|
||||
{"label": "Bristol, UK", "bounds": [51.39, -2.73, 51.55, -2.51]},
|
||||
{"label": "Bristol, USA", "bounds": [41.6, -72.9, 41.8, -72.8]},
|
||||
]}))
|
||||
page.route("**/api/search?*", lambda route: route.fulfill(json={
|
||||
"routes": [{"id": 15083963, "name": "Metro M11 <script>", "ref": "M11",
|
||||
"route": "subway", "type": "route", "from": "Airport", "to": "Centre",
|
||||
"operator": "Metro"}], "truncated": True,
|
||||
}))
|
||||
page.route("**/api/route/15083963", lambda route: route.fulfill(json={
|
||||
"name": "Metro M11", "ref": "M11", "other_directions": [],
|
||||
"stops": [{"name": "Airport", "lat": 41.2, "lon": 28.8}],
|
||||
"geojson": {"type": "FeatureCollection", "features": [{
|
||||
"type": "Feature", "properties": {},
|
||||
"geometry": {"type": "LineString", "coordinates": [[28.8, 41.2], [28.9, 41.3]]},
|
||||
}]},
|
||||
}))
|
||||
page.goto(f"http://127.0.0.1:{server.server_port}/")
|
||||
page.get_by_label("Find a public transport route").fill("24")
|
||||
page.get_by_label("Transport type").select_option("bus")
|
||||
page.get_by_label("Location (optional)").fill("Bristol")
|
||||
page.get_by_role("button", name="Search", exact=True).click()
|
||||
browser_api.expect(page.locator("#search-status")).to_contain_text("Choose a location")
|
||||
with page.expect_request("**/api/search?*") as request:
|
||||
page.get_by_role("button", name="Bristol, UK", exact=True).click()
|
||||
params = parse_qs(request.value.url.split('?')[1])
|
||||
assert params['q'] == ['24'] and params['mode'] == ['bus']
|
||||
assert params['south'] == ['51.39'] and params['east'] == ['-2.51']
|
||||
browser_api.expect(page.locator('#search-area-label')).to_contain_text('Bristol, UK')
|
||||
assert page.evaluate('map.getBounds().contains(L.latLngBounds([51.39, -2.73], [51.55, -2.51]))')
|
||||
assert page.evaluate('map.getZoom()') > 2
|
||||
assert page.evaluate('searchAreaLayer !== null')
|
||||
browser_api.expect(page.locator("#search-status")).to_contain_text("More matches exist")
|
||||
shared_url = page.url
|
||||
shared_params = parse_qs(shared_url.split('?')[1])
|
||||
assert shared_params['location'] == ['Bristol']
|
||||
assert shared_params['place'] == ['Bristol, UK']
|
||||
assert shared_params['q'] == ['24'] and shared_params['mode'] == ['bus']
|
||||
assert len(location_requests) == 1
|
||||
page.set_viewport_size({'width': 390 if width == 1280 else 1280, 'height': 844})
|
||||
with page.expect_request('**/api/search?*') as request:
|
||||
page.reload()
|
||||
assert parse_qs(request.value.url.split('?')[1]) == params
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('More matches exist')
|
||||
browser_api.expect(page.get_by_label('Find a public transport route')).to_have_value('24')
|
||||
browser_api.expect(page.get_by_label('Transport type')).to_have_value('bus')
|
||||
browser_api.expect(page.get_by_label('Location (optional)')).to_have_value('Bristol')
|
||||
browser_api.expect(page.locator('#search-area-label')).to_contain_text('Bristol, UK')
|
||||
assert page.url == shared_url
|
||||
assert len(location_requests) == 1
|
||||
page.set_viewport_size({'width': width, 'height': 844})
|
||||
if width == 390:
|
||||
page.evaluate('openPanel()')
|
||||
link = page.locator("#search-results a")
|
||||
browser_api.expect(link).to_contain_text("Metro M11 <script>")
|
||||
assert page.locator("#search-results script").count() == 0
|
||||
page.get_by_label('Search this map area').check()
|
||||
browser_api.expect(page.get_by_label('Location (optional)')).to_be_disabled()
|
||||
page.evaluate('map.setView([51.45, -2.59], 13, {animate: false})')
|
||||
with page.expect_request('**/api/search?*') as request:
|
||||
page.get_by_role('button', name='Search', exact=True).click()
|
||||
map_params = parse_qs(request.value.url.split('?')[1])
|
||||
assert float(map_params['south'][0]) < 51.45 < float(map_params['north'][0])
|
||||
assert float(map_params['west'][0]) < -2.59 < float(map_params['east'][0])
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('More matches exist')
|
||||
shared_map_url = page.url
|
||||
assert parse_qs(shared_map_url.split('?')[1])['area'] == ['map']
|
||||
page.set_viewport_size({'width': 390 if width == 1280 else 1280, 'height': 844})
|
||||
with page.expect_request('**/api/search?*') as request:
|
||||
page.reload()
|
||||
assert parse_qs(request.value.url.split('?')[1]) == map_params
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('More matches exist')
|
||||
browser_api.expect(page.get_by_label('Search this map area')).to_be_checked()
|
||||
browser_api.expect(page.get_by_label('Location (optional)')).to_be_disabled()
|
||||
assert page.url == shared_map_url
|
||||
page.set_viewport_size({'width': width, 'height': 844})
|
||||
if width == 390:
|
||||
page.evaluate('openPanel()')
|
||||
page.evaluate('map.setView([51.5, -2.7], 13, {animate: false})')
|
||||
with page.expect_request('**/api/search?*') as request:
|
||||
page.get_by_role('button', name='Search', exact=True).click()
|
||||
moved_params = parse_qs(request.value.url.split('?')[1])
|
||||
assert moved_params['west'] != map_params['west']
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('More matches exist')
|
||||
with page.expect_request('**/api/search?*') as request:
|
||||
page.go_back()
|
||||
assert parse_qs(request.value.url.split('?')[1]) == map_params
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('More matches exist')
|
||||
with page.expect_request('**/api/search?*') as request:
|
||||
page.go_forward()
|
||||
assert parse_qs(request.value.url.split('?')[1]) == moved_params
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('More matches exist')
|
||||
page.get_by_label('Search this map area').uncheck()
|
||||
browser_api.expect(page.get_by_label('Location (optional)')).to_be_enabled()
|
||||
page.get_by_label('Location (optional)').fill('Bristol')
|
||||
page.get_by_role('button', name='Search', exact=True).click()
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('Choose a location')
|
||||
with page.expect_request('**/api/search?*') as request:
|
||||
page.get_by_role('button', name='Bristol, UK', exact=True).click()
|
||||
assert parse_qs(request.value.url.split('?')[1])['south'] == ['51.39']
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('More matches exist')
|
||||
link.click()
|
||||
browser_api.expect(page.locator("#route-name")).to_have_text("Metro M11")
|
||||
browser_api.expect(page).to_have_url(f"http://127.0.0.1:{server.server_port}/15083963")
|
||||
page.route("**/api/search?*", lambda route: route.fulfill(json={"routes": [], "truncated": False}))
|
||||
page.get_by_label("Find a public transport route").fill("Unknown")
|
||||
page.get_by_role("button", name="Search", exact=True).click()
|
||||
browser_api.expect(page.locator("#search-status")).to_contain_text("No routes found")
|
||||
assert page.locator("#search-results a").count() == 0
|
||||
world_url = page.url
|
||||
assert parse_qs(world_url.split('?')[1]) == {'q': ['Unknown']}
|
||||
with page.expect_request('**/api/search?*') as request:
|
||||
page.reload()
|
||||
assert parse_qs(request.value.url.split('?')[1]) == {'q': ['Unknown']}
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('No routes found')
|
||||
page.route("**/api/search?*", lambda route: route.fulfill(status=502, json={"message": "Search unavailable"}))
|
||||
page.get_by_role("button", name="Search", exact=True).click()
|
||||
browser_api.expect(page.locator("#search-status")).to_have_text("Search unavailable")
|
||||
browser_api.expect(page.get_by_role("button", name="Search", exact=True)).to_be_enabled()
|
||||
page.route('**/api/locations?*', lambda route: route.fulfill(json={'locations': []}))
|
||||
page.get_by_label('Location (optional)').fill('Unknown place')
|
||||
page.get_by_role('button', name='Search', exact=True).click()
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('No location found')
|
||||
page.route('**/api/locations?*', lambda route: route.fulfill(status=502, json={'message': 'Location unavailable'}))
|
||||
page.get_by_role('button', name='Search', exact=True).click()
|
||||
browser_api.expect(page.locator('#search-status')).to_have_text('Location unavailable')
|
||||
page.goto(f'http://127.0.0.1:{server.server_port}/?q=24&mode=bus&south=nan&west=-3&north=52&east=-2')
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('Invalid search area')
|
||||
assert errors == []
|
||||
browser.close()
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def location_cache(monkeypatch: Any) -> None:
|
||||
"""Give each location test an empty cache and no pending rate-limit delay."""
|
||||
core._find_locations_cached.cache_clear()
|
||||
monkeypatch.setattr(core, '_last_location_request', 0.0)
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_locations_cache_and_bounds(location_cache: None) -> None:
|
||||
"""Geocode worldwide, preserve candidate areas, and reuse successful lookups."""
|
||||
responses.get(core.NOMINATIM_URL, json=[
|
||||
{"display_name": "Bristol, UK", "boundingbox": ["51.39", "51.55", "-2.73", "-2.51"]},
|
||||
{"display_name": "Bristol, USA", "boundingbox": ["41.6", "41.8", "-72.9", "-72.8"]},
|
||||
{"display_name": "Invalid", "boundingbox": ["nan", "52", "-3", "-2"]},
|
||||
])
|
||||
locations = core.find_locations(' Bristol ')
|
||||
assert locations[0] == {"label": "Bristol, UK", "bounds": (51.39, -2.73, 51.55, -2.51)}
|
||||
assert len(locations) == 2
|
||||
assert core.find_locations('Bristol') == locations
|
||||
assert len(responses.calls) == 1
|
||||
assert 'countrycodes' not in responses.calls[0].request.url
|
||||
assert 'openstreetmap.tools' in responses.calls[0].request.headers['User-Agent']
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_location_api(app: Any, location_cache: None) -> None:
|
||||
"""Location resolution returns candidates or explicit errors, never global routes."""
|
||||
client = app.test_client()
|
||||
responses.get(core.NOMINATIM_URL, json=[])
|
||||
result = client.get('/api/locations?q=Nowhere')
|
||||
assert result.status_code == 200
|
||||
assert result.json == {"locations": []}
|
||||
assert client.get('/api/locations').status_code == 400
|
||||
assert client.get('/api/locations?q=x').status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize('payload', [{}, 'broken JSON', requests.Timeout('timeout'), 429])
|
||||
@responses.activate
|
||||
def test_location_failure(payload: Any, location_cache: None) -> None:
|
||||
"""Upstream errors and invalid location payloads become OsmError."""
|
||||
if isinstance(payload, int):
|
||||
responses.get(core.NOMINATIM_URL, status=payload)
|
||||
elif isinstance(payload, dict):
|
||||
responses.get(core.NOMINATIM_URL, json=payload)
|
||||
else:
|
||||
responses.get(core.NOMINATIM_URL, body=payload)
|
||||
with pytest.raises(core.OsmError) as error:
|
||||
core.find_locations('Bristol')
|
||||
assert error.value.status_code == 502
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_area_query_and_parent_masters() -> None:
|
||||
"""Scope routes before text filtering and include masters of local routes."""
|
||||
responses.post(core.OVERPASS_URL, json={"elements": [relation(24, ref='24')]})
|
||||
result = core.search_routes('24', 'bus', (51.39, -2.73, 51.55, -2.51))
|
||||
assert result['routes'][0]['ref'] == '24'
|
||||
query = parse_qs(responses.calls[0].request.body)['data'][0]
|
||||
assert 'rel(51.3900000,-2.7300000,51.5500000,-2.5100000)' in query
|
||||
assert 'rel(br.areaRoutes)["type"="route_master"]' in query
|
||||
assert 'rel.inArea["type"="route"]["route"~"^(bus)$"]["ref"~"^24$",i]' in query
|
||||
assert 'rel.inArea["type"="route_master"]' in query
|
||||
|
||||
|
||||
@pytest.mark.parametrize('suffix', [
|
||||
'&south=51', '&south=x&west=-3&north=52&east=-2',
|
||||
'&south=nan&west=-3&north=52&east=-2',
|
||||
'&south=51&west=-3&north=inf&east=-2',
|
||||
'&south=52&west=-3&north=51&east=-2',
|
||||
'&south=51&west=179&north=52&east=-179',
|
||||
'&south=-90&west=-180&north=90&east=180',
|
||||
])
|
||||
@responses.activate
|
||||
def test_invalid_area_api(app: Any, suffix: str) -> None:
|
||||
"""Reject partial, invalid, broad and date-line-crossing bounds without HTTP."""
|
||||
result = app.test_client().get('/api/search?q=24' + suffix)
|
||||
assert result.status_code == 400
|
||||
assert result.json['error'] == 'invalid_query'
|
||||
assert len(responses.calls) == 0
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_area_api(app: Any) -> None:
|
||||
"""Parse and pass all four map coordinates through to Overpass."""
|
||||
responses.post(core.OVERPASS_URL, json={"elements": []})
|
||||
result = app.test_client().get('/api/search?q=24&mode=bus&south=51.39&west=-2.73&north=51.55&east=-2.51')
|
||||
assert result.status_code == 200
|
||||
assert 'rel(51.3900000,-2.7300000,51.5500000,-2.5100000)' in parse_qs(responses.calls[0].request.body)['data'][0]
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_location_rate_limit(location_cache: None, monkeypatch: Any) -> None:
|
||||
"""Distinct lookups are spaced one second apart; cached ones need no delay."""
|
||||
delays: list[float] = []
|
||||
monkeypatch.setattr(core.time, 'monotonic', lambda: 10.0)
|
||||
monkeypatch.setattr(core.time, 'sleep', delays.append)
|
||||
responses.get(core.NOMINATIM_URL, json=[])
|
||||
core.find_locations('Bristol')
|
||||
core.find_locations('London')
|
||||
core.find_locations('Bristol')
|
||||
assert delays == [1.0]
|
||||
assert len(responses.calls) == 2
|
||||
Loading…
Add table
Add a link
Reference in a new issue