thermograph/tests/test_content.py

442 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for the SSR content pages, ported from backend/tests/web/test_content.py
(repo-split Stage 3). The `client` fixture (conftest.py) fakes api_client by
calling the real backend payload builders against the same synthetic history
fixture backend/tests uses, so assertions here and there agree on the same
numbers. Tests that exercise backend-only concerns (indexnow submission, the
/api/v2/content/* JSON endpoints themselves, data-layer helpers like
cities.title_name) stay in backend/tests -- this file covers only what the SSR
rendering layer (content.py + format.py) does with that data.
"""
import math
import os
import re
import httpx
import pytest
import api_client
import content
import format as fmt
from conftest import B, SLUG
def test_robots_txt(client):
r = client.get(f"{B}/robots.txt")
assert r.status_code == 200
assert "Sitemap:" in r.text and "/sitemap.xml" in r.text
assert "Disallow: /thermograph/api/" in r.text
def test_sitemap_lists_city_urls(client):
r = client.get(f"{B}/sitemap.xml")
assert r.status_code == 200
assert "<urlset" in r.text
assert f"/climate/{SLUG}</loc>" in r.text
assert f"/climate/{SLUG}/july</loc>" in r.text
assert f"/climate/{SLUG}/records</loc>" in r.text
def test_city_page_renders_stats_in_html(client):
r = client.get(f"{B}/climate/{SLUG}")
assert r.status_code == 200
b = r.text
assert "London" in b and "climate" in b.lower()
assert 'rel="canonical"' in b and f"/climate/{SLUG}" in b
assert '"@type":"Dataset"' in b
assert "average temperatures by month" in b.lower()
assert re.search(r"-?\d+°C</span>", b)
def _jsonld_breadcrumbs(html: str) -> list[dict]:
import json
m = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S)
assert m, "no JSON-LD block on page"
graph = json.loads(m.group(1))["@graph"]
return [n for n in graph if n["@type"] == "BreadcrumbList"]
@pytest.mark.parametrize("path", [f"/climate/{SLUG}", f"/climate/{SLUG}/records"])
def test_breadcrumb_jsonld_items_valid_for_google(client, path):
(bc,) = _jsonld_breadcrumbs(client.get(B + path).text)
els = bc["itemListElement"]
assert [e["position"] for e in els] == list(range(1, len(els) + 1))
for e in els[:-1]:
assert e["item"].startswith("http"), f"crumb {e['name']!r} missing item"
def test_jsonld_url_uses_the_ssr_requests_own_origin(client):
# A regression guard for a real bug caught during the port: the jsonld
# "url" field must reflect the browser-facing request the SSR app
# received, not the internal backend-call's own origin.
for path in (f"/climate/{SLUG}", f"/climate/{SLUG}/records"):
html = client.get(B + path).text
m = re.search(r'"url":"(http://testserver[^"]*)"', html)
assert m, f"{path}: jsonld url missing or not on the request's own origin"
def test_city_404(client):
assert client.get(f"{B}/climate/nope-not-a-city").status_code == 404
def _unreachable(*_a, **_k):
req = httpx.Request("GET", "http://backend.invalid/x")
raise httpx.ConnectError("connection refused", request=req)
@pytest.mark.parametrize("path, attr", [
(f"/climate/{SLUG}", "city"),
(f"/climate/{SLUG}/july", "city_month"),
(f"/climate/{SLUG}/records", "city_records"),
("/climate", "hub"),
("/", "home"),
("/sitemap.xml", "sitemap"),
])
def test_backend_unreachable_maps_to_503(client, monkeypatch, path, attr):
"""A backend that's down/timing out (httpx.ConnectError, TimeoutException,
...) never raises httpx.HTTPStatusError -- it doesn't even get a response
-- so it must be caught separately from the 404/503 status-code passthrough
or it escapes as a raw framework 500 instead of a clean 503."""
monkeypatch.setattr(api_client, attr, _unreachable)
assert client.get(f"{B}{path}").status_code == 503
def test_curated_event_renders(client):
b = client.get(f"{B}/climate/new-orleans-louisiana-us").text
assert "Notable weather in New Orleans" in b
assert "Hurricane Katrina" in b
def test_uncurated_city_has_no_event_section(client):
b = client.get(f"{B}/climate/shanghai-cn").text
assert "city-event" not in b
def test_city_travel_cta_prefills_compare(client):
b = client.get(f"{B}/climate/{SLUG}").text
assert "Thinking of visiting" in b
assert "/compare#loc=" in b
def test_month_and_records(client):
assert client.get(f"{B}/climate/{SLUG}/july").status_code == 200
assert client.get(f"{B}/climate/{SLUG}/records").status_code == 200
assert client.get(f"{B}/climate/{SLUG}/notamonth").status_code == 404
def test_month_records_cover_every_metric_high_and_low(client):
b = client.get(f"{B}/climate/{SLUG}/july").text
assert "July records" in b
for label in ("High", "Low", "Precip"):
assert f'class="rc-metric">{label}<' in b
n = b.count('rc-row rc-high')
assert n >= 3 and b.count('rc-row rc-low') == n
assert "Highest" in b and "Lowest" in b
assert "Wettest" in b and "Driest" in b
def test_records_page_has_monthly_and_seasonal(client):
b = client.get(f"{B}/climate/{SLUG}/records").text
assert "Records by month" in b
for month in ("January", "July", "December"):
assert month in b
assert f"/climate/{SLUG}/january" in b
assert "Records by season" in b
assert "Northern Hemisphere" in b
assert 'Winter <span class="season-span">(DecFeb)</span>' in b
assert 'Summer <span class="season-span">(JunAug)</span>' in b
assert "All-time records" in b
assert '"@type":"Dataset"' in b
assert re.search(r"-?\d+°C</span>", b)
assert "monthly" in b.lower() and "seasonal" in b.lower()
def test_records_seasons_flip_in_southern_hemisphere(client):
b = client.get(f"{B}/climate/sao-paulo-br/records").text
assert "Southern Hemisphere" in b
assert 'Summer <span class="season-span">(DecFeb)</span>' in b
assert 'Winter <span class="season-span">(JunAug)</span>' in b
def test_climate_pages_are_colour_coded(client):
city = client.get(f"{B}/climate/{SLUG}").text
assert "t-cell t-" in city
assert "range-fill" in city and "--c1:var(--" in city
recs = client.get(f"{B}/climate/{SLUG}/records").text
assert "t-inline t-" in recs
month = client.get(f"{B}/climate/{SLUG}/july").text
assert "t-inline t-" in month
def test_records_show_both_extremes_per_metric(client):
b = client.get(f"{B}/climate/{SLUG}/records").text
assert "Daytime high" in b and "Overnight low" in b
assert "" in b and "" in b
assert b.count('class="rec-ext"') >= 48
def test_hub_glossary_about(client):
assert client.get(f"{B}/climate").status_code == 200
assert client.get(f"{B}/glossary").status_code == 200
assert client.get(f"{B}/glossary/percentile").status_code == 200
assert client.get(f"{B}/glossary/not-a-term").status_code == 404
assert client.get(f"{B}/about").status_code == 200
def test_static_page_titles_come_from_content_yaml(client):
from markupsafe import escape
for path, key in ((f"{B}/climate", "hub"), (f"{B}/glossary", "glossary_index"),
(f"{B}/about", "about"), (f"{B}/privacy", "privacy")):
html = client.get(path).text
assert f"<title>{escape(content.PAGES[key]['title'])}</title>" in html, path
def test_glossary_terms_come_from_content_yaml(client):
html = client.get(f"{B}/glossary").text
for entry in content.GLOSSARY.values():
assert entry["term"] in html
term_html = client.get(f"{B}/glossary/percentile").text
assert content.GLOSSARY["percentile"]["short"] in term_html
def test_footer_has_no_leaked_jinja_comment(client):
for path in (f"{B}/about", f"{B}/climate/{SLUG}", f"{B}/climate/{SLUG}/records"):
b = client.get(path).text
footer = re.search(r"<footer.*?</footer>", b, re.S)
assert footer, f"no footer on {path}"
assert "#}" not in footer.group(0) and "{#" not in footer.group(0), path
def test_page_titles_lead_with_city_and_payload(client):
for path, must_start, payload in (
(f"{B}/climate/{SLUG}", "London, GB climate", "how unusual it is now"),
(f"{B}/climate/{SLUG}/july", "London, GB in July", "normal weather"),
(f"{B}/climate/{SLUG}/records", "London, GB weather records", "hottest & coldest"),
):
b = client.get(path).text
title = re.search(r"<title>(.*?)</title>", b, re.S).group(1)
title = title.replace("&amp;", "&")
assert title.startswith(must_start), title
assert payload in title, title
assert "England, United Kingdom" not in title
assert len(must_start) <= 40
og = re.search(r'<meta property="og:title" content="(.*?)"', b, re.S).group(1)
assert og == re.search(r"<title>(.*?)</title>", b, re.S).group(1)
def test_meta_descriptions_lead_with_real_numbers(client):
for path in (f"{B}/climate/{SLUG}", f"{B}/climate/{SLUG}/july",
f"{B}/climate/{SLUG}/records"):
b = client.get(path).text
desc = re.search(r'<meta name="description" content="(.*?)"', b, re.S).group(1)
assert len(desc) <= 155, f"{len(desc)}: {desc}"
assert re.search(r"-?\d+°C", desc), desc
assert desc.endswith("years of local history."), desc
rec = client.get(f"{B}/climate/{SLUG}/records").text
desc = re.search(r'<meta name="description" content="(.*?)"', rec, re.S).group(1)
assert re.search(r"\([A-Z][a-z]{2} \d{4}\)", desc), desc
_TEMP_SPAN = re.compile(r'<span class="temp" data-temp-f="(-?[\d.]+)"([^>]*)>(-?\d+)°([CF]?)</span>')
def _temp_spans(html):
return [(float(f), int(n), letter) for f, _attrs, n, letter in _TEMP_SPAN.findall(html)]
def test_climate_pages_carry_convertible_temps(client):
for path in (f"{B}/climate/{SLUG}", f"{B}/climate/{SLUG}/july", f"{B}/climate/{SLUG}/records"):
b = client.get(path).text
spans = _temp_spans(b)
assert spans, f"no temperature spans on {path}"
assert re.search(r"\d+°F \(-?\d+°C\)", b) is None
def test_units_default_to_the_citys_country(client):
gb = client.get(f"{B}/climate/{SLUG}").text
us = client.get(f"{B}/climate/seattle-washington-us").text
gb_spans, us_spans = _temp_spans(gb), _temp_spans(us)
assert gb_spans and us_spans
assert {letter for _f, _n, letter in gb_spans if letter} == {"C"}
assert {letter for _f, _n, letter in us_spans if letter} == {"F"}
assert any(math.floor(f + 0.5) != n for f, n, _l in gb_spans), "data-temp-f was converted"
assert all(math.floor(f + 0.5) == n for f, n, _l in us_spans)
assert 'data-unit-default="C"' in gb
assert 'data-unit-default="F"' in us
_PRECIP_SPAN = re.compile(r'<span class="precip" data-precip-in="([\d.]+)">([\d.]+) (in|mm)</span>')
def test_precip_and_wind_follow_the_citys_unit(client):
gb = client.get(f"{B}/climate/{SLUG}/records").text
us = client.get(f"{B}/climate/seattle-washington-us/records").text
gb_p, us_p = _PRECIP_SPAN.findall(gb), _PRECIP_SPAN.findall(us)
assert gb_p and us_p
assert {u for _raw, _shown, u in gb_p} == {"mm"}
assert {u for _raw, _shown, u in us_p} == {"in"}
for raw, shown, _u in gb_p:
assert abs(float(shown) - float(raw) * 25.4) < 0.51, (raw, shown)
for raw, shown, _u in us_p:
assert abs(float(shown) - float(raw)) < 0.005, (raw, shown)
def test_wind_and_humidity_render_per_unit_system():
# The synthetic history carries only tmax/tmin/precip, so wind and
# humidity never reach a rendered page in these tests -- exercise
# format.py's renderers directly, same as the backend's equivalent test.
with fmt.unit_scope("F"):
assert fmt.wind_text(10) == "10 mph"
assert '<span class="wind" data-wind-mph="10.0">10 mph</span>' == fmt.wind(10)
assert fmt.fmt("humid", 7.75) == "7.8 g/m³"
with fmt.unit_scope("C"):
assert fmt.wind_text(10) == "16 km/h"
assert 'data-wind-mph="10.0"' in fmt.wind(10)
assert fmt.fmt("humid", 7.75) == "7.8 g/m³"
def test_precip_precision_differs_by_unit():
with fmt.unit_scope("F"):
assert fmt.precip_text(0.04) == "0.04 in"
with fmt.unit_scope("C"):
assert fmt.precip_text(0.04) == "1 mm"
assert fmt.precip_text(1.81) == "46 mm"
def test_measure_conversion_constants_match_the_frontend():
units_js = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"frontend", "units.js")
with open(units_js, encoding="utf-8") as f:
src = f.read()
mm = float(re.search(r"MM_PER_IN\s*=\s*([\d.]+)", src).group(1))
kmh = float(re.search(r"KMH_PER_MPH\s*=\s*([\d.]+)", src).group(1))
assert mm == fmt.MM_PER_IN
assert kmh == fmt.KMH_PER_MPH
def test_unit_default_absent_without_a_city(client):
for path in (f"{B}/", f"{B}/climate", f"{B}/about", f"{B}/glossary"):
assert "data-unit-default" not in client.get(path).text, path
def test_unit_does_not_leak_between_requests(client):
assert 'data-unit-default="C"' in client.get(f"{B}/climate/{SLUG}").text
about = client.get(f"{B}/about").text
assert "data-unit-default" not in about
assert {l for _f, _n, l in _temp_spans(about) if l} == {"F"}
us = client.get(f"{B}/climate/seattle-washington-us").text
assert {l for _f, _n, l in _temp_spans(us) if l} == {"F"}
gb_again = client.get(f"{B}/climate/{SLUG}").text
assert {l for _f, _n, l in _temp_spans(gb_again) if l} == {"C"}
def test_f_country_list_matches_the_frontend():
units_js = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"frontend", "units.js")
with open(units_js, encoding="utf-8") as f:
src = f.read()
literal = re.search(r"F_REGIONS = new Set\(\[(.*?)\]\)", src, re.S).group(1)
assert set(re.findall(r'"([A-Z]{2})"', literal)) == set(fmt.F_COUNTRIES)
def test_celsius_rounding_matches_js():
assert fmt._round_half_up(0.5) == 1
assert fmt._round_half_up(1.5) == 2
assert fmt._round_half_up(2.5) == 3
assert fmt._round_half_up(-0.5) == 0
assert fmt._c(31.1) == 0
def test_pct_ordinal_matches_backend_grading():
# format.pct_ordinal is a ported copy of backend/data/grading.py's
# pct_ordinal (the frontend can no longer import backend code) -- pin the
# two against the same cases so a future edit to either can't drift silently.
assert fmt.pct_ordinal(66.4) == "66th"
assert fmt.pct_ordinal(1.2) == "1st"
assert fmt.pct_ordinal(22) == "22nd"
assert fmt.pct_ordinal(3) == "3rd"
assert fmt.pct_ordinal(11) == "11th"
assert fmt.pct_ordinal(99.6) == "99th"
assert fmt.pct_ordinal(100) == "99th"
assert fmt.pct_ordinal(0) == "1st"
assert fmt.pct_ordinal(None) == ""
def test_city_page_percentiles_are_real_ordinals(client):
html = client.get(f"{B}/climate/{SLUG}").text
assert "th pct" in html or "st pct" in html or "nd pct" in html or "rd pct" in html
for bad in re.findall(r"\d+\.\d+(?:st|nd|rd|th)|100th|\b0th|\b\d*[123]th", html):
raise AssertionError(f"malformed percentile ordinal: {bad!r}")
def test_city_page_etag_is_stable(client):
r1 = client.get(f"{B}/climate/{SLUG}")
r2 = client.get(f"{B}/climate/{SLUG}")
assert r1.headers["etag"] == r2.headers["etag"]
r3 = client.get(f"{B}/climate/{SLUG}", headers={"If-None-Match": r1.headers["etag"]})
assert r3.status_code == 304
def test_seo_pages_load_unit_and_account_scripts(client):
b = client.get(f"{B}/climate/{SLUG}").text
for src in (f"{B}/units.js", f"{B}/account.js", f"{B}/climate.js"):
assert f'src="{src}"' in b
def test_indexnow_key_file_served(client):
from conftest import FAKE_INDEXNOW_KEY
r = client.get(f"{B}/{FAKE_INDEXNOW_KEY}.txt")
assert r.status_code == 200
assert r.text.strip() == FAKE_INDEXNOW_KEY
assert r.headers["content-type"].startswith("text/plain")
def test_sitemap_lastmod_is_stable(client):
import datetime
body = client.get(f"{B}/sitemap.xml").text
mods = set(re.findall(r"<lastmod>([^<]+)</lastmod>", body))
assert len(mods) == 1
datetime.date.fromisoformat(next(iter(mods)))
def test_search_verification_meta(client, monkeypatch):
monkeypatch.setenv("THERMOGRAPH_GOOGLE_VERIFY", "gtok123")
monkeypatch.setenv("THERMOGRAPH_BING_VERIFY", "btok456")
seo = client.get(f"{B}/climate/{SLUG}").text
home = client.get(f"{B}/").text
for b in (seo, home):
assert '<meta name="google-site-verification" content="gtok123">' in b
assert '<meta name="msvalidate.01" content="btok456">' in b
BRAND_PAGES = [B + p for p in ("/", "/climate", "/glossary", "/about", "/privacy")]
@pytest.mark.parametrize("path", BRAND_PAGES)
def test_brand_lockup_links_home(client, path):
html = client.get(path).text
brand = html.split('<div class="brand">', 1)[1].split("</header>", 1)[0]
head = brand.split("</h1>")[0] if "<h1" in brand else brand.split("</p>")[0]
assert "<a href=" in head, f"{path}: brand is not a link"
href = head.split('<a href="', 1)[1].split('"', 1)[0]
assert href in ("./", f"{B}/"), f"{path}: brand links to {href!r}, not home"
assert '<span class="logo">' in head.split("<a href=", 1)[1], \
f"{path}: the mark sits outside the home link"
def test_form_is_hidden_for_now(client):
"""The footer digest signup is temporarily hidden (commented out in the
base template). The /digest endpoint (backend-owned) stays live; only the
UI is pulled. Restore the form and flip this back to asserting presence.
Ported from backend/tests/notifications/test_digest.py (repo-split Stage 4)
-- every page it checks is frontend-owned now."""
for path in ["/", "/about", "/climate", "/privacy", "/glossary"]:
html = client.get(f"{B}{path}").text
assert "data-digest" not in html, path