Import active NaPTAN stops into a local SQLite spatial index, including conversion of British National Grid coordinates. Add local searches, shareable stop links, source details and transport mode colours while retaining OSM route lookup by ATCO code. Closes #1
352 lines
17 KiB
Python
352 lines
17 KiB
Python
"""Browser tests for the UK bus stop finder using Python Playwright."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import threading
|
|
from collections.abc import Iterator
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from werkzeug.serving import make_server
|
|
|
|
from uk_bus_stops.app import create_app
|
|
|
|
playwright_api = pytest.importorskip("playwright.sync_api")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def flask_url() -> Iterator[str]:
|
|
"""Run the Flask application on a temporary local port."""
|
|
server = make_server("127.0.0.1", 0, create_app({"TESTING": True}))
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
yield f"http://127.0.0.1:{server.server_port}"
|
|
server.shutdown()
|
|
thread.join()
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def chromium_browser() -> Iterator[Any]:
|
|
"""Launch the installed Chromium browser through Python Playwright."""
|
|
with playwright_api.sync_playwright() as playwright:
|
|
instance = playwright.chromium.launch(headless=True)
|
|
yield instance
|
|
instance.close()
|
|
|
|
|
|
def test_search_and_stop_details_in_browser(chromium_browser: Any, flask_url: str) -> None:
|
|
"""A location search renders stops, an ATCO code, tags, and route metadata."""
|
|
page = chromium_browser.new_page(viewport={"width": 1280, "height": 800})
|
|
page_errors: list[str] = []
|
|
search_requests: list[str] = []
|
|
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
|
page.on("request", lambda request: (
|
|
search_requests.append(request.url) if "/api/search?" in request.url else None
|
|
))
|
|
stop = {
|
|
"type": "node", "id": 123, "lat": 51.451, "lon": -2.591,
|
|
"name": "Central Stop", "atco_code": "0100BRP90314", "indicator": "N",
|
|
"bearing": "NW",
|
|
"transport_type": "Bus stop",
|
|
"tags": {"highway": "bus_stop", "shelter": "yes"},
|
|
}
|
|
page.route("**/api/search?*", lambda route: route.fulfill(json={
|
|
"kind": "geocode",
|
|
"query": "Bristol",
|
|
"locations": [
|
|
{"lat": 51.45, "lon": -2.59, "label": "Bristol Centre", "type": "city"},
|
|
{"lat": 51.2, "lon": -2.7, "label": "Bristol Road, Somerset", "type": "road"},
|
|
],
|
|
}))
|
|
page.route("**/api/stops?*", lambda route: route.fulfill(json={
|
|
"kind": "location",
|
|
"location": {"lat": 51.451, "lon": -2.591, "label": "Central Stop"},
|
|
"stops": [stop, {
|
|
"type": "node", "id": 124, "lat": 51.452, "lon": -2.592,
|
|
"name": "Nearby Stop", "atco_code": "0100BRP90315", "indicator": "S",
|
|
"bearing": "SE", "transport_type": "Bus stop",
|
|
"tags": {"highway": "bus_stop"},
|
|
}],
|
|
}))
|
|
page.route("**/api/stops/in-bounds?*", lambda route: route.fulfill(json={
|
|
"kind": "map",
|
|
"stops": [stop, {
|
|
"type": "node", "id": 124, "lat": 51.452, "lon": -2.592,
|
|
"name": "Nearby Stop", "atco_code": "0100BRP90315", "indicator": "S",
|
|
"bearing": "SE", "transport_type": "Bus stop",
|
|
"tags": {"highway": "bus_stop"},
|
|
}],
|
|
}))
|
|
page.route("**/api/stop/node/123", lambda route: route.fulfill(json={"stop": stop}))
|
|
page.route("**/api/stop/node/123/routes", lambda route: route.fulfill(json={"routes": [{
|
|
"id": 9, "ref": "A1", "name": "Airport bus", "operator": "Example Bus",
|
|
"from": "Airport", "to": "City Centre",
|
|
}]}))
|
|
page.goto(flask_url, wait_until="networkidle")
|
|
page.get_by_label("Location or ATCO code").fill("Bristol")
|
|
page.get_by_role("button", name="Search").click()
|
|
playwright_api.expect(page).to_have_url(re.compile(r"\?q=Bristol$"))
|
|
assert all(key in search_requests[-1] for key in ("west=", "south=", "east=", "north="))
|
|
playwright_api.expect(page.get_by_text("2 UK matches for “Bristol”", exact=True)).to_be_visible()
|
|
page.get_by_text("Bristol Centre", exact=True).click()
|
|
playwright_api.expect(page).to_have_url(re.compile(r"\?lat=51.45&lon=-2.59$"))
|
|
playwright_api.expect(page.get_by_text("Central Stop", exact=True)).to_be_visible()
|
|
assert page.locator("#stop-list .stop-title").all_inner_texts() == ["Central Stop", "Nearby Stop"]
|
|
playwright_api.expect(page.locator("#stop-list .stop-meta").first).to_contain_text("m · Bus stop")
|
|
playwright_api.expect(page.locator("#stop-list .stop-meta").first).to_contain_text("NW-bound")
|
|
page.get_by_text("Central Stop", exact=True).hover()
|
|
playwright_api.expect(page.locator(".hovered-stop-marker")).to_be_visible()
|
|
assert page.evaluate("map.getZoom()") > 6
|
|
page.evaluate("markers.getLayers()[0].openTooltip()")
|
|
playwright_api.expect(page.get_by_text("Central Stop · NW-bound · Bus stop", exact=True)).to_be_visible()
|
|
page.get_by_text("Central Stop", exact=True).click()
|
|
playwright_api.expect(page).to_have_url(re.compile(r"\?node=123$"))
|
|
playwright_api.expect(page.locator(".hovered-stop-marker")).to_be_hidden()
|
|
playwright_api.expect(page.locator("#atco-code")).to_have_text("0100BRP90314")
|
|
playwright_api.expect(page.locator("#stop-type")).to_have_text("Bus stop")
|
|
playwright_api.expect(page.locator("#stop-bearing")).to_have_text("NW-bound")
|
|
playwright_api.expect(page.locator(".selected-stop-marker")).to_be_visible()
|
|
playwright_api.expect(page.get_by_text("Airport → City Centre · Example Bus", exact=True)).to_be_visible()
|
|
assert page_errors == []
|
|
page.reload(wait_until="networkidle")
|
|
playwright_api.expect(page.get_by_label("Location or ATCO code")).to_have_value("")
|
|
playwright_api.expect(page.locator("#stop-name")).to_have_text("Central Stop")
|
|
playwright_api.expect(page.locator("#atco-code")).to_have_text("0100BRP90314")
|
|
playwright_api.expect(page.locator("#stop-bearing")).to_have_text("NW-bound")
|
|
playwright_api.expect(page.get_by_role("button", name="All nearby stops")).to_be_visible()
|
|
page.get_by_role("button", name="All nearby stops").click()
|
|
playwright_api.expect(page.get_by_text("Nearby Stop", exact=True)).to_be_visible()
|
|
playwright_api.expect(page).to_have_url(re.compile(r"\?lat=.*&lon=.*$"))
|
|
page.close()
|
|
|
|
|
|
def test_moving_map_loads_only_when_zoomed_in(chromium_browser: Any, flask_url: str) -> None:
|
|
"""Map loads skip wide views and zoom-ins already covered by fetched bounds."""
|
|
page = chromium_browser.new_page(viewport={"width": 1280, "height": 800})
|
|
requests: list[str] = []
|
|
|
|
def fulfil_bounds(route: Any) -> None:
|
|
"""Record viewport calls and return a railway station."""
|
|
requests.append(route.request.url)
|
|
route.fulfill(json={"kind": "map", "stops": [{
|
|
"type": "node", "id": 456, "lat": 51.45, "lon": -2.59,
|
|
"name": "Temple Meads", "atco_code": "9100BRSTLTM", "indicator": None,
|
|
"transport_type": "Railway station", "tags": {"railway": "station"},
|
|
}]})
|
|
|
|
page.route("**/api/stops/in-bounds?*", fulfil_bounds)
|
|
page.route("**/api/stop/node/456/routes", lambda route: route.fulfill(json={"routes": []}))
|
|
page.goto(flask_url, wait_until="networkidle")
|
|
page.evaluate("userMapInteractionPending = true; map.setView([51.45, -2.59], 15)")
|
|
playwright_api.expect(page.get_by_text("Temple Meads", exact=True)).to_be_visible()
|
|
assert len(requests) == 1
|
|
page.evaluate("userMapInteractionPending = true; map.setZoom(10)")
|
|
playwright_api.expect(page.locator("#map-status")).to_have_text(
|
|
"Zoom in to load transport stops"
|
|
)
|
|
page.wait_for_timeout(500)
|
|
assert len(requests) == 1
|
|
page.evaluate("userMapInteractionPending = true; map.setZoom(15)")
|
|
page.wait_for_timeout(700)
|
|
assert len(requests) == 2
|
|
page.get_by_text("Temple Meads", exact=True).click()
|
|
playwright_api.expect(page.locator("#stop-name")).to_have_text("Temple Meads")
|
|
page.wait_for_timeout(700)
|
|
playwright_api.expect(page.locator("#stop-detail")).to_be_visible()
|
|
assert len(requests) == 2
|
|
page.evaluate("userMapInteractionPending = true; map.panBy([2500, 0])")
|
|
page.wait_for_timeout(700)
|
|
playwright_api.expect(page.locator("#stop-name")).to_have_text("Temple Meads")
|
|
playwright_api.expect(page.locator("#stop-detail")).to_be_visible()
|
|
playwright_api.expect(page).to_have_url(re.compile(r"\?node=456$"))
|
|
assert len(requests) == 3
|
|
page.close()
|
|
|
|
|
|
def test_coordinate_url_and_search_input(chromium_browser: Any, flask_url: str) -> None:
|
|
"""lat/lon URLs load on refresh and coordinate input creates a shareable URL."""
|
|
page = chromium_browser.new_page(viewport={"width": 1280, "height": 800})
|
|
requested_urls: list[str] = []
|
|
|
|
def fulfil_stops(route: Any) -> None:
|
|
"""Record coordinate API calls and return an empty nearby result."""
|
|
requested_urls.append(route.request.url)
|
|
route.fulfill(json={
|
|
"kind": "location",
|
|
"location": {"lat": 51.4545, "lon": -2.5879, "label": "Coordinates"},
|
|
"stops": [],
|
|
})
|
|
|
|
page.route("**/api/stops?*", fulfil_stops)
|
|
page.goto(f"{flask_url}?lat=51.4545&lon=-2.5879", wait_until="networkidle")
|
|
playwright_api.expect(page.get_by_label("Location or ATCO code")).to_have_value(
|
|
"51.4545, -2.5879"
|
|
)
|
|
playwright_api.expect(page.locator("#stop-count")).to_have_text("0 found")
|
|
assert page.evaluate("map.getZoom()") == 16
|
|
assert "lat=51.4545" in requested_urls[-1]
|
|
|
|
page.get_by_label("Location or ATCO code").fill("51.46, -2.58")
|
|
page.get_by_role("button", name="Search").click()
|
|
playwright_api.expect(page).to_have_url(re.compile(r"\?lat=51.46&lon=-2.58$"))
|
|
playwright_api.expect(page.locator("#stop-count")).to_have_text("0 found")
|
|
page.close()
|
|
|
|
|
|
def test_single_postcode_match_opens_directly(chromium_browser: Any, flask_url: str) -> None:
|
|
"""A unique UK postcode result skips the location-choice panel."""
|
|
page = chromium_browser.new_page(viewport={"width": 1280, "height": 800})
|
|
pending_stop_requests: list[Any] = []
|
|
page.route("**/api/search?*", lambda route: route.fulfill(json={
|
|
"kind": "geocode",
|
|
"query": "SW1A 1AA",
|
|
"locations": [{
|
|
"lat": 51.501, "lon": -0.142, "label": "Westminster, London",
|
|
"type": "postcode",
|
|
}],
|
|
}))
|
|
page.route("**/api/stops?*", lambda route: pending_stop_requests.append(route))
|
|
page.goto(flask_url, wait_until="networkidle")
|
|
page.get_by_label("Location or ATCO code").fill("SW1A 1AA")
|
|
page.get_by_role("button", name="Search").click()
|
|
playwright_api.expect(page).to_have_url(re.compile(r"\?lat=51.501&lon=-0.142$"))
|
|
playwright_api.expect(page.locator(".leaflet-interactive")).to_have_count(1)
|
|
assert page.evaluate("map.getZoom()") == 16
|
|
assert len(pending_stop_requests) == 1
|
|
pending_stop_requests[0].fulfill(json={
|
|
"kind": "location",
|
|
"location": {"lat": 51.501, "lon": -0.142, "label": "Westminster, London"},
|
|
"stops": [],
|
|
})
|
|
playwright_api.expect(page.locator("#stop-count")).to_have_text("0 found")
|
|
playwright_api.expect(page.locator("#geocode-panel")).to_be_hidden()
|
|
page.close()
|
|
|
|
|
|
def test_browser_location_loads_nearby_stops(chromium_browser: Any, flask_url: str) -> None:
|
|
"""A permitted desktop geolocation result is used for a nearby-stop search."""
|
|
context = chromium_browser.new_context(geolocation={"latitude": 51.5, "longitude": -0.12})
|
|
context.grant_permissions(["geolocation"], origin=flask_url)
|
|
page = context.new_page()
|
|
page.route("**/api/stops?*", lambda route: route.fulfill(json={
|
|
"kind": "location",
|
|
"location": {"lat": 51.5, "lon": -0.12, "label": "Your location"},
|
|
"stops": [],
|
|
}))
|
|
page.goto(flask_url, wait_until="networkidle")
|
|
page.get_by_role("button", name="Use my current location").click()
|
|
playwright_api.expect(page).to_have_url(re.compile(r"\?lat=51.5&lon=-0.12$"))
|
|
playwright_api.expect(page.locator("#stop-count")).to_have_text("0 found")
|
|
context.close()
|
|
|
|
|
|
def test_mobile_layout_opens_results_sheet(chromium_browser: Any, flask_url: str) -> None:
|
|
"""The finder exposes its search panel as an open mobile bottom sheet."""
|
|
page = chromium_browser.new_page(viewport={"width": 390, "height": 844})
|
|
page.goto(flask_url, wait_until="domcontentloaded")
|
|
playwright_api.expect(page.locator("#sidebar")).to_have_class(re.compile("panel-open"))
|
|
playwright_api.expect(page.get_by_label("Location or ATCO code")).to_be_visible()
|
|
page.close()
|
|
|
|
|
|
def test_mobile_selected_stop_stays_above_detail_sheet(
|
|
chromium_browser: Any, flask_url: str
|
|
) -> None:
|
|
"""A shared stop is centred in the visible map area above the mobile sheet."""
|
|
page = chromium_browser.new_page(viewport={"width": 390, "height": 844})
|
|
stop = {
|
|
"type": "node", "id": 485403178, "lat": 51.439385, "lon": -2.601798,
|
|
"name": "West Street", "atco_code": "0100BRA10073", "indicator": None,
|
|
"bearing": "SW", "transport_type": "Bus stop",
|
|
"tags": {"highway": "bus_stop", "naptan:Bearing": "SW"},
|
|
}
|
|
page.route("**/api/stop/node/485403178", lambda route: route.fulfill(json={"stop": stop}))
|
|
page.route("**/api/stop/node/485403178/routes", lambda route: route.fulfill(json={"routes": []}))
|
|
page.route("**/api/stops?*", lambda route: route.fulfill(json={
|
|
"kind": "location",
|
|
"location": {"lat": stop["lat"], "lon": stop["lon"], "label": stop["name"]},
|
|
"stops": [stop],
|
|
}))
|
|
page.route("**/api/stops/in-bounds?*", lambda route: route.fulfill(json={
|
|
"kind": "map", "stops": [stop],
|
|
}))
|
|
page.goto(f"{flask_url}?node=485403178", wait_until="networkidle")
|
|
playwright_api.expect(page.locator("#stop-name")).to_have_text("West Street")
|
|
playwright_api.expect(page.locator(".selected-stop-marker")).to_be_visible()
|
|
page.wait_for_timeout(400)
|
|
position = page.evaluate("""() => {
|
|
const point = map.latLngToContainerPoint(selectedMarkerHalo.getLatLng());
|
|
const mapRect = document.getElementById('map').getBoundingClientRect();
|
|
const sheetRect = document.getElementById('sidebar').getBoundingClientRect();
|
|
return {markerY: point.y, visibleBottom: sheetRect.top - mapRect.top};
|
|
}""")
|
|
assert 0 < position["markerY"] < position["visibleBottom"]
|
|
page.close()
|
|
|
|
|
|
def test_mobile_postcode_is_fitted_above_results_sheet(
|
|
chromium_browser: Any, flask_url: str
|
|
) -> None:
|
|
"""A unique postcode marker is fitted inside the unobscured mobile map area."""
|
|
page = chromium_browser.new_page(viewport={"width": 390, "height": 844})
|
|
page.route("**/api/search?*", lambda route: route.fulfill(json={
|
|
"kind": "geocode", "query": "SW1A 1AA",
|
|
"locations": [{
|
|
"lat": 51.501, "lon": -0.142, "label": "Westminster, London",
|
|
"type": "postcode",
|
|
}],
|
|
}))
|
|
page.route("**/api/stops?*", lambda route: route.fulfill(json={
|
|
"kind": "location",
|
|
"location": {"lat": 51.501, "lon": -0.142, "label": "Westminster, London"},
|
|
"stops": [{
|
|
"type": "node", "id": 999, "lat": 51.502, "lon": -0.141,
|
|
"name": "Nearby Stop", "atco_code": "490000001", "indicator": None,
|
|
"bearing": "N", "transport_type": "Bus stop",
|
|
"tags": {"highway": "bus_stop"},
|
|
}],
|
|
}))
|
|
page.goto(flask_url, wait_until="networkidle")
|
|
page.get_by_label("Location or ATCO code").fill("SW1A 1AA")
|
|
page.get_by_role("button", name="Search").click()
|
|
playwright_api.expect(page.locator("#stop-count")).to_have_text("1 found")
|
|
page.wait_for_timeout(400)
|
|
position = page.evaluate("""() => {
|
|
const point = map.latLngToContainerPoint(searchMarker.getLatLng());
|
|
const mapRect = document.getElementById('map').getBoundingClientRect();
|
|
const sheetRect = document.getElementById('sidebar').getBoundingClientRect();
|
|
return {markerY: point.y, visibleBottom: sheetRect.top - mapRect.top};
|
|
}""")
|
|
assert 0 < position["markerY"] < position["visibleBottom"]
|
|
page.close()
|
|
|
|
|
|
def test_naptan_shared_stop_and_osm_routes(chromium_browser: Any, flask_url: str) -> None:
|
|
"""NaPTAN links survive reload and show source fields plus matched OSM routes."""
|
|
page = chromium_browser.new_page(viewport={"width": 1280, "height": 800})
|
|
errors: list[str] = []
|
|
page.on("pageerror", lambda error: errors.append(str(error)))
|
|
stop = {
|
|
"type": "naptan", "source": "NaPTAN", "id": "0100BRP90317",
|
|
"lat": 51.44827, "lon": -2.58302, "name": "Temple Meads Stn",
|
|
"atco_code": "0100BRP90317", "indicator": "T1", "bearing": "SE",
|
|
"transport_type": "Bus stop", "tags": {"CommonName": "Temple Meads Stn", "Status": "active"},
|
|
}
|
|
page.route("**/api/stop/naptan/0100BRP90317", lambda route: route.fulfill(json={"stop": stop}))
|
|
page.route("**/api/stop/naptan/0100BRP90317/routes", lambda route: route.fulfill(json={
|
|
"matched_stops": 1, "routes": [{"id": 12, "ref": "A1", "name": "Airport bus"}],
|
|
}))
|
|
page.route("**/api/stops/in-bounds?*", lambda route: route.fulfill(json={"kind": "map", "stops": [stop]}))
|
|
page.goto(f"{flask_url}/?naptan=0100BRP90317")
|
|
expect = playwright_api.expect
|
|
for _ in range(2):
|
|
expect(page.locator("#stop-name")).to_have_text("Temple Meads Stn")
|
|
expect(page.locator("#stop-source")).to_contain_text("NaPTAN")
|
|
expect(page.locator("#tags-heading")).to_have_text("NaPTAN stop data")
|
|
expect(page.locator("#osm-link")).not_to_be_visible()
|
|
expect(page.locator("#route-list")).to_contain_text("A1")
|
|
assert "naptan=0100BRP90317" in page.url
|
|
page.reload()
|
|
assert errors == []
|
|
page.close()
|