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.
355 lines
15 KiB
Python
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
|