thermograph/tests/web/test_homepage.py
Emi Griffith 22019bc632 Persist the homepage feed to Postgres instead of a per-replica file (#232)
homepage.json lives on the appdata volume today, written by whichever
process's notifier last refreshed it. Under Swarm each web replica has its
own disk, so a file only one replica's notifier ever writes leaves every
other replica reading a stale or missing feed indefinitely.

On Postgres, refresh()/load() go through store.py's existing derived-payload
table instead (a fixed sentinel key, since the feed isn't cell-scoped) - a
single upsert is already atomic, and every replica reads the same row.
store.IS_POSTGRES is False without THERMOGRAPH_DATABASE_URL, so dev/tests
keep the plain file path entirely unchanged; no existing test needed to
change.

Verified live against real Postgres: refresh() persists into the shared
derived table and load() reads the exact same feed back.
2026-07-21 00:24:17 +00:00

355 lines
15 KiB
Python

"""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 <p>.
assert html.count("<h1") == 1
assert 'p class="site-name"' in html
def test_hero_locate_has_a_status_slot(client, no_feed):
"""The locate button needs somewhere to report a declined prompt or a
timeout. Without it the control fails silently, which is how it shipped
broken: navigator.geolocation exists on plain http:// but always errors."""
html = client.get(f"{B}/").text
assert 'id="hero-locate"' in html
assert 'id="hero-locate-msg"' in html
assert 'aria-live="polite"' in html
def test_homepage_brand_keeps_the_lockup_styling(client, no_feed):
"""The homepage renders the brand as <p class="site-name"> 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 "<h1 class=\"site-name\"" not in html # the hero owns the h1
css = (pathlib.Path(__file__).parents[3] / "frontend" / "style.css").read_text()
lockup = css.split(".brand h1", 1)[1].split("}", 1)[0]
assert ".brand .site-name" in lockup, (
"the .brand h1 lockup rule must also match .brand .site-name"
)
def test_city_chips_all_resolve(client, no_feed):
"""Every homepage chip must be a real, routable city — a stale slug would
render an empty chip list and leak a 404 link."""
resolved = content._home_cities()
assert len(resolved) == len(content.HOME_CITY_SLUGS)
html = client.get(f"{B}/").text
for city in resolved:
assert f"/climate/{city['slug']}" in html
assert cities.get(city["slug"]) is not None
def test_records_strip_shows_both_tails(client, monkeypatch):
"""Two-sided honesty: a qualifying cold-tail city appears alongside the heat."""
feed = {
"generated_at": time.time(), "date": datetime.date.today().isoformat(),
"considered": 800,
"picks": {"extreme": _pick("Phoenix", 99.4, "rec-hot", "Near Record", "warm")},
"ranked": [_pick("Phoenix", 99.4, "rec-hot", "Near Record", "warm"),
_pick("Ushuaia", 2.1, "very-cold", "Very Low", "cold")],
}
monkeypatch.setattr(homepage, "load", lambda: feed)
html = client.get(f"{B}/").text
assert "Unusual right now" in html
assert "Phoenix" in html and "Ushuaia" in html
# Band colour comes from the shared tokens, and the band word is always
# present too — colour is never the only signal.
assert "var(--rec-hot)" in html and "var(--very-cold)" in html
assert "Near Record" in html and "Very Low" in html
def test_strip_cards_carry_value_metric_and_normal(client, monkeypatch):
feed = {
"generated_at": time.time(), "date": datetime.date.today().isoformat(),
"considered": 800, "picks": {},
"ranked": [_pick("Tehran", 99.6, "rec-hot", "Near Record", "warm"),
_pick("Ushuaia", 0.4, "rec-cold", "Near Record", "cold")],
}
monkeypatch.setattr(homepage, "load", lambda: feed)
html = client.get(f"{B}/").text
# Both tails must be tellable apart in WORDS, not just by colour.
assert "Near Record hot" in html and "Near Record cold" in html
assert "normal" in html and "High temp" in html
assert "100th" not in html and "0th" not in html
# Temperatures render as convertible spans, and the converter is loaded, or
# the strip would stay in °F while the °F/°C toggle says °C.
assert 'class="temp" data-temp-f' in html
assert "climate.js" in html
def test_hero_uses_most_unusual_default(client, monkeypatch):
feed = {
"generated_at": time.time(), "date": datetime.date.today().isoformat(),
"considered": 800,
"picks": {"extreme": _pick("Phoenix", 96.0, "very-hot", "Very High", "warm")},
"ranked": [],
}
monkeypatch.setattr(homepage, "load", lambda: feed)
html = client.get(f"{B}/").text
assert "Today in Phoenix, Somewhere" in html
assert "96th percentile" in html
assert "the most unusual weather we're tracking" in html
def test_homepage_renders_without_feed(client, no_feed):
"""A cold checkout with no precomputed feed still serves a complete page."""
html = client.get(f"{B}/").text
assert "How unusual is your weather?" in html
assert "Unusual right now" not in html # strip is omitted, not broken
assert 'id="hero-grade"' in html # the frame is still there
def test_homepage_etag_revalidates(client, no_feed):
r = client.get(f"{B}/")
etag = r.headers["etag"]
again = client.get(f"{B}/", headers={"If-None-Match": etag})
assert again.status_code == 304
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."""
assert client.get(f"{B}/index.html").status_code == 404
def test_privacy_page(client):
r = client.get(f"{B}/privacy")
assert r.status_code == 200
assert "no analytics library" in r.text.lower() or "no analytics" in r.text.lower()
assert "Use my location" in r.text
def test_privacy_is_linked_but_not_in_sitemap(client):
assert f"{B}/privacy" in client.get(f"{B}/").text
# It's a policy page, not a crawl target we want ranked.
assert "/privacy" not in client.get(f"{B}/sitemap.xml").text