Improve stop search and add documentation

This commit is contained in:
Edward Betts 2026-08-15 12:06:23 +01:00
parent 42484907ac
commit 71e1b57164
9 changed files with 358 additions and 37 deletions

View file

@ -111,12 +111,15 @@ when the server is running.
A mobile-friendly map for finding the `naptan:AtcoCode` recorded on UK bus
stops in OpenStreetMap. Search by postcode, street, place, exact ATCO code, or
browser location. Searches are preserved in shareable `?q=...` or
browser location. Postcode, street, and place searches show up to 20
UK-only Nominatim matches for the user to choose from. Searches are preserved in shareable `?q=...` or
`?lat=...&lon=...` URLs, and `latitude, longitude` can be entered directly in
the search box. Selecting a stop shows its tags and stop-area-aware bus route
relations. Stop results identify their transport mode (such as bus, rail or
tram), and moving the map at zoom level 15 or closer loads stops in the visible
area. Wider map views deliberately do not query Overpass.
The current map bounds bias Nominatim's ranking without excluding UK matches
outside the visible area.
Selecting a stop replaces the search parameters with a shareable stop URL such
as `?node=485403163` (or `?way=...` / `?relation=...` for other OSM object
@ -134,6 +137,8 @@ flask --app uk_bus_stops.app run
```
The production URL is `https://openstreetmap.tools/uk-bus-stops/`.
The app's `/about` page documents shareable URL parameters, map behaviour,
data sources, privacy considerations, and JSON endpoints.
Browser tests use Python Playwright. After installing the development extras,
install Chromium once and run the suite:

View file

@ -18,7 +18,7 @@ from uk_bus_stops.core import (
stops_in_bounds,
)
ATCO_PATTERN = re.compile(r"^(?=[A-Za-z0-9]{10,16}$)(?=.*\d)[A-Za-z0-9]+$")
ATCO_PATTERN = re.compile(r"^(?=[A-Za-z0-9]{9,16}$)(?=.*\d)[A-Za-z0-9]+$")
COORDINATE_PATTERN = re.compile(
r"^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*,\s*"
r"([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*$"
@ -36,6 +36,20 @@ def parse_coordinates(value: str) -> tuple[float, float] | None:
return lat, lon
def parse_viewbox() -> tuple[float, float, float, float] | None:
"""Parse an optional west/south/east/north search-bias bounding box."""
names = ("west", "south", "east", "north")
values = [request.args.get(name) for name in names]
if not any(value is not None for value in values):
return None
if any(value is None for value in values):
raise ValueError("All four viewbox coordinates are required.")
west, south, east, north = (float(value) for value in values if value is not None)
if not (-180 <= west < east <= 180 and -90 <= south < north <= 90):
raise ValueError("The search viewbox is invalid.")
return west, south, east, north
def create_app(test_config: dict[str, Any] | None = None) -> Flask:
"""Create and configure the bus stop finder application."""
app = Flask(__name__)
@ -47,12 +61,21 @@ def create_app(test_config: dict[str, Any] | None = None) -> Flask:
"""Render the bus stop finder."""
return render_template("index.html")
@app.get("/about")
def about() -> ResponseReturnValue:
"""Explain the finder, its shareable URLs, and its data sources."""
return render_template("about.html")
@app.get("/api/search")
def search() -> ResponseReturnValue:
"""Search directly by ATCO code or geocode a UK location."""
query = request.args.get("q", "").strip()
if not query:
return _error("missing_query", "Enter a postcode, place, street or ATCO code.", 400)
try:
viewbox = parse_viewbox()
except ValueError as exc:
return _error("invalid_viewbox", str(exc), 400)
try:
coordinates = parse_coordinates(query)
if coordinates is not None:
@ -66,11 +89,10 @@ def create_app(test_config: dict[str, Any] | None = None) -> Flask:
if ATCO_PATTERN.fullmatch(query):
stops = find_atco_code(query)
return jsonify({"kind": "atco", "label": query.upper(), "stops": stops})
location = geocode(query)
if location is None:
locations = geocode(query, viewbox=viewbox)
if not locations:
return _error("not_found", "No UK location matched that search.", 404)
stops = nearby_stops(location["lat"], location["lon"])
return jsonify({"kind": "location", "location": location, "stops": stops})
return jsonify({"kind": "geocode", "query": query, "locations": locations})
except UpstreamError as exc:
return _error("upstream_error", str(exc), 502)

View file

@ -32,26 +32,39 @@ def _get_json(url: str, *, params: dict[str, Any]) -> Any:
raise UpstreamError("The OpenStreetMap service is temporarily unavailable.") from exc
def geocode(query: str) -> dict[str, Any] | None:
"""Geocode a UK postcode, street, or place name with Nominatim."""
def geocode(
query: str, viewbox: tuple[float, float, float, float] | None = None
) -> list[dict[str, Any]]:
"""Return UK-only matches, optionally biased to a west/south/east/north box."""
params: dict[str, Any] = {
"q": query,
"format": "jsonv2",
"limit": 20,
"countrycodes": "gb",
"addressdetails": 1,
"dedupe": 1,
"bounded": 0,
}
if viewbox is not None:
west, south, east, north = viewbox
params["viewbox"] = f"{west},{north},{east},{south}"
results = _get_json(
NOMINATIM_URL,
params={
"q": query,
"format": "jsonv2",
"limit": 1,
"countrycodes": "gb",
"addressdetails": 1,
},
params=params,
)
if not isinstance(results, list) or not results:
return None
result = results[0]
return {
"lat": float(result["lat"]),
"lon": float(result["lon"]),
"label": result.get("display_name", query),
}
if not isinstance(results, list):
return []
locations: list[dict[str, Any]] = []
for result in results:
if not isinstance(result, dict) or "lat" not in result or "lon" not in result:
continue
locations.append({
"lat": float(result["lat"]),
"lon": float(result["lon"]),
"label": result.get("display_name", query),
"type": result.get("type"),
})
return locations
def _overpass(query: str) -> list[dict[str, Any]]:

View file

@ -0,0 +1,23 @@
body { color: #1c2333; background: #f8f9fa; }
.navbar { min-height: 56px; }
.navbar-brand { font-size: 1.05rem; }
.doc-column { max-width: 1100px; }
.doc-column h1 { font-size: clamp(1.8rem, 4vw, 2.5rem); margin-bottom: 1rem; }
.doc-column h2 { margin-top: 2.25rem; margin-bottom: .8rem; font-size: 1.35rem; scroll-margin-top: 1rem; }
.doc-column p, .doc-column li { line-height: 1.7; }
.doc-column code { overflow-wrap: anywhere; }
.share-table { min-width: 900px; }
.share-table th:nth-child(1), .share-table td:nth-child(1) { width: 14%; }
.share-table th:nth-child(2), .share-table td:nth-child(2) { width: 51%; }
.share-table th:nth-child(3), .share-table td:nth-child(3) { width: 35%; }
.share-table td:nth-child(3), .share-table td:nth-child(3) code {
white-space: nowrap;
overflow-wrap: normal;
}
.endpoint-table td:first-child { width: 58%; }
@media (max-width: 575.98px) {
.navbar-brand { font-size: .95rem; }
.navbar .nav-link { font-size: .75rem !important; }
.doc-column .lead { font-size: 1.05rem; }
.endpoint-table td:first-child { width: auto; }
}

View file

@ -58,6 +58,19 @@ function setSearchUrl(params, replace = false) {
history[replace ? 'replaceState' : 'pushState'](null, '', url);
}
/** Build a text-search API URL biased toward the map without restricting results. */
function locationSearchApiUrl(query) {
const bounds = map.getBounds();
const params = new URLSearchParams({
q: query,
west: bounds.getWest().toFixed(7),
south: bounds.getSouth().toFixed(7),
east: bounds.getEast().toFixed(7),
north: bounds.getNorth().toFixed(7),
});
return `${API_URLS.search}?${params}`;
}
/** Perform a map movement without treating it as a user pan. */
function moveMap(callback, onComplete = null) {
userMapInteractionPending = false;
@ -94,7 +107,7 @@ function restoreSearchFromUrl() {
const query = params.get('q');
if (query) {
byId('search-input').value = query;
loadUrl(`${API_URLS.search}?${new URLSearchParams({q: query})}`);
loadUrl(locationSearchApiUrl(query));
return;
}
@ -110,6 +123,7 @@ function restoreSearchFromUrl() {
}
byId('search-input').value = '';
show('geocode-panel', false);
markers.clearLayers();
if (searchMarker) { searchMarker.remove(); searchMarker = null; }
if (selectedMarkerHalo) { selectedMarkerHalo.remove(); selectedMarkerHalo = null; }
@ -139,6 +153,7 @@ function stopApiUrl(template, type, id) {
/** Fetch and display the stop named by a shareable URL. */
async function loadSharedStop(type, id) {
show('alert', false);
show('geocode-panel', false);
setLoading(true, 'Loading stop…');
try {
const response = await fetch(stopApiUrl(API_URLS.stopTemplate, type, id));
@ -232,6 +247,7 @@ function renderStops(data, fitMap = true, sortOrigin = null, preserveSelection =
if (!preserveSelection) {
show('stop-detail', false);
show('results-panel');
show('geocode-panel', false);
}
byId('results-heading').textContent = data.kind === 'atco'
? `ATCO code ${data.label}`
@ -287,6 +303,46 @@ function renderStops(data, fitMap = true, sortOrigin = null, preserveSelection =
if (isMobile() && data.kind !== 'map') setPanel(true);
}
/** Show UK-only Nominatim matches and wait for the user to choose one. */
function renderGeocodeChoices(data) {
selectedStop = null;
markers.clearLayers();
if (searchMarker) { searchMarker.remove(); searchMarker = null; }
if (selectedMarkerHalo) { selectedMarkerHalo.remove(); selectedMarkerHalo = null; }
show('stop-detail', false);
show('results-panel', false);
show('geocode-panel');
byId('geocode-summary').textContent = `${data.locations.length} UK match${data.locations.length === 1 ? '' : 'es'} for “${data.query}`;
const list = byId('geocode-list');
list.replaceChildren();
for (const locationResult of data.locations) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'stop-item location-item';
const title = document.createElement('span');
title.className = 'stop-title';
title.textContent = locationResult.label;
button.appendChild(title);
if (locationResult.type) {
const meta = document.createElement('span');
meta.className = 'stop-meta text-capitalize';
meta.textContent = String(locationResult.type).replaceAll('_', ' ');
button.appendChild(meta);
}
button.addEventListener('click', () => chooseLocation(locationResult));
list.appendChild(button);
}
if (isMobile()) setPanel(true);
}
/** Load nearby stops for one selected Nominatim result. */
function chooseLocation(locationResult) {
const coordinates = {lat: locationResult.lat, lon: locationResult.lon};
setSearchUrl(new URLSearchParams(coordinates));
const apiParams = new URLSearchParams({...coordinates, label: locationResult.label});
loadUrl(`${API_URLS.stops}?${apiParams}`);
}
/** Choose marker colours that make different transport modes easy to scan. */
function markerColour(stop) {
const type = stop.transport_type || '';
@ -430,7 +486,8 @@ async function loadUrl(url) {
const response = await fetch(url);
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Search failed.');
renderStops(data);
if (data.kind === 'geocode') renderGeocodeChoices(data);
else renderStops(data);
} catch (error) {
showError(error.message || 'Network error. Please try again.');
} finally {
@ -489,7 +546,7 @@ byId('search-form').addEventListener('submit', event => {
} else {
const params = new URLSearchParams({q: query});
setSearchUrl(params);
loadUrl(`${API_URLS.search}?${params}`);
loadUrl(locationSearchApiUrl(query));
}
});

View file

@ -0,0 +1,129 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="About the UK Bus Stop Finder and its shareable URL parameters.">
<title>About UK Bus Stop Finder</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='about.css') }}">
</head>
<body>
<nav class="navbar navbar-dark bg-dark px-3">
<a class="navbar-brand" href="{{ url_for('index') }}">UK Bus Stop Finder</a>
<div class="d-flex align-items-center gap-3">
<a class="nav-link text-white small" href="{{ url_for('index') }}">Map</a>
<a class="nav-link text-white small" href="https://openstreetmap.tools/">OSM tools</a>
</div>
</nav>
<main class="container py-4 py-md-5">
<div class="doc-column">
<h1>About the UK Bus Stop Finder</h1>
<p class="lead">
This tool makes it easier to find the ATCO code recorded for a UK public
transport stop in OpenStreetMap. ATCO codes are used by journey-planning
services and integrations such as Home Assistant bus departure displays.
</p>
<p>
Search by postcode, street, place, ATCO code, or coordinates. Select a
result to see its code, direction, coordinates, OSM tags, and associated
bus route relations. Stops without an ATCO code are still shown and link
to the OpenStreetMap editor.
</p>
<h2 id="urls">Shareable URL parameters</h2>
<p>Use one search form at a time; selecting a stop replaces search parameters with its OSM object ID.</p>
<div class="table-responsive">
<table class="table table-bordered align-middle share-table">
<thead><tr><th>Parameters</th><th>Meaning</th><th>Example</th></tr></thead>
<tbody>
<tr>
<td><code>q</code></td>
<td>A postcode, street, place name, or exact ATCO code. Place searches show up to 20 UK-only matches to choose from.</td>
<td><a href="{{ url_for('index', q='Bristol Temple Meads') }}"><code>{{ url_for('index') }}?q=Bristol+Temple+Meads</code></a></td>
</tr>
<tr>
<td><code>lat</code> and <code>lon</code></td>
<td>Decimal latitude and longitude. Both are required. The map opens around this point.</td>
<td><a href="{{ url_for('index', lat='51.4545', lon='-2.5879') }}"><code>{{ url_for('index') }}?lat=51.4545&amp;lon=-2.5879</code></a></td>
</tr>
<tr>
<td><code>node</code></td>
<td>An OpenStreetMap node ID. The stop opens with nearby stops visible.</td>
<td><a href="{{ url_for('index', node='485403178') }}"><code>{{ url_for('index') }}?node=485403178</code></a></td>
</tr>
<tr>
<td><code>way</code></td>
<td>An OSM way ID, for the less common stop mapped as an area.</td>
<td><code>{{ url_for('index') }}?way=123456</code></td>
</tr>
<tr>
<td><code>relation</code></td>
<td>An OSM relation ID used as a stop object.</td>
<td><code>{{ url_for('index') }}?relation=123456</code></td>
</tr>
</tbody>
</table>
</div>
<p>
You can also enter coordinates directly in the search box as
<code>latitude, longitude</code>, for example <code>51.4545, -2.5879</code>.
Browser location searches produce a shareable <code>lat</code>/<code>lon</code> URL.
</p>
<h2 id="map">Using the map</h2>
<ul>
<li>Search results are sorted by distance from the searched point.</li>
<li>After moving the map, results are sorted from the new map centre.</li>
<li>Text searches use the visible map as a Nominatim ranking preference, but matches outside the view remain eligible.</li>
<li>Stops in the visible area load automatically at zoom level 15 or closer.</li>
<li>Wider views do not query Overpass, to avoid requesting an excessively large area.</li>
<li>Selecting a stop highlights it and refreshes surrounding markers without closing its details.</li>
</ul>
<h2 id="data">Data and availability</h2>
<p>
Stop and route data comes from
<a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>.
Place searches use <a href="https://nominatim.org/">Nominatim</a>. Transport
queries use the Britain-and-Ireland Overpass instance operated at
<a href="https://overpass.atownsend.org.uk/">overpass.atownsend.org.uk</a>,
with <a href="https://overpass.private.coffee/">Private.coffee</a> as a fallback.
</p>
<p>
Public Overpass services can occasionally be busy. OSM data may be incomplete
or out of date, and this finder is not an official source of NaPTAN data.
If a code or stop is missing, use the edit link on its detail panel and follow
OpenStreetMap's editing and source requirements.
</p>
<h2 id="privacy">Location and privacy</h2>
<p>
“Use my current location” asks the browser for permission. The coordinates are
sent to this application to find nearby stops and appear in the shareable URL;
the application does not intentionally persist search or location history.
Requests to map tiles, Nominatim, and Overpass are subject to those services'
own policies and operational logs.
</p>
<h2 id="api">JSON endpoints</h2>
<p>The interface uses these endpoints. Errors have the form <code>{"error": "code", "message": "text"}</code>.</p>
<div class="table-responsive">
<table class="table table-sm table-striped endpoint-table">
<thead><tr><th>Endpoint</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td><code>GET {{ url_for('search') }}?q=…</code></td><td>Search a place, coordinates, or ATCO code.</td></tr>
<tr><td><code>GET {{ url_for('stops') }}?lat=…&amp;lon=…</code></td><td>Stops within 1 km of a point.</td></tr>
<tr><td><code>GET {{ url_for('bounded_stops') }}?south=…&amp;west=…&amp;north=…&amp;east=…</code></td><td>Stops in a safely limited map area.</td></tr>
<tr><td><code>GET {{ url_for('stop_detail', element_type='node', element_id=485403178) }}</code></td><td>One stop by OSM type and ID.</td></tr>
<tr><td><code>GET {{ url_for('stop_routes', element_type='node', element_id=485403178) }}</code></td><td>Stop-area-aware bus routes serving a stop.</td></tr>
</tbody>
</table>
</div>
<p class="mt-4"><a class="btn btn-primary" href="{{ url_for('index') }}">Open the stop finder</a></p>
</div>
</main>
</body>
</html>

View file

@ -12,7 +12,10 @@
<body>
<nav class="navbar navbar-dark bg-dark px-3">
<a class="navbar-brand" href="{{ url_for('index') }}">UK Bus Stop Finder</a>
<a class="nav-link text-white small" href="https://openstreetmap.tools/">OSM tools</a>
<div class="d-flex align-items-center gap-3">
<a class="nav-link text-white small" href="{{ url_for('about') }}">About</a>
<a class="nav-link text-white small" href="https://openstreetmap.tools/">OSM tools</a>
</div>
</nav>
<main id="main-row">
@ -41,6 +44,12 @@
<span class="spinner-border spinner-border-sm me-1"></span><span id="loading-text">Finding stops…</span>
</div>
<section id="geocode-panel" class="d-none">
<h2 class="h6 mb-1">Choose a location</h2>
<p id="geocode-summary" class="small text-muted mb-2"></p>
<div id="geocode-list"></div>
</section>
<section id="results-panel" class="d-none">
<div class="d-flex justify-content-between align-items-baseline mb-2">
<h2 id="results-heading" class="h6 mb-0">Nearby stops</h2>

View file

@ -25,9 +25,13 @@ def test_geocode_limits_search_to_great_britain_and_identifies_app() -> None:
json=[{"lat": "51.45", "lon": "-2.59", "display_name": "Bristol, England"}],
)
result = core.geocode("Bristol")
assert result == {"lat": 51.45, "lon": -2.59, "label": "Bristol, England"}
assert result == [{
"lat": 51.45, "lon": -2.59, "label": "Bristol, England", "type": None,
}]
request = responses.calls[0].request
assert "countrycodes=gb" in request.url
assert "limit=20" in request.url
assert "bounded=0" in request.url
assert request.headers["User-Agent"].startswith("uk-bus-stops/")
assert "edward@4angle.com" in request.headers["User-Agent"]
@ -143,6 +147,7 @@ def test_place_name_is_not_mistaken_for_atco_code() -> None:
"""Long alphabetic place names still go through Nominatim geocoding."""
assert ATCO_PATTERN.fullmatch("Manchester") is None
assert ATCO_PATTERN.fullmatch("0100BRP90314") is not None
assert ATCO_PATTERN.fullmatch("010000056") is not None
def test_parse_coordinates_accepts_valid_pair_and_rejects_invalid_pair() -> None:
@ -165,6 +170,45 @@ def test_search_coordinates_bypasses_nominatim(client: Any) -> None:
assert responses.calls[0].request.url.startswith(core.OVERPASS_URL)
@responses.activate
def test_geocode_uses_unbounded_map_bias() -> None:
"""The map view biases Nominatim ranking without restricting its results."""
responses.get(core.NOMINATIM_URL, json=[])
assert core.geocode("North Street", viewbox=(-2.7, 51.4, -2.5, 51.5)) == []
url = responses.calls[0].request.url
assert "viewbox=-2.7%2C51.5%2C-2.5%2C51.4" in url
assert "bounded=0" in url
@responses.activate
def test_place_search_returns_uk_choices_before_loading_stops(client: Any) -> None:
"""Ambiguous place searches return UK Nominatim choices without querying Overpass."""
responses.get(core.NOMINATIM_URL, json=[
{"lat": "51.1", "lon": "-1.3", "display_name": "North Street, Winchester",
"type": "residential"},
{"lat": "51.4", "lon": "-2.6", "display_name": "North Street, Bristol",
"type": "secondary"},
])
response = client.get(
"/api/search?q=North+Street&west=-2.7&south=51.4&east=-2.5&north=51.5"
)
assert response.status_code == 200
data = response.get_json()
assert data["kind"] == "geocode"
assert [item["label"] for item in data["locations"]] == [
"North Street, Winchester", "North Street, Bristol",
]
assert len(responses.calls) == 1
assert "viewbox=-2.7%2C51.5%2C-2.5%2C51.4" in responses.calls[0].request.url
def test_search_rejects_partial_viewbox(client: Any) -> None:
"""Search-bias bounds must contain all four valid coordinates."""
response = client.get("/api/search?q=North+Street&west=-2.7")
assert response.status_code == 400
assert response.get_json()["error"] == "invalid_viewbox"
def test_location_coordinates_are_validated(client: Any) -> None:
"""Latitude and longitude must be numeric and within geographic ranges."""
assert client.get("/api/stops?lat=hello&lon=1").status_code == 400
@ -211,3 +255,16 @@ def test_index_contains_search_and_map(client: Any) -> None:
assert b"Find a UK bus stop code" in response.data
assert b'id="search-input"' in response.data
assert b'id="map"' in response.data
assert b'href="/about"' in response.data
def test_about_documents_urls_and_services(client: Any) -> None:
"""The About page documents shareable links, APIs, and OSM data sources."""
response = client.get("/about")
assert response.status_code == 200
assert b"Shareable URL parameters" in response.data
assert b"?q=Bristol+Temple+Meads" in response.data
assert b"?node=485403178" in response.data
assert b"?lat=51.4545&amp;lon=-2.5879" in response.data
assert b"overpass.atownsend.org.uk" in response.data
assert b"JSON endpoints" in response.data

View file

@ -39,7 +39,11 @@ def test_search_and_stop_details_in_browser(chromium_browser: Any, flask_url: st
"""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",
@ -48,14 +52,12 @@ def test_search_and_stop_details_in_browser(chromium_browser: Any, flask_url: st
"tags": {"highway": "bus_stop", "shelter": "yes"},
}
page.route("**/api/search?*", lambda route: route.fulfill(json={
"kind": "location",
"location": {"lat": 51.45, "lon": -2.59, "label": "Bristol"},
"stops": [{
"type": "node", "id": 125, "lat": 51.47, "lon": -2.61,
"name": "Far Stop", "atco_code": "0100BRP90316", "indicator": None,
"bearing": "E", "transport_type": "Bus stop",
"tags": {"highway": "bus_stop"},
}, stop],
"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",
@ -85,8 +87,12 @@ def test_search_and_stop_details_in_browser(chromium_browser: Any, flask_url: st
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", "Far Stop"]
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