"""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") 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("#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: """Panning a close map loads visible stops while a wide map makes no query.""" 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) == 3 page.evaluate("userMapInteractionPending = true; map.panBy([80, 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) == 4 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}) 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": [], })) 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("#stop-count")).to_have_text("0 found") playwright_api.expect(page.locator("#geocode-panel")).to_be_hidden() page.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()