The homepage header lost its wordmark treatment: the mark baseline-aligned instead of centring (riding ~8px high) and the wordmark fell back to 20px Inter regular instead of the 26px monospace used everywhere else. The lockup is styled by `.brand h1` — an element selector. The homepage renders the brand as `<p class="site-name">` so its hero headline can own the page's only h1, so it matched none of it: no flex centring, no gap, no monospace, no weight. Measured against /about at 393px: homepage <p> Inter 20px w400 display:block mark -8.5px /about <h1> ui-monospace 26px w700 display:flex mark -1.2px Match `.brand .site-name` alongside `.brand h1` (and the same for the inner link and the compact-header size). Both now measure -1.2px, the intended optical nudge. Adds a test for the invariant. Nothing else caught this: the page rendered, every test passed, and only the rendering was wrong — so the test asserts the lockup rule covers the class the homepage actually uses, and fails if the selector is narrowed again.
232 lines
9.2 KiB
Python
232 lines
9.2 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
|
|
|
|
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_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[2] / "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_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
|