Improve stop search and add documentation
This commit is contained in:
parent
42484907ac
commit
71e1b57164
9 changed files with 358 additions and 37 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
23
src/uk_bus_stops/static/about.css
Normal file
23
src/uk_bus_stops/static/about.css
Normal 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; }
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
129
src/uk_bus_stops/templates/about.html
Normal file
129
src/uk_bus_stops/templates/about.html
Normal 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&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=…&lon=…</code></td><td>Stops within 1 km of a point.</td></tr>
|
||||
<tr><td><code>GET {{ url_for('bounded_stops') }}?south=…&west=…&north=…&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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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&lon=-2.5879" in response.data
|
||||
assert b"overpass.atownsend.org.uk" in response.data
|
||||
assert b"JSON endpoints" in response.data
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue