thermograph/frontend/tests/test_homepage.py
Emi Griffith d6df04eab2 Subtree-merge thermograph-frontend (origin/main) into frontend/
git-subtree-dir: frontend
git-subtree-mainline: a4be7066e5
git-subtree-split: 3a98146da4
2026-07-22 22:01:11 -07:00

176 lines
7.3 KiB
Python

"""Tests for the rendered homepage -- ported from backend/tests/web/test_homepage.py
(repo-split Stage 4): content.py's Jinja rendering lives in this service now, not
backend's. The cache-only "unusual right now" precompute (api/homepage.py) itself
stays tested in backend/tests/web/test_homepage.py -- nothing about it moved.
"""
import datetime
import pathlib
import time
import pytest
from api import homepage
from data import cities
from api import content_payloads
from conftest import B
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,
}
@pytest.fixture
def no_feed(monkeypatch):
"""The homepage with no precomputed feed at all."""
monkeypatch.setattr(homepage, "load", lambda: None)
return None
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 = [{"slug": s, "name": cities.get(s)["name"]}
for s in content_payloads.HOME_CITY_SLUGS if cities.get(s)]
assert len(resolved) == len(content_payloads.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_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