"""Tests for the homepage: the cache-only "unusual right now" precompute, the server-rendered surfaces, and the digest signup. 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 pathlib import time import pytest from fastapi.testclient import TestClient from web import app as appmod from data import cities from data import climate from data import store from web import content from web import homepage B = "/thermograph" @pytest.fixture def client(): return TestClient(appmod.app) @pytest.fixture def no_feed(monkeypatch): """The homepage with no precomputed feed at all.""" monkeypatch.setattr(homepage, "load", lambda: None) return None def _pick(name, pct, cls, grade, tail, slug=None): return { "slug": slug or (name.lower().replace(" ", "-") + "-xx"), "name": name, "display": f"{name}, Somewhere", "lat": 1.0, "lon": 2.0, "cell_id": "1_2", "date": datetime.date.today().isoformat(), "metric": "tmax", "metric_label": "High temp", "window_label": "mid-July", "value": 99.0, "normal": 88.0, "percentile": pct, "pct_label": homepage._pct_label(pct), "grade": grade, "grade_label": homepage._grade_label(grade, tail), "cls": cls, "departure": abs(pct - 50), "tail": tail, } # --- 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()}) # --- the rendered page ------------------------------------------------------- def test_homepage_is_complete_without_js(client, no_feed): """A crawler (or a reader with JS off) gets the whole page as real HTML.""" r = client.get(f"{B}/") assert r.status_code == 200 html = r.text assert "How unusual is your weather?" in html assert "How it works" in html assert "Climate context for 1,000 cities" in html assert "Free. No ads. No tracking." in html # The interactive tool's DOM contract survives the move to Jinja. for dom_id in ('id="find-btn"', 'id="date-input"', 'id="panel"', 'id="placeholder"', 'id="results"'): assert dom_id in html # Exactly one h1: the hero headline. The brand degrades to a

. assert html.count(" so the hero owns the page's only h1. The lockup styling is written against `.brand h1`, so the class MUST be matched there too — otherwise the brand silently falls back to block layout, which baseline-aligns the mark instead of centring it and drops the monospace wordmark. Nothing else catches this: the page still renders, and every other test still passes, while the header looks wrong. """ html = client.get(f"{B}/").text assert 'class="site-name"' in html assert "