203 lines
7.8 KiB
Python
203 lines
7.8 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 time
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
|
||
|
|
import app as appmod
|
||
|
|
import cities
|
||
|
|
import climate
|
||
|
|
import content
|
||
|
|
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, "percentile": pct, "grade": grade, "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")
|
||
|
|
|
||
|
|
|
||
|
|
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)
|
||
|
|
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_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_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_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
|