195 lines
8.3 KiB
Python
195 lines
8.3 KiB
Python
"""Tests for the homepage's cache-only "unusual right now" precompute
|
|
(api/homepage.py) -- the rendered-page assertions (repo-split Stage 4) moved
|
|
to frontend_ssr/tests/test_homepage.py, since content.py's Jinja rendering
|
|
lives there now, not in this process.
|
|
|
|
The precompute must never touch the network — a sweep over ~1000 cities that
|
|
fetched would burst the archive quota — so the tests here assert that the
|
|
fetching climate helpers are not called at all.
|
|
"""
|
|
import datetime
|
|
import json
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from api import homepage
|
|
from data import climate
|
|
from data import store
|
|
|
|
B = "/thermograph"
|
|
|
|
|
|
# --- the precompute ----------------------------------------------------------
|
|
def test_build_never_fetches_upstream(monkeypatch):
|
|
"""The sweep uses the cache-only readers, never the fetching ones."""
|
|
def boom(*a, **k):
|
|
raise AssertionError("the homepage sweep must not fetch upstream")
|
|
|
|
monkeypatch.setattr(climate, "get_history", boom)
|
|
monkeypatch.setattr(climate, "get_recent_forecast", boom)
|
|
feed = homepage.build(limit=20)
|
|
assert set(feed) >= {"generated_at", "date", "considered", "picks", "ranked"}
|
|
|
|
|
|
def test_build_grades_cached_cities(monkeypatch, history, recent):
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: history.clone())
|
|
monkeypatch.setattr(climate, "load_cached_recent_forecast", lambda cell: recent.clone())
|
|
feed = homepage.build(limit=5)
|
|
assert feed["considered"] == 5
|
|
for row in feed["ranked"]:
|
|
assert row["percentile"] is not None
|
|
assert row["cls"] and not row["cls"].startswith("--") # token name, no prefix
|
|
assert row["tail"] in ("warm", "cold")
|
|
# The card needs the reading itself and something to compare it against —
|
|
# a percentile alone says nothing about what the weather actually is.
|
|
assert row["value"] is not None
|
|
assert row["normal"] is not None, "the ±7-day median must reach the card"
|
|
assert row["metric_label"] in ("High temp", "Low temp")
|
|
assert row["pct_label"].endswith(("st", "nd", "rd", "th"))
|
|
# "Tehran, Iran", not "Dar es Salaam, Dar es Salaam Region, Tanzania".
|
|
assert row["display"].count(",") <= 1
|
|
|
|
|
|
def test_percentile_label_never_reads_100th_or_0th():
|
|
"""An empirical percentile can round to 100 (or 0). "100th percentile" reads
|
|
as a bug rather than "as hot as it has ever been", so the label floors into
|
|
the real 1-99 range."""
|
|
assert homepage._pct_label(99.6) == "99th"
|
|
assert homepage._pct_label(100.0) == "99th"
|
|
assert homepage._pct_label(0.4) == "1st"
|
|
assert homepage._pct_label(0.0) == "1st"
|
|
# The ordinary teens/ones rules still apply in between.
|
|
assert homepage._pct_label(11) == "11th"
|
|
assert homepage._pct_label(22) == "22nd"
|
|
assert homepage._pct_label(3) == "3rd"
|
|
|
|
|
|
def test_near_record_is_disambiguated_by_tail():
|
|
""""Near Record" is the band label at BOTH ends of the scale. Unqualified, a
|
|
cold extreme and a hot one render identically and colour becomes the only
|
|
signal — which the design rules forbid."""
|
|
assert homepage._grade_label("Near Record", "warm") == "Near Record hot"
|
|
assert homepage._grade_label("Near Record", "cold") == "Near Record cold"
|
|
# Tiers that already read directionally are left alone.
|
|
assert homepage._grade_label("Very High", "warm") == "Very High"
|
|
assert homepage._grade_label("Low", "cold") == "Low"
|
|
|
|
|
|
def test_missing_cell_cache_is_skipped_not_fatal(monkeypatch):
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: None)
|
|
monkeypatch.setattr(climate, "load_cached_recent_forecast", lambda cell: None)
|
|
feed = homepage.build(limit=10)
|
|
assert feed["considered"] == 0 and feed["ranked"] == []
|
|
|
|
|
|
def test_load_survives_missing_and_corrupt_file(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(homepage, "FEED_PATH", str(tmp_path / "nope.json"))
|
|
assert homepage.load() is None
|
|
|
|
bad = tmp_path / "homepage.json"
|
|
bad.write_text("{not json")
|
|
monkeypatch.setattr(homepage, "FEED_PATH", str(bad))
|
|
assert homepage.load() is None
|
|
|
|
bad.write_text('{"unexpected": true}')
|
|
assert homepage.load() is None
|
|
|
|
|
|
def test_refresh_writes_atomically(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(homepage, "FEED_PATH", str(tmp_path / "homepage.json"))
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: None)
|
|
homepage.refresh(limit=3, fetch=0)
|
|
written = json.loads((tmp_path / "homepage.json").read_text())
|
|
assert written["ranked"] == []
|
|
# No temp files left behind.
|
|
assert [p.name for p in tmp_path.iterdir()] == ["homepage.json"]
|
|
|
|
|
|
def test_refresh_persists_via_store_on_postgres_not_the_file(monkeypatch, tmp_path):
|
|
"""On Postgres every web replica has its own disk, so the feed must go through
|
|
store.py's shared table instead — and must NOT also write the (per-replica,
|
|
stale-to-everyone-else) local file."""
|
|
monkeypatch.setattr(homepage, "FEED_PATH", str(tmp_path / "homepage.json"))
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: None)
|
|
monkeypatch.setattr(store, "IS_POSTGRES", True)
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
store, "put_payload",
|
|
lambda kind, cell_id, key, token, payload, **kw: calls.append(
|
|
(kind, cell_id, key, token, payload)))
|
|
feed = homepage.refresh(limit=3, fetch=0)
|
|
assert len(calls) == 1
|
|
kind, cell_id, key, token, payload = calls[0]
|
|
assert (kind, cell_id, key) == ("homepage", "_global", "feed")
|
|
assert token and payload is feed
|
|
assert not (tmp_path / "homepage.json").exists()
|
|
|
|
|
|
def test_load_reads_via_store_on_postgres(monkeypatch):
|
|
monkeypatch.setattr(store, "IS_POSTGRES", True)
|
|
sentinel = {"ranked": [], "date": "2026-01-01"}
|
|
monkeypatch.setattr(store, "get_json", lambda *a, **k: sentinel)
|
|
assert homepage.load() is sentinel
|
|
|
|
|
|
def test_load_on_postgres_handles_absent_or_malformed(monkeypatch):
|
|
monkeypatch.setattr(store, "IS_POSTGRES", True)
|
|
monkeypatch.setattr(store, "get_json", lambda *a, **k: None)
|
|
assert homepage.load() is None
|
|
monkeypatch.setattr(store, "get_json", lambda *a, **k: {"no_ranked_key": True})
|
|
assert homepage.load() is None
|
|
|
|
|
|
def test_refresh_can_be_strictly_cache_only(monkeypatch, tmp_path):
|
|
"""fetch=0 must spend no upstream request at all — that is the contract the
|
|
test suite and any offline rebuild rely on."""
|
|
monkeypatch.setattr(homepage, "FEED_PATH", str(tmp_path / "homepage.json"))
|
|
|
|
def boom(*a, **k):
|
|
raise AssertionError("refresh(fetch=0) must not fetch")
|
|
|
|
monkeypatch.setattr(climate, "get_recent_forecast", boom)
|
|
monkeypatch.setattr(climate, "get_history", boom)
|
|
feed = homepage.refresh(limit=3, fetch=0)
|
|
assert feed["refreshed"] == 0
|
|
|
|
|
|
def test_stale_observations_are_not_ranked(monkeypatch, history, recent):
|
|
"""A reading older than a day is not "right now". The recent/forecast cache
|
|
goes stale for any city nobody visits, and grading it anyway is what showed a
|
|
days-old reading under the "Unusual right now" heading, disagreeing with the
|
|
Day page for the same cell."""
|
|
import polars as pl
|
|
|
|
stale = recent.clone()
|
|
old = datetime.date.today() - datetime.timedelta(days=5)
|
|
stale = stale.with_columns(
|
|
(pl.col("date") - pl.duration(days=(stale["date"].max() - old).days)).alias("date")
|
|
)
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: history.clone())
|
|
monkeypatch.setattr(climate, "load_cached_recent_forecast", lambda cell: stale.clone())
|
|
feed = homepage.build(limit=5)
|
|
assert feed["ranked"] == [], "stale readings must not be presented as current"
|
|
assert feed["considered"] == 0
|
|
|
|
|
|
def test_staleness(monkeypatch):
|
|
today = datetime.date.today().isoformat()
|
|
assert not homepage.is_stale({"date": today, "generated_at": time.time()})
|
|
assert homepage.is_stale({"date": today, "generated_at": time.time() - 7 * 3600})
|
|
assert homepage.is_stale({"date": "1999-01-01", "generated_at": time.time()})
|
|
|
|
|
|
# --- static frontend, unaffected by the SSR content split --------------------
|
|
@pytest.fixture
|
|
def client():
|
|
from fastapi.testclient import TestClient
|
|
from web import app as appmod
|
|
return TestClient(appmod.app)
|
|
|
|
|
|
def test_old_static_index_is_gone(client):
|
|
"""index.html must not linger behind the static mount as indexable duplicate
|
|
content now that / is server-rendered (by frontend_ssr)."""
|
|
assert client.get(f"{B}/index.html").status_code == 404
|