2026-07-21 17:48:55 +00:00
|
|
|
|
"""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
|
|
|
|
|
|
|
2026-07-23 04:41:43 +00:00
|
|
|
|
import httpx
|
2026-07-21 17:48:55 +00:00
|
|
|
|
import pytest
|
|
|
|
|
|
|
2026-07-23 04:41:43 +00:00
|
|
|
|
import api_client
|
2026-07-21 17:48:55 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 04:41:43 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 17:48:55 +00:00
|
|
|
|
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">(Dec–Feb)</span>' in b
|
|
|
|
|
|
assert 'Summer <span class="season-span">(Jun–Aug)</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">(Dec–Feb)</span>' in b
|
|
|
|
|
|
assert 'Winter <span class="season-span">(Jun–Aug)</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("&", "&")
|
|
|
|
|
|
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"
|
2026-07-21 20:01:30 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|