"""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 datetime import os import sys import tempfile import threading import numpy as np import polars as pl import pytest BACKEND = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, BACKEND) from data 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-") # Keep the authoritative accounts DB out of the repo's data/ folder, and don't # start the background notifier thread during tests. Set before db is imported. os.environ.setdefault("THERMOGRAPH_ACCOUNTS_DB", os.path.join(_TMP, "accounts.sqlite")) os.environ.setdefault("THERMOGRAPH_ENABLE_NOTIFIER", "0") os.environ.setdefault("THERMOGRAPH_ENABLE_HEARTBEAT", "0") # web/app.py (repo-split Stage 4) fails loud at import if this is unset -- tests # never actually hit a frontend-owned path through the live proxy (that's # frontend_ssr/tests' job), so an unreachable placeholder is fine here. os.environ.setdefault("THERMOGRAPH_FRONTEND_BASE_INTERNAL", "http://127.0.0.1:1") # Pin the IndexNow key so it isn't generated + written into the repo's data/ dir. os.environ.setdefault("THERMOGRAPH_INDEXNOW_KEY", "test0000indexnow0000key000000000") from data 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") from core import audit # noqa: E402 audit.AUDIT_DIR = os.path.join(_TMP, "logs", "audit") audit.ERROR_DIR = os.path.join(_TMP, "logs", "errors") # The request-logging middleware runs under TestClient too; keep its access log # out of the repo's logs/ so self-test traffic never pollutes the live monitor. audit.ACCESS_DIR = os.path.join(_TMP, "logs", "access") audit.ACTIVITY_DIR = os.path.join(_TMP, "logs", "activity") audit.HEARTBEAT_DIR = os.path.join(_TMP, "logs", "heartbeat") def _years_before(d: datetime.date, years: int) -> datetime.date: """`d` shifted back `years` years, same month/day (Feb 29 → Feb 28).""" try: return d.replace(year=d.year - years) except ValueError: return d.replace(year=d.year - years, day=28) def _daily(start: datetime.date, end: datetime.date) -> list[datetime.date]: """Inclusive daily date list from `start` to `end`.""" return [start + datetime.timedelta(days=i) for i in range((end - start).days + 1)] def _with_doy(df: pl.DataFrame) -> pl.DataFrame: return df.with_columns(pl.col("date").dt.ordinal_day().cast(pl.Int16).alias("doy")) def make_history(years: int = 20, end: str | None = None, seed: int = 7) -> pl.DataFrame: """A plausible daily record (tmax/tmin/precip + doy), matching the columns and dtypes climate.get_history returns for an older cache.""" end_ts = (datetime.date.fromisoformat(end) if end else datetime.date.today() - datetime.timedelta(days=6)) dates = _daily(_years_before(end_ts, years), end_ts) rng = np.random.default_rng(seed) doy = np.array([d.timetuple().tm_yday for d in dates]) 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) return _with_doy(pl.DataFrame({ "date": dates, "tmax": np.round(tmax, 1), "tmin": np.round(tmin, 1), "precip": np.round(precip, 2), })) def make_recent(history: pl.DataFrame, future_days: int = 7, seed: int = 11) -> pl.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 = datetime.date.today() start = history["date"].max() - datetime.timedelta(days=14) dates = _daily(start, today + datetime.timedelta(days=future_days)) rng = np.random.default_rng(seed) doy = np.array([d.timetuple().tm_yday for d in dates]) seasonal = 55 + 30 * np.sin((doy - 100) / 366.0 * 2 * np.pi) tmax = seasonal + rng.normal(0, 8, len(dates)) return _with_doy(pl.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), })) @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