176 lines
7.3 KiB
Python
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
|