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 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 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 `?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 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 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 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. 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 Selecting a stop replaces the search parameters with a shareable stop URL such
as `?node=485403163` (or `?way=...` / `?relation=...` for other OSM object 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 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, Browser tests use Python Playwright. After installing the development extras,
install Chromium once and run the suite: install Chromium once and run the suite:

View file

@ -18,7 +18,7 @@ from uk_bus_stops.core import (
stops_in_bounds, 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( COORDINATE_PATTERN = re.compile(
r"^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*,\s*" r"^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*,\s*"
r"([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*$" r"([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*$"
@ -36,6 +36,20 @@ def parse_coordinates(value: str) -> tuple[float, float] | None:
return lat, lon 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: def create_app(test_config: dict[str, Any] | None = None) -> Flask:
"""Create and configure the bus stop finder application.""" """Create and configure the bus stop finder application."""
app = Flask(__name__) app = Flask(__name__)
@ -47,12 +61,21 @@ def create_app(test_config: dict[str, Any] | None = None) -> Flask:
"""Render the bus stop finder.""" """Render the bus stop finder."""
return render_template("index.html") 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") @app.get("/api/search")
def search() -> ResponseReturnValue: def search() -> ResponseReturnValue:
"""Search directly by ATCO code or geocode a UK location.""" """Search directly by ATCO code or geocode a UK location."""
query = request.args.get("q", "").strip() query = request.args.get("q", "").strip()
if not query: if not query:
return _error("missing_query", "Enter a postcode, place, street or ATCO code.", 400) 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: try:
coordinates = parse_coordinates(query) coordinates = parse_coordinates(query)
if coordinates is not None: 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): if ATCO_PATTERN.fullmatch(query):
stops = find_atco_code(query) stops = find_atco_code(query)
return jsonify({"kind": "atco", "label": query.upper(), "stops": stops}) return jsonify({"kind": "atco", "label": query.upper(), "stops": stops})
location = geocode(query) locations = geocode(query, viewbox=viewbox)
if location is None: if not locations:
return _error("not_found", "No UK location matched that search.", 404) return _error("not_found", "No UK location matched that search.", 404)
stops = nearby_stops(location["lat"], location["lon"]) return jsonify({"kind": "geocode", "query": query, "locations": locations})
return jsonify({"kind": "location", "location": location, "stops": stops})
except UpstreamError as exc: except UpstreamError as exc:
return _error("upstream_error", str(exc), 502) 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 raise UpstreamError("The OpenStreetMap service is temporarily unavailable.") from exc
def geocode(query: str) -> dict[str, Any] | None: def geocode(
"""Geocode a UK postcode, street, or place name with Nominatim.""" 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( results = _get_json(
NOMINATIM_URL, NOMINATIM_URL,
params={ params=params,
"q": query,
"format": "jsonv2",
"limit": 1,
"countrycodes": "gb",
"addressdetails": 1,
},
) )
if not isinstance(results, list) or not results: if not isinstance(results, list):
return None return []
result = results[0] locations: list[dict[str, Any]] = []
return { for result in results:
"lat": float(result["lat"]), if not isinstance(result, dict) or "lat" not in result or "lon" not in result:
"lon": float(result["lon"]), continue
"label": result.get("display_name", query), 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]]: 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); 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. */ /** Perform a map movement without treating it as a user pan. */
function moveMap(callback, onComplete = null) { function moveMap(callback, onComplete = null) {
userMapInteractionPending = false; userMapInteractionPending = false;
@ -94,7 +107,7 @@ function restoreSearchFromUrl() {
const query = params.get('q'); const query = params.get('q');
if (query) { if (query) {
byId('search-input').value = query; byId('search-input').value = query;
loadUrl(`${API_URLS.search}?${new URLSearchParams({q: query})}`); loadUrl(locationSearchApiUrl(query));
return; return;
} }
@ -110,6 +123,7 @@ function restoreSearchFromUrl() {
} }
byId('search-input').value = ''; byId('search-input').value = '';
show('geocode-panel', false);
markers.clearLayers(); markers.clearLayers();
if (searchMarker) { searchMarker.remove(); searchMarker = null; } if (searchMarker) { searchMarker.remove(); searchMarker = null; }
if (selectedMarkerHalo) { selectedMarkerHalo.remove(); selectedMarkerHalo = 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. */ /** Fetch and display the stop named by a shareable URL. */
async function loadSharedStop(type, id) { async function loadSharedStop(type, id) {
show('alert', false); show('alert', false);
show('geocode-panel', false);
setLoading(true, 'Loading stop…'); setLoading(true, 'Loading stop…');
try { try {
const response = await fetch(stopApiUrl(API_URLS.stopTemplate, type, id)); const response = await fetch(stopApiUrl(API_URLS.stopTemplate, type, id));
@ -232,6 +247,7 @@ function renderStops(data, fitMap = true, sortOrigin = null, preserveSelection =
if (!preserveSelection) { if (!preserveSelection) {
show('stop-detail', false); show('stop-detail', false);
show('results-panel'); show('results-panel');
show('geocode-panel', false);
} }
byId('results-heading').textContent = data.kind === 'atco' byId('results-heading').textContent = data.kind === 'atco'
? `ATCO code ${data.label}` ? `ATCO code ${data.label}`
@ -287,6 +303,46 @@ function renderStops(data, fitMap = true, sortOrigin = null, preserveSelection =
if (isMobile() && data.kind !== 'map') setPanel(true); 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. */ /** Choose marker colours that make different transport modes easy to scan. */
function markerColour(stop) { function markerColour(stop) {
const type = stop.transport_type || ''; const type = stop.transport_type || '';
@ -430,7 +486,8 @@ async function loadUrl(url) {
const response = await fetch(url); const response = await fetch(url);
const data = await response.json(); const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Search failed.'); if (!response.ok) throw new Error(data.message || 'Search failed.');
renderStops(data); if (data.kind === 'geocode') renderGeocodeChoices(data);
else renderStops(data);
} catch (error) { } catch (error) {
showError(error.message || 'Network error. Please try again.'); showError(error.message || 'Network error. Please try again.');
} finally { } finally {
@ -489,7 +546,7 @@ byId('search-form').addEventListener('submit', event => {
} else { } else {
const params = new URLSearchParams({q: query}); const params = new URLSearchParams({q: query});
setSearchUrl(params); 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> <body>
<nav class="navbar navbar-dark bg-dark px-3"> <nav class="navbar navbar-dark bg-dark px-3">
<a class="navbar-brand" href="{{ url_for('index') }}">UK Bus Stop Finder</a> <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> </nav>
<main id="main-row"> <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> <span class="spinner-border spinner-border-sm me-1"></span><span id="loading-text">Finding stops…</span>
</div> </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"> <section id="results-panel" class="d-none">
<div class="d-flex justify-content-between align-items-baseline mb-2"> <div class="d-flex justify-content-between align-items-baseline mb-2">
<h2 id="results-heading" class="h6 mb-0">Nearby stops</h2> <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"}], json=[{"lat": "51.45", "lon": "-2.59", "display_name": "Bristol, England"}],
) )
result = core.geocode("Bristol") 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 request = responses.calls[0].request
assert "countrycodes=gb" in request.url 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 request.headers["User-Agent"].startswith("uk-bus-stops/")
assert "edward@4angle.com" in request.headers["User-Agent"] 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.""" """Long alphabetic place names still go through Nominatim geocoding."""
assert ATCO_PATTERN.fullmatch("Manchester") is None assert ATCO_PATTERN.fullmatch("Manchester") is None
assert ATCO_PATTERN.fullmatch("0100BRP90314") is not 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: 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) 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: def test_location_coordinates_are_validated(client: Any) -> None:
"""Latitude and longitude must be numeric and within geographic ranges.""" """Latitude and longitude must be numeric and within geographic ranges."""
assert client.get("/api/stops?lat=hello&lon=1").status_code == 400 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"Find a UK bus stop code" in response.data
assert b'id="search-input"' in response.data assert b'id="search-input"' in response.data
assert b'id="map"' 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.""" """A location search renders stops, an ATCO code, tags, and route metadata."""
page = chromium_browser.new_page(viewport={"width": 1280, "height": 800}) page = chromium_browser.new_page(viewport={"width": 1280, "height": 800})
page_errors: list[str] = [] page_errors: list[str] = []
search_requests: list[str] = []
page.on("pageerror", lambda error: page_errors.append(str(error))) 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 = { stop = {
"type": "node", "id": 123, "lat": 51.451, "lon": -2.591, "type": "node", "id": 123, "lat": 51.451, "lon": -2.591,
"name": "Central Stop", "atco_code": "0100BRP90314", "indicator": "N", "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"}, "tags": {"highway": "bus_stop", "shelter": "yes"},
} }
page.route("**/api/search?*", lambda route: route.fulfill(json={ page.route("**/api/search?*", lambda route: route.fulfill(json={
"kind": "location", "kind": "geocode",
"location": {"lat": 51.45, "lon": -2.59, "label": "Bristol"}, "query": "Bristol",
"stops": [{ "locations": [
"type": "node", "id": 125, "lat": 51.47, "lon": -2.61, {"lat": 51.45, "lon": -2.59, "label": "Bristol Centre", "type": "city"},
"name": "Far Stop", "atco_code": "0100BRP90316", "indicator": None, {"lat": 51.2, "lon": -2.7, "label": "Bristol Road, Somerset", "type": "road"},
"bearing": "E", "transport_type": "Bus stop", ],
"tags": {"highway": "bus_stop"},
}, stop],
})) }))
page.route("**/api/stops?*", lambda route: route.fulfill(json={ page.route("**/api/stops?*", lambda route: route.fulfill(json={
"kind": "location", "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_label("Location or ATCO code").fill("Bristol")
page.get_by_role("button", name="Search").click() page.get_by_role("button", name="Search").click()
playwright_api.expect(page).to_have_url(re.compile(r"\?q=Bristol$")) 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() 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("m · Bus stop")
playwright_api.expect(page.locator("#stop-list .stop-meta").first).to_contain_text("NW-bound") playwright_api.expect(page.locator("#stop-list .stop-meta").first).to_contain_text("NW-bound")
assert page.evaluate("map.getZoom()") > 6 assert page.evaluate("map.getZoom()") > 6