322 lines
16 KiB
Python
322 lines
16 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()
|