Add backend test suite; gate direct pushes; serialize LAN deploys (#41)
- backend/tests: 74 hermetic tests (no network, no repo data//logs/ writes) covering grid snapping/round-trips, grading percentiles/bands/windows/ dry streaks, the places index (norm, one-edit matchers, search, corrections), the derived store (token validity, cache=False, degraded mode), and route-level API tests over a faked climate layer — routing, validation, ETag/304 revalidation, store replay, the /cell bundle, and the v1/v2 aliases. The API tests would have caught the /place AttributeError regression. - requirements-dev.txt + make test (venv prefers uv-pinned 3.12, matching deploy-dev.sh — pyarrow wheels stop at 3.12 and some pyenv builds lack sqlite). - CI: extract the build job into a reusable build.yml, add the test run and an API health probe (page-only curl can't catch route wiring faults); deploy-dev.yml now runs the same build gate before deploying direct pushes, which previously deployed with no CI at all. - Deploys serialize under one dev-lan-deploy concurrency group across both workflows (previously per-PR groups could interleave two deploys to the same checkout), and are never cancelled mid-restart. - deploy-dev.sh health check also probes /api/v2/place — best-effort externals mean a failure there is a genuine server bug.
This commit is contained in:
parent
686ac7afaf
commit
4ac5323375
7 changed files with 611 additions and 0 deletions
3
requirements-dev.txt
Normal file
3
requirements-dev.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Test/dev-only dependencies, layered on the runtime set.
|
||||
-r requirements.txt
|
||||
pytest==8.4.1
|
||||
94
tests/conftest.py
Normal file
94
tests/conftest.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Shared test setup: import path, offline guards, and synthetic weather data.
|
||||
|
||||
Everything here keeps the suite hermetic — no Open-Meteo, no Nominatim, no
|
||||
GeoNames download, and no writes into the repo's data/ or logs/ folders.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
BACKEND = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, BACKEND)
|
||||
|
||||
import places # noqa: E402
|
||||
|
||||
# app.py calls places.start_loading() at import; pretend it already ran so no
|
||||
# background GeoNames download starts. Tests build their own tiny index.
|
||||
places._load_started = True
|
||||
|
||||
_TMP = tempfile.mkdtemp(prefix="thermograph-tests-")
|
||||
|
||||
import store # noqa: E402
|
||||
|
||||
# Keep derived-store writes out of the repo's data/ folder. Individual store
|
||||
# tests re-point this per test; everything else shares one throwaway DB.
|
||||
store.DB_PATH = os.path.join(_TMP, "store.sqlite")
|
||||
|
||||
import audit # noqa: E402
|
||||
|
||||
audit.AUDIT_DIR = os.path.join(_TMP, "logs", "audit")
|
||||
audit.ERROR_DIR = os.path.join(_TMP, "logs", "errors")
|
||||
|
||||
|
||||
def make_history(years: int = 20, end: str | None = None, seed: int = 7) -> pd.DataFrame:
|
||||
"""A plausible daily record (tmax/tmin/precip + doy), matching the columns
|
||||
and dtypes climate.get_history returns for an older cache."""
|
||||
end_ts = pd.Timestamp(end) if end else pd.Timestamp.today().normalize() - pd.Timedelta(days=6)
|
||||
dates = pd.date_range(end_ts - pd.DateOffset(years=years), end_ts, freq="D")
|
||||
rng = np.random.default_rng(seed)
|
||||
doy = dates.dayofyear.to_numpy()
|
||||
seasonal = 55 + 30 * np.sin((doy - 100) / 366.0 * 2 * np.pi)
|
||||
tmax = seasonal + rng.normal(0, 8, len(dates))
|
||||
tmin = tmax - 15 + rng.normal(0, 3, len(dates))
|
||||
precip = np.where(rng.random(len(dates)) < 0.3, rng.gamma(1.5, 0.2, len(dates)), 0.0)
|
||||
df = pd.DataFrame({
|
||||
"date": dates,
|
||||
"tmax": np.round(tmax, 1),
|
||||
"tmin": np.round(tmin, 1),
|
||||
"precip": np.round(precip, 2),
|
||||
})
|
||||
df["doy"] = df["date"].dt.dayofyear.astype("int16")
|
||||
return df
|
||||
|
||||
|
||||
def make_recent(history: pd.DataFrame, future_days: int = 7, seed: int = 11) -> pd.DataFrame:
|
||||
"""A recent+forecast bundle: from a couple of weeks before the archive's end
|
||||
through `future_days` past today — the shape climate.get_recent_forecast returns."""
|
||||
today = pd.Timestamp.today().normalize()
|
||||
start = pd.Timestamp(history["date"].max()) - pd.Timedelta(days=14)
|
||||
dates = pd.date_range(start, today + pd.Timedelta(days=future_days), freq="D")
|
||||
rng = np.random.default_rng(seed)
|
||||
doy = dates.dayofyear.to_numpy()
|
||||
seasonal = 55 + 30 * np.sin((doy - 100) / 366.0 * 2 * np.pi)
|
||||
tmax = seasonal + rng.normal(0, 8, len(dates))
|
||||
df = pd.DataFrame({
|
||||
"date": dates,
|
||||
"tmax": np.round(tmax, 1),
|
||||
"tmin": np.round(tmax - 15, 1),
|
||||
"precip": np.where(rng.random(len(dates)) < 0.3, 0.15, 0.0),
|
||||
})
|
||||
df["doy"] = df["date"].dt.dayofyear.astype("int16")
|
||||
return df
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def history():
|
||||
return make_history()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def recent(history):
|
||||
return make_recent(history)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_store(tmp_path, monkeypatch):
|
||||
"""store.py against a fresh database file, isolated per test."""
|
||||
monkeypatch.setattr(store, "DB_PATH", str(tmp_path / "store.sqlite"))
|
||||
monkeypatch.setattr(store, "_local", threading.local())
|
||||
return store
|
||||
138
tests/test_api.py
Normal file
138
tests/test_api.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""Route-level tests over the FastAPI app with the weather/geocode layer faked —
|
||||
they exercise the real routing, validation, derived-store and ETag plumbing, and
|
||||
would catch wiring regressions (e.g. a handler calling a deleted helper)."""
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app as appmod
|
||||
import climate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, history, recent):
|
||||
monkeypatch.setattr(climate, "get_history",
|
||||
lambda cell: (history.copy(), {"cached": True, "cache_age_days": 3}))
|
||||
monkeypatch.setattr(climate, "get_recent_forecast", lambda cell: recent.copy())
|
||||
monkeypatch.setattr(climate, "load_cached_history", lambda cell: history.copy())
|
||||
monkeypatch.setattr(climate, "recent_stamp", lambda cell_id: "rs-test")
|
||||
monkeypatch.setattr(climate, "reverse_geocode", lambda lat, lon: "Testville, Washington")
|
||||
return TestClient(appmod.app)
|
||||
|
||||
|
||||
Q = {"lat": 47.6062, "lon": -122.3321}
|
||||
|
||||
|
||||
def test_place_serves_any_point_worldwide(client):
|
||||
for q in (Q, {"lat": 48.8566, "lon": 2.3522}, {"lat": -33.8688, "lon": 151.2093}):
|
||||
r = client.get("/thermograph/api/v2/place", params=q)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["place"] == "Testville, Washington"
|
||||
assert set(r.json()["cell"]) == {"center_lat", "center_lon"}
|
||||
|
||||
|
||||
def test_grade_shape_and_conditional_revalidation(client, history):
|
||||
r = client.get("/thermograph/api/v2/grade", params=Q)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["place"] == "Testville, Washington"
|
||||
assert body["climatology"]["tmax"] is not None
|
||||
days = [d["date"] for d in body["recent"]]
|
||||
assert days == sorted(days, reverse=True) # newest first
|
||||
assert body["recent"][0]["tmax"]["grade"]
|
||||
|
||||
etag = r.headers["etag"]
|
||||
r304 = client.get("/thermograph/api/v2/grade", params=Q,
|
||||
headers={"If-None-Match": etag})
|
||||
assert r304.status_code == 304 and r304.headers["etag"] == etag
|
||||
|
||||
# Without the validator the derived store replays the exact same bytes.
|
||||
r2 = client.get("/thermograph/api/v2/grade", params=Q)
|
||||
assert r2.status_code == 200 and r2.content == r.content
|
||||
|
||||
|
||||
def test_grade_is_aliased_across_api_versions(client):
|
||||
for prefix in ("api", "api/v1", "api/v2"):
|
||||
assert client.get(f"/thermograph/{prefix}/grade", params=Q).status_code == 200
|
||||
|
||||
|
||||
def test_query_validation_rejects_out_of_range(client):
|
||||
assert client.get("/thermograph/api/v2/grade",
|
||||
params={"lat": 999, "lon": 0}).status_code == 422
|
||||
assert client.get("/thermograph/api/v2/grade",
|
||||
params={**Q, "days": 0}).status_code == 422
|
||||
|
||||
|
||||
def test_day_detail_and_ladders(client, history):
|
||||
r = client.get("/thermograph/api/v2/day", params=Q)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["latest"] == pd.Timestamp(history["date"].max()).date().isoformat()
|
||||
tmax = body["detail"]["metrics"]["tmax"]
|
||||
assert tmax["ladder"]["tiers"][0]["c"] == "rec-hot"
|
||||
assert tmax["obs"]["grade"]
|
||||
|
||||
|
||||
def test_day_maps_rate_limit_to_503(client, monkeypatch):
|
||||
def rate_limited(cell):
|
||||
raise RuntimeError("Open-Meteo request failed (429): rate-limited")
|
||||
monkeypatch.setattr(climate, "get_history", rate_limited)
|
||||
r = client.get("/thermograph/api/v2/day", params=Q)
|
||||
assert r.status_code == 503
|
||||
assert "rate-limited" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_calendar_compact_range(client, history):
|
||||
r = client.get("/thermograph/api/v2/calendar", params={**Q, "months": 2})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["range"]["end"] == pd.Timestamp(history["date"].max()).date().isoformat()
|
||||
day = body["days"][0]
|
||||
assert {"date", "dsr", "tmax", "tmin", "precip"} <= set(day)
|
||||
assert set(day["tmax"]) == {"v", "pct", "c", "g"}
|
||||
|
||||
|
||||
def test_forecast_grades_future_days(client):
|
||||
r = client.get("/thermograph/api/v2/forecast", params=Q)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["forecast"] is True
|
||||
days = [d["date"] for d in body["recent"]]
|
||||
assert days and days == sorted(days, reverse=True) # furthest-out first
|
||||
assert min(days) > body["target_date"] # strictly future
|
||||
|
||||
|
||||
def test_cell_bundle_matches_per_view_payloads(client):
|
||||
r = client.get("/thermograph/api/v2/cell", params=Q)
|
||||
assert r.status_code == 200
|
||||
slices = r.json()["slices"]
|
||||
assert set(slices) == {"calendar", "grade", "forecast", "day"}
|
||||
for s in slices.values():
|
||||
assert s["etag"] and s["data"]
|
||||
# The grade slice must be byte-for-byte what /grade serves (same store row).
|
||||
grade = client.get("/thermograph/api/v2/grade", params=Q)
|
||||
assert grade.json() == slices["grade"]["data"]
|
||||
assert grade.headers["etag"] == slices["grade"]["etag"]
|
||||
|
||||
r304 = client.get("/thermograph/api/v2/cell", params=Q,
|
||||
headers={"If-None-Match": r.headers["etag"]})
|
||||
assert r304.status_code == 304
|
||||
|
||||
|
||||
def test_suggest_falls_back_to_upstream_geocoder(client, monkeypatch):
|
||||
upstream = [{"name": "Seattle", "admin1": "Washington", "country": "United States",
|
||||
"country_code": "US", "lat": 47.6, "lon": -122.33, "population": 737015}]
|
||||
monkeypatch.setattr(climate, "geocode", lambda q, count=5: list(upstream))
|
||||
r = client.get("/thermograph/api/v2/suggest", params={"q": "seattle-fallback-probe"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["results"][0]["name"] == "Seattle"
|
||||
assert body["corrected"] is None
|
||||
|
||||
|
||||
def test_pages_serve_with_origin_filled_in(client):
|
||||
r = client.get("/thermograph/")
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.headers["content-type"]
|
||||
assert "__ORIGIN__" not in r.text
|
||||
assert client.head("/thermograph/calendar").status_code == 200
|
||||
138
tests/test_grading.py
Normal file
138
tests/test_grading.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import grading
|
||||
|
||||
|
||||
# ---- empirical percentile ----------------------------------------------------
|
||||
|
||||
def test_percentile_mid_rank_handles_ties():
|
||||
samples = np.array([1.0, 2.0, 2.0, 3.0])
|
||||
# less=1, equal=2 -> (1 + 0.5*2) / 4 = 50%
|
||||
assert grading.empirical_percentile(samples, 2.0) == 50.0
|
||||
|
||||
|
||||
def test_percentile_extremes_and_empties():
|
||||
samples = np.array([1.0, 2.0, 3.0])
|
||||
assert grading.empirical_percentile(samples, 0.0) == 0.0
|
||||
assert grading.empirical_percentile(samples, 4.0) == 100.0
|
||||
assert grading.empirical_percentile(np.array([]), 1.0) is None
|
||||
assert grading.empirical_percentile(samples, None) is None
|
||||
assert grading.empirical_percentile(samples, float("nan")) is None
|
||||
|
||||
|
||||
# ---- tier bands ---------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("pct,label,css", [
|
||||
(99.5, "Near Record", "rec-hot"), # top tier is strict: > 99 only
|
||||
(99.0, "Very High", "very-hot"), # p99 exactly is NOT near-record
|
||||
(90.0, "Very High", "very-hot"),
|
||||
(75.0, "High", "hot"),
|
||||
(60.0, "Above Normal", "warm"),
|
||||
(59.9, "Normal", "normal"),
|
||||
(40.0, "Normal", "normal"),
|
||||
(39.9, "Below Normal", "cool"),
|
||||
(10.0, "Low", "cold"),
|
||||
(1.0, "Very Low", "very-cold"),
|
||||
(0.5, "Near Record", "rec-cold"), # strictly below the 1st percentile
|
||||
])
|
||||
def test_temp_band_boundaries(pct, label, css):
|
||||
assert grading._band(pct, grading.TEMP_BANDS) == (label, css)
|
||||
|
||||
|
||||
def test_ladders_stay_aligned_with_bands():
|
||||
"""The detail-view ladders re-encode the band tables by hand; catch drift."""
|
||||
for bands, ladder in [(grading.TEMP_BANDS, grading._TEMP_LADDER),
|
||||
(grading.RAIN_BANDS, grading._RAIN_LADDER)]:
|
||||
assert len(bands) == len(ladder)
|
||||
for (_, label, css), (lcss, llabel, *_rest) in zip(bands, ladder):
|
||||
assert (label, css) == (llabel, lcss)
|
||||
|
||||
|
||||
# ---- seasonal window ----------------------------------------------------------
|
||||
|
||||
def test_window_mask_wraps_across_year_end():
|
||||
doys = np.array([1, 180, 360, 366])
|
||||
mask = grading.window_mask(doys, target_doy=1, half=7)
|
||||
assert mask.tolist() == [True, False, True, True]
|
||||
|
||||
|
||||
# ---- precip grading -----------------------------------------------------------
|
||||
|
||||
def test_dry_day_gets_dry_class_without_percentile():
|
||||
g = grading._grade_precip(np.array([0.0, 0.5, 1.0]), 0.005)
|
||||
assert g["class"] == "dry" and g["percentile"] is None and g["grade"] == "Dry"
|
||||
|
||||
|
||||
def test_rain_percentile_ranks_among_rain_days_only():
|
||||
# 7 dry days + rain days [0.1, 0.2, 0.4]; 0.2 ranks among the 3 rain days:
|
||||
# less=1, equal=1 -> (1 + 0.5) / 3 = 50% -> Moderate, unaffected by the dry mass.
|
||||
samples = np.array([0.0] * 7 + [0.1, 0.2, 0.4])
|
||||
g = grading._grade_precip(samples, 0.2)
|
||||
assert g["percentile"] == 50.0
|
||||
assert g["grade"] == "Moderate"
|
||||
|
||||
|
||||
def test_rain_with_no_historical_rain_days_is_extreme():
|
||||
g = grading._grade_precip(np.zeros(10), 0.3)
|
||||
assert g["percentile"] == 100.0 and g["class"] == "wet-9"
|
||||
|
||||
|
||||
# ---- dry streaks ---------------------------------------------------------------
|
||||
|
||||
def test_dry_streaks_walk():
|
||||
dates = pd.date_range("2024-01-01", periods=5, freq="D")
|
||||
precips = [0.5, 0.0, float("nan"), 0.02, 0.005]
|
||||
out = grading.dry_streaks(dates.values, precips)
|
||||
assert list(out.values()) == [0, 1, 2, 0, 1] # NaN counts as dry
|
||||
|
||||
|
||||
# ---- range + day grading over a synthetic record --------------------------------
|
||||
|
||||
def test_grade_range_compact_shape(history):
|
||||
end = pd.Timestamp(history["date"].max())
|
||||
start = end - pd.Timedelta(days=30)
|
||||
days = grading.grade_range(history, start, end)
|
||||
assert len(days) == 31
|
||||
first = days[0]
|
||||
assert set(first) == {"date", "dsr", *grading.TEMP_METRICS, "precip"}
|
||||
assert first["dsr"] >= 0
|
||||
g = first["tmax"]
|
||||
assert set(g) == {"v", "pct", "c", "g"}
|
||||
for rec in days: # dry days carry no rain percentile, wet days always do
|
||||
if rec["precip"]["c"] == "dry":
|
||||
assert rec["precip"]["pct"] is None
|
||||
else:
|
||||
assert rec["precip"]["pct"] is not None
|
||||
|
||||
|
||||
def test_grade_day_normals_and_departure(history):
|
||||
target = pd.Timestamp(history["date"].max())
|
||||
row = history[history["date"] == target].iloc[0]
|
||||
result = grading.grade_day(history, target, {"tmax": row["tmax"], "tmin": row["tmin"],
|
||||
"precip": row["precip"]})
|
||||
assert set(result["normals"]["tmax"]) == {"p1", "p10", "p25", "p40", "p50", "p60",
|
||||
"p75", "p90", "p99"}
|
||||
expected = max(abs(result["tmax"]["percentile"] - 50), abs(result["tmin"]["percentile"] - 50))
|
||||
assert result["departure"] == round(expected, 1)
|
||||
|
||||
|
||||
def test_day_detail_with_and_without_observation(history):
|
||||
target = pd.Timestamp(history["date"].max())
|
||||
detail = grading.day_detail(history, target, {"tmax": 60.0, "precip": 0.0})
|
||||
assert detail["metrics"]["tmax"]["obs"]["value"] == 60.0
|
||||
assert detail["metrics"]["tmax"]["ladder"]["tiers"][0]["c"] == "rec-hot"
|
||||
assert detail["metrics"]["precip"]["ladder"]["tiers"][-1]["c"] == "dry"
|
||||
|
||||
bare = grading.day_detail(history, target, None)
|
||||
assert bare["metrics"]["tmax"]["obs"] is None
|
||||
assert bare["metrics"]["tmax"]["ladder"] is not None
|
||||
|
||||
|
||||
def test_climatology_summary(history):
|
||||
climo = grading.climatology(history, 180)
|
||||
# ±7-day window over ~20 years -> ~15 samples per year.
|
||||
assert climo["n_samples"] >= 15 * 19
|
||||
assert climo["tmax"]["p40"] <= climo["tmax"]["p50"] <= climo["tmax"]["p60"]
|
||||
assert climo["feels"] is None # column absent from this record
|
||||
61
tests/test_grid.py
Normal file
61
tests/test_grid.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
import grid
|
||||
|
||||
|
||||
def test_snap_is_deterministic_and_contains_point():
|
||||
a = grid.snap(47.6062, -122.3321)
|
||||
b = grid.snap(47.6062, -122.3321)
|
||||
assert a == b
|
||||
assert a["bounds"]["south"] <= 47.6062 <= a["bounds"]["north"]
|
||||
|
||||
|
||||
def test_snap_center_lands_in_same_cell():
|
||||
cell = grid.snap(40.7128, -74.0060)
|
||||
again = grid.snap(cell["center_lat"], cell["center_lon"])
|
||||
assert again["id"] == cell["id"]
|
||||
|
||||
|
||||
def test_from_id_round_trip():
|
||||
cell = grid.snap(35.6762, 139.6503) # Tokyo — worldwide coverage
|
||||
rebuilt = grid.from_id(cell["id"])
|
||||
assert rebuilt == cell
|
||||
|
||||
|
||||
def test_from_id_rejects_malformed():
|
||||
with pytest.raises(ValueError):
|
||||
grid.from_id("not-a-cell")
|
||||
|
||||
|
||||
def test_longitude_wraps_across_antimeridian():
|
||||
assert grid.snap(10.0, 200.0)["id"] == grid.snap(10.0, -160.0)["id"]
|
||||
assert grid.snap(10.0, 540.0)["id"] == grid.snap(10.0, 180.0)["id"]
|
||||
|
||||
|
||||
def test_latitude_clamped_at_poles():
|
||||
cell = grid.snap(95.0, 0.0)
|
||||
assert cell == grid.snap(90.0, 0.0)
|
||||
assert -90.0 <= cell["center_lat"] <= 90.0
|
||||
assert -180.0 <= cell["center_lon"] <= 180.0
|
||||
|
||||
|
||||
def test_reported_center_is_a_valid_coordinate_everywhere():
|
||||
for lat, lon in [(89.99, 179.99), (-89.99, -179.99), (0.0, 0.0), (66.5, -179.5)]:
|
||||
cell = grid.snap(lat, lon)
|
||||
assert -90.0 <= cell["center_lat"] <= 90.0
|
||||
assert -180.0 <= cell["center_lon"] <= 180.0
|
||||
|
||||
|
||||
def test_cells_stay_roughly_square():
|
||||
# The cos(lat) scaling should keep the area near ~4 sq mi away from the poles.
|
||||
for lat in (0.0, 30.0, 45.0, 60.0):
|
||||
cell = grid.snap(lat, 10.0)
|
||||
assert cell["area_sq_mi"] == pytest.approx(4.0, rel=0.15)
|
||||
|
||||
|
||||
def test_lon_step_clamps_near_poles():
|
||||
# cos(89°) ~ 0.017 would blow the step up; the 0.05 clamp caps it.
|
||||
assert grid._lon_step(89.0) == pytest.approx(grid.LAT_STEP / 0.05)
|
||||
assert not math.isinf(grid._lon_step(90.0))
|
||||
112
tests/test_places.py
Normal file
112
tests/test_places.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import pytest
|
||||
|
||||
import places
|
||||
|
||||
|
||||
def _entry(name, admin1, country, cc, lat, lon, pop):
|
||||
return (places._norm(name), name, admin1, country, cc, lat, lon, pop)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index(monkeypatch):
|
||||
entries = [
|
||||
_entry("Seattle", "Washington", "United States", "US", 47.60, -122.33, 750_000),
|
||||
_entry("West Seattle", "Washington", "United States", "US", 47.57, -122.39, 30_000),
|
||||
_entry("SeaTac", "Washington", "United States", "US", 47.44, -122.29, 31_000),
|
||||
_entry("Portland", "Oregon", "United States", "US", 45.52, -122.68, 650_000),
|
||||
_entry("Paris", None, "France", "FR", 48.86, 2.35, 2_100_000),
|
||||
# The index normalizes GeoNames' ASCII name; the display name keeps accents.
|
||||
(places._norm("Coeur d'Alene"), "Cœur d'Alene", "Idaho", "United States",
|
||||
"US", 47.68, -116.78, 56_000),
|
||||
]
|
||||
monkeypatch.setattr(places, "_data", places._build(entries))
|
||||
return places
|
||||
|
||||
|
||||
# ---- normalization -------------------------------------------------------------
|
||||
|
||||
def test_norm_strips_accents_and_punctuation():
|
||||
assert places._norm("Coeur d'Alene") == "coeur dalene"
|
||||
assert places._norm("Winston-Salem") == "winston salem"
|
||||
assert places._norm(" MÜNCHEN ") == "munchen"
|
||||
assert places._norm("São Paulo") == "sao paulo"
|
||||
|
||||
|
||||
# ---- one-edit matchers ----------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("q,w,hit", [
|
||||
("chic", "chicago", True), # plain prefix
|
||||
("chicgo", "chicago", True), # missing letter
|
||||
("chhicago", "chicago", True), # extra letter
|
||||
("chixago", "chicago", True), # substituted letter
|
||||
("cihcago", "chicago", True), # adjacent swap
|
||||
("chizzgo", "chicago", False), # two edits
|
||||
("boston", "chicago", False),
|
||||
])
|
||||
def test_prefix_edit1(q, w, hit):
|
||||
assert places._prefix_edit1(q, w) is hit
|
||||
|
||||
|
||||
@pytest.mark.parametrize("a,b,hit", [
|
||||
("west", "west", True),
|
||||
("pest", "west", True), # substitution
|
||||
("wst", "west", True), # deletion
|
||||
("wesst", "west", True), # insertion
|
||||
("ewst", "west", True), # transposition
|
||||
("east", "west", False), # two substitutions
|
||||
("we", "west", False), # length differs by 2
|
||||
])
|
||||
def test_within1(a, b, hit):
|
||||
assert places._within1(a, b) is hit
|
||||
|
||||
|
||||
# ---- search ----------------------------------------------------------------------
|
||||
|
||||
def test_search_returns_none_until_loaded(monkeypatch):
|
||||
monkeypatch.setattr(places, "_data", None)
|
||||
assert places.search("seattle") is None
|
||||
|
||||
|
||||
def test_search_prefix_matches_by_population(index):
|
||||
out = places.search("sea", 5)
|
||||
assert [r["name"] for r in out] == ["Seattle", "SeaTac"]
|
||||
assert all(r["match"] == "prefix" for r in out)
|
||||
|
||||
|
||||
def test_search_tolerates_one_typo(index):
|
||||
out = places.search("seatle", 5)
|
||||
assert out[0]["name"] == "Seattle"
|
||||
assert out[0]["match"] == "fuzzy"
|
||||
|
||||
|
||||
def test_search_no_fuzzy_for_short_queries(index):
|
||||
# 3 letters: "one letter off" would match half the index — prefix only.
|
||||
assert all(r["match"] == "prefix" for r in places.search("sea", 5))
|
||||
assert places.search("xea", 5) == []
|
||||
|
||||
|
||||
def test_search_normalizes_the_query(index):
|
||||
out = places.search("coeur dalene", 5)
|
||||
assert out and out[0]["name"] == "Cœur d'Alene"
|
||||
|
||||
|
||||
def test_search_result_shape(index):
|
||||
r = places.search("paris", 1)[0]
|
||||
assert r == {"name": "Paris", "admin1": None, "country": "France",
|
||||
"country_code": "FR", "lat": 48.86, "lon": 2.35,
|
||||
"population": 2_100_000, "match": "prefix"}
|
||||
|
||||
|
||||
# ---- corrections ------------------------------------------------------------------
|
||||
|
||||
def test_corrections_respell_a_typod_token(index):
|
||||
assert "west seattle" in places.corrections("pest seattle")
|
||||
|
||||
|
||||
def test_corrections_empty_until_loaded(monkeypatch):
|
||||
monkeypatch.setattr(places, "_data", None)
|
||||
assert places.corrections("pest seattle") == []
|
||||
|
||||
|
||||
def test_corrections_skip_short_tokens(index):
|
||||
assert places.corrections("st paris") == [] # 1-2 letter tokens are noise
|
||||
65
tests/test_store.py
Normal file
65
tests/test_store.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
|
||||
def test_payload_round_trip(tmp_store):
|
||||
payload = {"cell": "1_2", "days": [1, 2, 3]}
|
||||
body = tmp_store.put_payload("grade", "1_2", "k", "tok", payload)
|
||||
assert json.loads(body) == payload
|
||||
assert tmp_store.get_payload("grade", "1_2", "k", "tok") == body
|
||||
assert tmp_store.get_json("grade", "1_2", "k", "tok") == payload
|
||||
|
||||
|
||||
def test_token_mismatch_is_a_miss(tmp_store):
|
||||
tmp_store.put_payload("grade", "1_2", "k", "tok-a", {"v": 1})
|
||||
assert tmp_store.get_payload("grade", "1_2", "k", "tok-b") is None
|
||||
# A rewrite under the new token replaces the row (same primary key).
|
||||
tmp_store.put_payload("grade", "1_2", "k", "tok-b", {"v": 2})
|
||||
assert tmp_store.get_json("grade", "1_2", "k", "tok-b") == {"v": 2}
|
||||
assert tmp_store.get_payload("grade", "1_2", "k", "tok-a") is None
|
||||
|
||||
|
||||
def test_cache_false_serves_without_persisting(tmp_store):
|
||||
body = tmp_store.put_payload("grade", "1_2", "k", "tok", {"v": 1}, cache=False)
|
||||
assert json.loads(body) == {"v": 1}
|
||||
assert tmp_store.get_payload("grade", "1_2", "k", "tok") is None
|
||||
|
||||
|
||||
def test_put_payload_rejects_nan(tmp_store):
|
||||
import pytest
|
||||
with pytest.raises(ValueError):
|
||||
tmp_store.put_payload("grade", "1_2", "k", "tok", {"v": float("nan")})
|
||||
|
||||
|
||||
def test_revgeo_round_trip(tmp_store):
|
||||
key = tmp_store.revgeo_key(47.60623, -122.33305)
|
||||
assert key == "47.606,-122.333"
|
||||
assert tmp_store.get_revgeo(key) == (False, None)
|
||||
tmp_store.put_revgeo(key, "Belltown, Seattle, Washington")
|
||||
assert tmp_store.get_revgeo(key) == (True, "Belltown, Seattle, Washington")
|
||||
|
||||
|
||||
def test_revgeo_cached_miss_retries_after_ttl(tmp_store):
|
||||
key = tmp_store.revgeo_key(1.0, 2.0)
|
||||
tmp_store.put_revgeo(key, None)
|
||||
assert tmp_store.get_revgeo(key) == (True, None) # fresh miss: don't re-ask yet
|
||||
# Age the row past the TTL directly in SQLite.
|
||||
conn = sqlite3.connect(tmp_store.DB_PATH)
|
||||
conn.execute("UPDATE revgeo SET updated_at=? WHERE key=?",
|
||||
(time.time() - tmp_store.REVGEO_MISS_TTL - 1, key))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
assert tmp_store.get_revgeo(key) == (False, None) # stale miss: retryable
|
||||
|
||||
|
||||
def test_store_is_a_pure_accelerator_when_db_unavailable(tmp_store, monkeypatch):
|
||||
# Point the store somewhere unwritable: every helper degrades, none raises.
|
||||
monkeypatch.setattr(tmp_store, "DB_PATH", "/proc/nope/store.sqlite")
|
||||
import threading
|
||||
monkeypatch.setattr(tmp_store, "_local", threading.local())
|
||||
assert tmp_store.get_payload("grade", "c", "k", "t") is None
|
||||
body = tmp_store.put_payload("grade", "c", "k", "t", {"v": 1})
|
||||
assert json.loads(body) == {"v": 1} # still serves the encoded payload
|
||||
assert tmp_store.get_revgeo("x") == (False, None)
|
||||
tmp_store.put_revgeo("x", "label") # no-op, no exception
|
||||
Loading…
Reference in a new issue