diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..6be4ef8 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +# Test/dev-only dependencies, layered on the runtime set. +-r requirements.txt +pytest==8.4.1 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..cb1c402 --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..bf35725 --- /dev/null +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_grading.py b/tests/test_grading.py new file mode 100644 index 0000000..bc1f87d --- /dev/null +++ b/tests/test_grading.py @@ -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 diff --git a/tests/test_grid.py b/tests/test_grid.py new file mode 100644 index 0000000..c1b85da --- /dev/null +++ b/tests/test_grid.py @@ -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)) diff --git a/tests/test_places.py b/tests/test_places.py new file mode 100644 index 0000000..00ac506 --- /dev/null +++ b/tests/test_places.py @@ -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 diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..7577262 --- /dev/null +++ b/tests/test_store.py @@ -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