mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
* Add navi-geo service (extraction #6) Faithful port of recon's geocode/reverse family to a new :8426 service: GET /api/geocode?q=&limit=&lat=&lon=&zoom= Photon-first ranked search GET /api/reverse?lat=&lon= reverse geocode (Photon) GET /api/reverse/<lat>/<lon> reverse enrichment bundle (Central) Ported modules: geocode.py (engine), netsyms.py (address SQLite), dem.py (planet-DEM reader), address_book.py (reader copy), and the three handlers + four bundle helpers from netsyms_api.py. All three routes public, behaviour- identical to recon. Behaviour-changing edges (both pre-decided in Phase A/B, called out in the PR): - landclass: in-process call replaced with HTTP GET to navi-landclass :8424, reading .summary (the same most-specific unit-name string). First navi→navi edge after landclass itself. - hardcoded paths/URLs → env vars (PHOTON_URL, NAVI_NETSYMS_DB, NAVI_TIMEZONE_DB, NAVI_DEM_PMTILES, NAVI_ADDRESS_BOOK_YAML, NAVI_LANDCLASS_URL); rerank trace log opt-in (NAVI_GEO_RERANK_TRACE_LOG, default off — recon always wrote /tmp). No secrets in this service: PADUS_DB_* disappears because landclass is HTTP- delegated (Phase A §10). Address book uses Option B (shared-file read), the same pattern navi-contacts already uses. Bundle 9-key contract preserved exactly (name/city/county/state/country/ postal_code/timezone/landclass/elevation_m), same null-on-component-failure semantics, same in-memory TTLCache(10_000, 86_400) per worker. Tests: 28 passing, 1 skipped (real timezone DB, off-box). Ported the 9 recon reverse-bundle tests + added the HTTP-landclass coupling tests + hermetic geocode reranker/intent-classifier tests (recon's geocode_test.py was a live smoke test). Adds usaddress/rapidfuzz/cachetools/shapely/numpy/Pillow/pmtiles to deps. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * PR #6 review fixes 1. Rename geocode._setup_trace_logger → setup_trace_logger (public hook) 2. Hoist `import requests as http_requests` to module level in geo_route.py 3. Wire netsyms.health() into admin.py (enriches the netsyms filesystem entry with row_count/file_size_bytes/indexed_countries; no shared-builder change) 4. Fix misleading LANDCLASS_TIMEOUT_S comment (recon had no timeout) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: zvx-echo6 <mj@k7zvx.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
193 lines
8.1 KiB
Python
193 lines
8.1 KiB
Python
"""Tests for the /api/reverse/<lat>/<lon> enrichment bundle (navi_geo.geo_route).
|
|
|
|
Ported from recon's reverse_bundle_test.py (9 tests). Photon/DEM/timezone are
|
|
mocked the same way; the in-process landclass mock becomes a mock of the
|
|
HTTP-delegated _reverse_landclass (Phase A §5). Two added tests exercise the
|
|
real navi-landclass HTTP client mapping (summary -> bundle['landclass']) and the
|
|
landclass-HTTP-failure -> null path. One timezone test exercises the real
|
|
SpatiaLite DB when present.
|
|
"""
|
|
import os
|
|
|
|
import pytest
|
|
|
|
import services.navi_geo.geo_route as geo_route
|
|
import services.navi_geo.landclass_client as landclass_client
|
|
from services.navi_geo.app import create_app
|
|
|
|
EXPECTED_KEYS = set(geo_route._BUNDLE_KEYS)
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
return create_app().test_client()
|
|
|
|
|
|
def _patch_all(monkeypatch, *, photon, timezone, landclass, elevation):
|
|
monkeypatch.setattr(geo_route, '_reverse_photon', photon)
|
|
monkeypatch.setattr(geo_route, '_reverse_timezone', timezone)
|
|
monkeypatch.setattr(geo_route, '_reverse_landclass', landclass)
|
|
monkeypatch.setattr(geo_route, '_reverse_elevation', elevation)
|
|
|
|
|
|
def test_happy_path(client, monkeypatch):
|
|
_patch_all(
|
|
monkeypatch,
|
|
photon=lambda lat, lon: {
|
|
'name': 'Where you are', 'city': 'Boise', 'county': 'Ada',
|
|
'state': 'Idaho', 'country': 'United States', 'postal_code': '83701'},
|
|
timezone=lambda lat, lon: 'America/Boise',
|
|
landclass=lambda lat, lon: 'Boise National Forest',
|
|
elevation=lambda lat, lon: 824,
|
|
)
|
|
resp = client.get('/api/reverse/43.6150/-116.2023')
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert set(data.keys()) == EXPECTED_KEYS
|
|
assert data['city'] == 'Boise' and data['timezone'] == 'America/Boise'
|
|
assert data['landclass'] == 'Boise National Forest' and data['elevation_m'] == 824
|
|
|
|
|
|
def test_negative_and_integer_coords_parse(client, monkeypatch):
|
|
# Regression: Flask's <float:> converter would 404 these; manual parse must not.
|
|
_patch_all(monkeypatch, photon=lambda lat, lon: {}, timezone=lambda lat, lon: None,
|
|
landclass=lambda lat, lon: None, elevation=lambda lat, lon: None)
|
|
for path in ('/api/reverse/43.6/-116.2', '/api/reverse/43/-116'):
|
|
resp = client.get(path)
|
|
assert resp.status_code == 200, f"{path} -> {resp.status_code}"
|
|
assert set(resp.get_json().keys()) == EXPECTED_KEYS
|
|
|
|
|
|
def test_partial_failure_returns_200_with_nulls(client, monkeypatch):
|
|
def boom(lat, lon):
|
|
raise RuntimeError('down')
|
|
_patch_all(monkeypatch, photon=boom, timezone=lambda lat, lon: 'America/Boise',
|
|
landclass=boom, elevation=lambda lat, lon: 824)
|
|
resp = client.get('/api/reverse/43.6150/-116.2023')
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert set(data.keys()) == EXPECTED_KEYS
|
|
assert data['name'] is None and data['city'] is None # photon failed -> nulls
|
|
assert data['landclass'] is None # landclass failed -> null
|
|
assert data['timezone'] == 'America/Boise' and data['elevation_m'] == 824
|
|
|
|
|
|
def test_ocean_point_mostly_null(client, monkeypatch):
|
|
_patch_all(monkeypatch, photon=lambda lat, lon: {}, timezone=lambda lat, lon: 'Etc/GMT+2',
|
|
landclass=lambda lat, lon: None, elevation=lambda lat, lon: 0)
|
|
resp = client.get('/api/reverse/0.0/-30.0')
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert set(data.keys()) == EXPECTED_KEYS
|
|
assert data['city'] is None and data['country'] is None and data['landclass'] is None
|
|
|
|
|
|
def test_invalid_input_400(client):
|
|
for path in ('/api/reverse/9999/0', '/api/reverse/0/9999', '/api/reverse/abc/0'):
|
|
resp = client.get(path)
|
|
assert resp.status_code == 400, f"{path} -> {resp.status_code}"
|
|
|
|
|
|
def test_cache_hit_serves_without_recompute(client, monkeypatch):
|
|
calls = {'n': 0}
|
|
|
|
def counting_photon(lat, lon):
|
|
calls['n'] += 1
|
|
return {'name': 'X'}
|
|
_patch_all(monkeypatch, photon=counting_photon, timezone=lambda lat, lon: None,
|
|
landclass=lambda lat, lon: None, elevation=lambda lat, lon: None)
|
|
client.get('/api/reverse/12.3456/-65.4321')
|
|
client.get('/api/reverse/12.3456/-65.4321') # same key (rounded) -> cached
|
|
assert calls['n'] == 1, f"expected 1 compute, got {calls['n']}"
|
|
|
|
|
|
def test_real_timezone_db(monkeypatch):
|
|
path = geo_route.tz_db_path()
|
|
if not os.path.exists(path):
|
|
pytest.skip("real timezone test (timezones.sqlite not present)")
|
|
assert geo_route._reverse_timezone(43.6150, -116.2023) == 'America/Boise'
|
|
assert geo_route._reverse_timezone(40.7128, -74.0060) == 'America/New_York'
|
|
|
|
|
|
def test_elevation_from_dem_reader_mock(client, monkeypatch):
|
|
# elevation_m comes from DEMReader.sample_point; other components stubbed null.
|
|
class FakeDEM:
|
|
def __init__(self):
|
|
self.called = 0
|
|
|
|
def sample_point(self, lat, lon):
|
|
self.called += 1
|
|
return 824
|
|
fake = FakeDEM()
|
|
monkeypatch.setattr(geo_route, '_DEM', fake)
|
|
monkeypatch.setattr(geo_route, '_reverse_photon', lambda lat, lon: {})
|
|
monkeypatch.setattr(geo_route, '_reverse_timezone', lambda lat, lon: None)
|
|
monkeypatch.setattr(geo_route, '_reverse_landclass', lambda lat, lon: None)
|
|
resp = client.get('/api/reverse/43.6150/-116.2023')
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert set(data.keys()) == EXPECTED_KEYS
|
|
assert data['elevation_m'] == 824
|
|
assert fake.called == 1
|
|
|
|
|
|
def test_elevation_dem_unavailable(client, monkeypatch):
|
|
# DEMReader failed to init at startup (_DEM is None) -> elevation_m null, 200.
|
|
monkeypatch.setattr(geo_route, '_DEM', None)
|
|
monkeypatch.setattr(geo_route, '_reverse_photon', lambda lat, lon: {})
|
|
monkeypatch.setattr(geo_route, '_reverse_timezone', lambda lat, lon: None)
|
|
monkeypatch.setattr(geo_route, '_reverse_landclass', lambda lat, lon: None)
|
|
resp = client.get('/api/reverse/43.6150/-116.2023')
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()['elevation_m'] is None
|
|
|
|
|
|
# ── Added: the navi-landclass HTTP coupling (Phase A §5 / Phase B locked) ──
|
|
|
|
class _FakeResp:
|
|
def __init__(self, status_code=200, json_data=None):
|
|
self.status_code = status_code
|
|
self._json = json_data or {}
|
|
|
|
def raise_for_status(self):
|
|
if self.status_code >= 400:
|
|
raise RuntimeError(f'HTTP {self.status_code}')
|
|
|
|
def json(self):
|
|
return self._json
|
|
|
|
|
|
def test_landclass_http_summary_maps_into_bundle(client, monkeypatch):
|
|
# navi-landclass returns the full dict; navi-geo reads .summary into landclass.
|
|
captured = {}
|
|
|
|
def fake_get(url, params=None, timeout=None):
|
|
captured['url'] = url
|
|
captured['params'] = params
|
|
return _FakeResp(200, {
|
|
'lat': params['lat'], 'lon': params['lon'],
|
|
'classifications': [{'unit_name': 'Boise National Forest'}],
|
|
'count': 1, 'is_public': True, 'is_private': False,
|
|
'summary': 'Boise National Forest',
|
|
})
|
|
monkeypatch.setattr(landclass_client.requests, 'get', fake_get)
|
|
monkeypatch.setattr(geo_route, '_reverse_photon', lambda lat, lon: {})
|
|
monkeypatch.setattr(geo_route, '_reverse_timezone', lambda lat, lon: None)
|
|
monkeypatch.setattr(geo_route, '_reverse_elevation', lambda lat, lon: None)
|
|
resp = client.get('/api/reverse/43.6150/-116.2023')
|
|
data = resp.get_json()
|
|
assert data['landclass'] == 'Boise National Forest' # the summary string only
|
|
assert captured['url'].endswith('/api/landclass')
|
|
assert captured['params'] == {'lat': 43.615, 'lon': -116.2023}
|
|
|
|
|
|
def test_landclass_http_failure_yields_null(client, monkeypatch):
|
|
def boom_get(url, params=None, timeout=None):
|
|
raise RuntimeError('connection refused')
|
|
monkeypatch.setattr(landclass_client.requests, 'get', boom_get)
|
|
monkeypatch.setattr(geo_route, '_reverse_photon', lambda lat, lon: {})
|
|
monkeypatch.setattr(geo_route, '_reverse_timezone', lambda lat, lon: None)
|
|
monkeypatch.setattr(geo_route, '_reverse_elevation', lambda lat, lon: None)
|
|
resp = client.get('/api/reverse/43.6150/-116.2023')
|
|
assert resp.status_code == 200 # never 5xx
|
|
assert resp.get_json()['landclass'] is None
|