thermograph/tests/conftest.py
Emi Griffith 4ac5323375 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.
2026-07-11 19:37:49 +00:00

94 lines
3.3 KiB
Python

"""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