"""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 pytest 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 "" in r.text assert f"/climate/{SLUG}/july" in r.text assert f"/climate/{SLUG}/records" 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", b) def _jsonld_breadcrumbs(html: str) -> list[dict]: import json m = re.search(r'', 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 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 (Dec–Feb)' in b assert 'Summer (Jun–Aug)' in b assert "All-time records" in b assert '"@type":"Dataset"' in b assert re.search(r"-?\d+°C", 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 (Dec–Feb)' in b assert 'Winter (Jun–Aug)' 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"{escape(content.PAGES[key]['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"", 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"(.*?)", 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'(.*?)", 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']*)>(-?\d+)°([CF]?)') 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'([\d.]+) (in|mm)') 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 '10 mph' == 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"([^<]+)", 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 '' in b assert '' 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('
', 1)[1].split("", 1)[0] head = brand.split("")[0] if "")[0] assert "