Implement story 1.4: location resolution ICAO and address

Adds location.resolve() with ICAO CSV lookup and Nominatim geocoding,
POST /find-location portal route with inline confirmation/error display,
and full test coverage with mocked network calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Edholm
2026-04-22 22:46:31 -04:00
parent d388cca478
commit 6231e3157e
5 changed files with 214 additions and 38 deletions
@@ -1,6 +1,6 @@
# Story 1.4: Location Resolution (ICAO & Address)
Status: ready-for-dev
Status: review
## Story
@@ -22,39 +22,39 @@ So that I can verify the device is centred on the correct location before commit
## Tasks / Subtasks
- [ ] Task 1: Implement `location.resolve(query)` in `src/planemapper/provisioning/location.py` (AC: #1, #2, #3, #4)
- [ ] 1.1 Normalise the input: `query = query.strip().upper()`
- [ ] 1.2 Detect ICAO heuristic: `len(query) == 4 and query.isalpha()` — if true, attempt ICAO lookup first
- [ ] 1.3 ICAO lookup: open `airports.csv` via `importlib.resources.files("planemapper.data").joinpath("airports.csv").open("r", encoding="utf-8")`; parse with `csv.DictReader`; search for row where `row["ident"] == query`; return `(float(row["latitude_deg"]), float(row["longitude_deg"]), row["name"])`
- [ ] 1.4 If ICAO lookup finds no match, raise `ValueError("ICAO code not found — try an address instead")`
- [ ] 1.5 Non-ICAO path: call `requests.get("https://nominatim.openstreetmap.org/search", params={"q": query, "format": "json", "limit": 1}, headers={"User-Agent": "planemapper/0.1 (https://github.com/football2801/planeMapper)"}, timeout=10)`
- [ ] 1.6 Parse Nominatim response: if `results` list is non-empty, return `(float(results[0]["lat"]), float(results[0]["lon"]), results[0]["display_name"])`
- [ ] 1.7 If Nominatim returns an empty list, raise `ValueError("Location not found — try a different search term")`
- [ ] 1.8 Annotate the function signature: `def resolve(query: str) -> tuple[float, float, str]`
- [x] Task 1: Implement `location.resolve(query)` in `src/planemapper/provisioning/location.py` (AC: #1, #2, #3, #4)
- [x] 1.1 Normalise the input: `query = query.strip().upper()`
- [x] 1.2 Detect ICAO heuristic: `len(query) == 4 and query.isalpha()` — if true, attempt ICAO lookup first
- [x] 1.3 ICAO lookup: open `airports.csv` via `importlib.resources.files("planemapper.data").joinpath("airports.csv").open("r", encoding="utf-8")`; parse with `csv.DictReader`; search for row where `row["ident"] == query`; return `(float(row["latitude_deg"]), float(row["longitude_deg"]), row["name"])`
- [x] 1.4 If ICAO lookup finds no match, raise `ValueError("ICAO code not found — try an address instead")`
- [x] 1.5 Non-ICAO path: call `requests.get("https://nominatim.openstreetmap.org/search", params={"q": query, "format": "json", "limit": 1}, headers={"User-Agent": "planemapper/0.1 (https://github.com/football2801/planeMapper)"}, timeout=10)`
- [x] 1.6 Parse Nominatim response: if `results` list is non-empty, return `(float(results[0]["lat"]), float(results[0]["lon"]), results[0]["display_name"])`
- [x] 1.7 If Nominatim returns an empty list, raise `ValueError("Location not found — try a different search term")`
- [x] 1.8 Annotate the function signature: `def resolve(query: str) -> tuple[float, float, str]`
- [ ] Task 2: Add `POST /find-location` route to `src/planemapper/provisioning/portal.py` (AC: #1, #2, #3, #4)
- [ ] 2.1 Import `location` from `planemapper.provisioning` at the top of `portal.py`
- [ ] 2.2 Implement `POST /find-location` — read `request.form["location"]` field
- [ ] 2.3 Call `location.resolve(query)` inside a `try/except ValueError`
- [ ] 2.4 On success: return updated form HTML showing the resolved name and coordinates (e.g. a confirmation section with `lat`, `lon`, `name` values visible) and hidden fields pre-populated with `lat`/`lon` for subsequent form submit
- [ ] 2.5 On `ValueError`: return updated form HTML with the error message displayed inline (no 4xx status — keep the form usable)
- [ ] 2.6 Annotate the route function with return type `str | Response`
- [x] Task 2: Add `POST /find-location` route to `src/planemapper/provisioning/portal.py` (AC: #1, #2, #3, #4)
- [x] 2.1 Import `location` from `planemapper.provisioning` at the top of `portal.py`
- [x] 2.2 Implement `POST /find-location` — read `request.form["location"]` field
- [x] 2.3 Call `location.resolve(query)` inside a `try/except ValueError`
- [x] 2.4 On success: return updated form HTML showing the resolved name and coordinates (e.g. a confirmation section with `lat`, `lon`, `name` values visible) and hidden fields pre-populated with `lat`/`lon` for subsequent form submit
- [x] 2.5 On `ValueError`: return updated form HTML with the error message displayed inline (no 4xx status — keep the form usable)
- [x] 2.6 Annotate the route function with return type `str | Response`
- [ ] Task 3: Write tests in `tests/provisioning/test_location.py` (AC: #1, #2, #3, #4, #5)
- [ ] 3.1 Test ICAO lookup hit: call `resolve("EGLL")` against the real `airports.csv`; assert returned `(lat, lon, name)` is a `tuple[float, float, str]` with plausible UK coordinates
- [ ] 3.2 Test ICAO lookup miss: call `resolve("ZZZZ")`; assert `ValueError` is raised with message `"ICAO code not found — try an address instead"`
- [ ] 3.3 Test Nominatim success: patch `planemapper.provisioning.location.requests.get` with `unittest.mock.patch`; mock return value `.json()` returns `[{"lat": "51.5", "lon": "-0.1", "display_name": "London"}]`; call `resolve("OX1 1AA")`; assert `(51.5, -0.1, "London")` returned
- [ ] 3.4 Test Nominatim empty response: patch `requests.get` as above but `.json()` returns `[]`; call `resolve("nonsense query")`; assert `ValueError` is raised with message `"Location not found — try a different search term"`
- [ ] 3.5 Assert the mock was called exactly once with the expected URL and `User-Agent` header (confirms no real HTTP in CI)
- [x] Task 3: Write tests in `tests/provisioning/test_location.py` (AC: #1, #2, #3, #4, #5)
- [x] 3.1 Test ICAO lookup hit: call `resolve("EGLL")` against the real `airports.csv`; assert returned `(lat, lon, name)` is a `tuple[float, float, str]` with plausible UK coordinates
- [x] 3.2 Test ICAO lookup miss: call `resolve("ZZZZ")`; assert `ValueError` is raised with message `"ICAO code not found — try an address instead"`
- [x] 3.3 Test Nominatim success: patch `planemapper.provisioning.location.requests.get` with `unittest.mock.patch`; mock return value `.json()` returns `[{"lat": "51.5", "lon": "-0.1", "display_name": "London"}]`; call `resolve("OX1 1AA")`; assert `(51.5, -0.1, "London")` returned
- [x] 3.4 Test Nominatim empty response: patch `requests.get` as above but `.json()` returns `[]`; call `resolve("nonsense query")`; assert `ValueError` is raised with message `"Location not found — try a different search term"`
- [x] 3.5 Assert the mock was called exactly once with the expected URL and `User-Agent` header (confirms no real HTTP in CI)
- [ ] Task 4: Update portal tests in `tests/provisioning/test_portal.py` (AC: #1, #2, #3, #4)
- [ ] 4.1 Add test for `POST /find-location` with a successful resolve (mock `location.resolve` to return `(51.5, -0.1, "London")`); assert 200 and that the response body contains the resolved name and coordinates
- [ ] 4.2 Add test for `POST /find-location` with a `ValueError` from `location.resolve`; assert 200 and that the response body contains the error message text
- [x] Task 4: Update portal tests in `tests/provisioning/test_portal.py` (AC: #1, #2, #3, #4)
- [x] 4.1 Add test for `POST /find-location` with a successful resolve (mock `location.resolve` to return `(51.5, -0.1, "London")`); assert 200 and that the response body contains the resolved name and coordinates
- [x] 4.2 Add test for `POST /find-location` with a `ValueError` from `location.resolve`; assert 200 and that the response body contains the error message text
- [ ] Task 5: Run quality gates
- [ ] 5.1 `pytest tests/` — all tests pass, 0 failures
- [ ] 5.2 `ruff check .` — zero violations
- [ ] 5.3 `ruff format --check .` — no formatting issues
- [x] Task 5: Run quality gates
- [x] 5.1 `pytest tests/` — all tests pass, 0 failures
- [x] 5.2 `ruff check .` — zero violations
- [x] 5.3 `ruff format --check .` — no formatting issues
## Dev Notes