151 lines
4.7 KiB
Python
151 lines
4.7 KiB
Python
|
|
"""GPSJAM GPS-interference overlay: level mapping, CSV→GeoJSON, API contract."""
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
|
||
|
|
from live_layers import gpsjam_csv_to_geojson, gpsjam_level, overlay_catalog, _cache
|
||
|
|
from main import app
|
||
|
|
|
||
|
|
BASE = "http://test"
|
||
|
|
|
||
|
|
# A valid H3 resolution-4 cell id (the payload hex column carries these).
|
||
|
|
HEX_A = "8400c57ffffffff"
|
||
|
|
|
||
|
|
CSV = (
|
||
|
|
"hex,count_good_aircraft,count_bad_aircraft\n"
|
||
|
|
f"{HEX_A},0,20\n" # 100*(20-1)/20 = 95 -> high
|
||
|
|
f"{HEX_A},8,2\n" # 100*(2-1)/10 = 10 -> medium
|
||
|
|
f"{HEX_A},98,2\n" # 100*(2-1)/100 = 1 -> low
|
||
|
|
f"{HEX_A},100,0\n" # bad == 0 -> dropped
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def _get(path: str) -> httpx.Response:
|
||
|
|
transport = httpx.ASGITransport(app=app)
|
||
|
|
async with httpx.AsyncClient(transport=transport, base_url=BASE) as client:
|
||
|
|
return await client.get(path)
|
||
|
|
|
||
|
|
|
||
|
|
def test_gpsjam_level_thresholds():
|
||
|
|
assert gpsjam_level(0.0) == "low"
|
||
|
|
assert gpsjam_level(2.0) == "low"
|
||
|
|
assert gpsjam_level(2.1) == "medium"
|
||
|
|
assert gpsjam_level(10.0) == "medium"
|
||
|
|
assert gpsjam_level(10.1) == "high"
|
||
|
|
assert gpsjam_level(95.0) == "high"
|
||
|
|
|
||
|
|
|
||
|
|
def test_gpsjam_csv_to_geojson_levels_and_drop_zero_bad():
|
||
|
|
fc = gpsjam_csv_to_geojson(CSV)
|
||
|
|
assert fc["type"] == "FeatureCollection"
|
||
|
|
assert len(fc["features"]) == 3 # bad==0 row dropped
|
||
|
|
levels = [f["properties"]["level"] for f in fc["features"]]
|
||
|
|
assert levels == ["high", "medium", "low"]
|
||
|
|
for f in fc["features"]:
|
||
|
|
geom = f["geometry"]
|
||
|
|
assert geom["type"] == "Polygon"
|
||
|
|
ring = geom["coordinates"][0]
|
||
|
|
assert len(ring) == 7 # 6 verts + closing point
|
||
|
|
assert ring[0] == ring[-1]
|
||
|
|
assert f["properties"]["hex"] == HEX_A
|
||
|
|
assert set(f["properties"]).issuperset({"level", "percent_bad", "good", "bad", "hex"})
|
||
|
|
|
||
|
|
|
||
|
|
def test_gpsjam_csv_skips_malformed_rows():
|
||
|
|
bad_csv = "hex,count_good_aircraft,count_bad_aircraft\n" \
|
||
|
|
",1,5\n" \
|
||
|
|
f"{HEX_A},x,5\n" \
|
||
|
|
f"{HEX_A},1,notanint\n" \
|
||
|
|
"not_a_cell,1,5\n"
|
||
|
|
fc = gpsjam_csv_to_geojson(bad_csv)
|
||
|
|
assert fc["features"] == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_overlay_catalog_has_gpsjam_stub():
|
||
|
|
entry = overlay_catalog()["gpsjam"]
|
||
|
|
assert entry["kind"] == "geojson"
|
||
|
|
assert entry["endpoint"] == "/api/map/gpsjam"
|
||
|
|
assert "GPSJAM" in entry["attribution"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_map_gpsjam_returns_featurecollection(monkeypatch):
|
||
|
|
async def fake_fetch(date):
|
||
|
|
return {"type": "FeatureCollection", "features": [{"type": "Feature"}]}
|
||
|
|
|
||
|
|
monkeypatch.setattr("main.fetch_gpsjam", fake_fetch)
|
||
|
|
resp = asyncio.run(_get("/api/map/gpsjam?date=2026-08-28"))
|
||
|
|
assert resp.status_code == 200
|
||
|
|
assert resp.json()["type"] == "FeatureCollection"
|
||
|
|
assert "max-age" in (resp.headers.get("cache-control") or "").lower()
|
||
|
|
|
||
|
|
|
||
|
|
def test_map_gpsjam_rejects_bad_date():
|
||
|
|
resp = asyncio.run(_get("/api/map/gpsjam?date=08-28-2026"))
|
||
|
|
assert resp.status_code == 422
|
||
|
|
|
||
|
|
|
||
|
|
def test_map_gpsjam_unavailable_on_404(monkeypatch):
|
||
|
|
import httpx as _httpx
|
||
|
|
|
||
|
|
async def fake_fetch(date):
|
||
|
|
exc = _httpx.HTTPStatusError(
|
||
|
|
"404", request=_httpx.Request("GET", "http://x"), response=_httpx.Response(404)
|
||
|
|
)
|
||
|
|
raise exc
|
||
|
|
|
||
|
|
monkeypatch.setattr("main.fetch_gpsjam", fake_fetch)
|
||
|
|
resp = asyncio.run(_get("/api/map/gpsjam?date=2026-08-28"))
|
||
|
|
assert resp.status_code == 200
|
||
|
|
body = resp.json()
|
||
|
|
assert body["error"] == "unavailable"
|
||
|
|
assert body["href"] == "https://gpsjam.org/"
|
||
|
|
|
||
|
|
|
||
|
|
def test_map_gpsjam_unavailable_on_empty_features(monkeypatch):
|
||
|
|
async def fake_fetch(date):
|
||
|
|
return {"type": "FeatureCollection", "features": []}
|
||
|
|
|
||
|
|
monkeypatch.setattr("main.fetch_gpsjam", fake_fetch)
|
||
|
|
resp = asyncio.run(_get("/api/map/gpsjam?date=2026-08-28"))
|
||
|
|
assert resp.status_code == 200
|
||
|
|
assert resp.json()["error"] == "unavailable"
|
||
|
|
|
||
|
|
|
||
|
|
def test_fetch_gpsjam_hits_http_once_within_ttl(monkeypatch):
|
||
|
|
_cache.clear()
|
||
|
|
hits = {"n": 0}
|
||
|
|
|
||
|
|
class FakeResp:
|
||
|
|
text = CSV
|
||
|
|
|
||
|
|
def raise_for_status(self):
|
||
|
|
pass
|
||
|
|
|
||
|
|
class FakeClient:
|
||
|
|
def __init__(self, **kw):
|
||
|
|
pass
|
||
|
|
|
||
|
|
async def __aenter__(self):
|
||
|
|
return self
|
||
|
|
|
||
|
|
async def __aexit__(self, *exc):
|
||
|
|
return False
|
||
|
|
|
||
|
|
async def get(self, url):
|
||
|
|
hits["n"] += 1
|
||
|
|
assert url == "https://gpsjam.org/data/2026-08-28-h3_4.csv"
|
||
|
|
return FakeResp()
|
||
|
|
|
||
|
|
monkeypatch.setattr("live_layers.httpx.AsyncClient", FakeClient)
|
||
|
|
monkeypatch.setattr("live_layers._http", None)
|
||
|
|
|
||
|
|
from live_layers import fetch_gpsjam
|
||
|
|
|
||
|
|
fc1 = asyncio.run(fetch_gpsjam("2026-08-28"))
|
||
|
|
fc2 = asyncio.run(fetch_gpsjam("2026-08-28"))
|
||
|
|
assert len(fc1["features"]) == 3
|
||
|
|
assert fc2 == fc1
|
||
|
|
assert hits["n"] == 1
|
||
|
|
_cache.clear()
|