thermograph/tests/test_content.py
Emi Griffith 59f6e11694 Month pages: show record high and low for every metric (#113)
The per-month records section listed only the warmest and coldest temperature.
Extend it to a card per metric (High, Low, Feels-like, Humidity, Wind, Gust,
Precip), each showing that calendar month's record high and low with the date.

Precip is special-cased: a per-day record low is just zero, and the dry-streak
helper would bridge year boundaries on month-filtered rows, so the wettest and
driest sides use the month's largest and smallest total accumulation, dated to
the year. Partial current months (< 26 days) are excluded so an unfinished month
can't win "driest" on a fraction of its rainfall.

Reuses the records page's card grid, so it's already vertical on mobile.

Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-16 05:13:51 +00:00

173 lines
7.2 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 crawlable SEO content pages: the city set, robots/sitemap, and
that the server-rendered pages contain the stats in the HTML (with the weather
layer faked, like test_api.py)."""
import pytest
from fastapi.testclient import TestClient
import app as appmod
import cities
import climate
# BASE defaults to /thermograph in tests (THERMOGRAPH_BASE unset).
B = "/thermograph"
SLUG = "london-england-gb" # present in cities.json
@pytest.fixture
def client(monkeypatch, history, recent):
monkeypatch.setattr(climate, "load_cached_history", lambda cell: history.clone())
monkeypatch.setattr(climate, "get_recent_forecast", lambda cell: recent.clone())
monkeypatch.setattr(climate, "get_history", lambda cell: (history.clone(), {"cached": True}))
return TestClient(appmod.app)
def test_city_set_slugs_unique_and_lookup():
slugs = cities.all_slugs()
assert len(slugs) == len(set(slugs)) >= 100
assert cities.get(SLUG) is not None
assert cities.get("does-not-exist") is None
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()
# a real number rendered (°F appears in the normals/today text)
assert "°F" in b
def test_city_404(client):
assert client.get(f"{B}/climate/nope-not-a-city").status_code == 404
def test_curated_events_have_valid_slugs():
import city_events
slugs = set(cities.all_slugs())
assert len(city_events.EVENTS) >= 20
assert all(s in slugs for s in city_events.EVENTS), \
[s for s in city_events.EVENTS if s not in slugs]
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 # falls back to the flavor blurb
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 # the city is pre-loaded on the compare page
def test_city_blurb_renders_with_attribution(client, monkeypatch):
monkeypatch.setattr(cities, "flavor",
lambda slug: {"extract": "Testville is a lovely place to test.",
"url": "https://en.wikipedia.org/wiki/Testville", "title": "Testville"})
b = client.get(f"{B}/climate/{SLUG}").text
assert "Testville is a lovely place to test." in b
assert "via Wikipedia" in b # CC BY-SA attribution link
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):
# The month page's records show a record high AND low for each metric present, not
# just warmest/coldest temperature — one card per metric with both extremes. (The
# test fixture carries tmax/tmin/precip; real cities add feels/humidity/wind/gust.)
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
# Every card carries a labelled high and low row (temp metrics use Highest/Lowest).
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
# Precip's extremes are the wettest/driest whole month by total accumulation.
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
# Monthly records: all 12 months, each linking to its month page.
assert "Records by month" in b
for month in ("January", "July", "December"):
assert month in b
assert f"/climate/{SLUG}/january" in b
# Seasonal records: labelled for London's Northern Hemisphere, with month spans.
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
# All-time table still present, plus structured data and real °F values.
assert "All-time records" in b
assert '"@type":"Dataset"' in b and "°F" in b
# Title/description advertise the new coverage.
assert "monthly" in b.lower() and "seasonal" in b.lower()
def test_records_seasons_flip_in_southern_hemisphere(client):
# São Paulo is south of the equator, so DecFeb is summer, not winter.
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):
# Temperatures wear the diverging cold→hot palette (a heat map, not a plain grid),
# and the city page carries the monthly temperature-range strip.
city = client.get(f"{B}/climate/{SLUG}").text
assert "t-cell t-" in city # tinted temperature cells in the normals table
assert "range-fill" in city and "--c1:var(--" in city # the monthly range strip's gradient bars
recs = client.get(f"{B}/climate/{SLUG}/records").text
assert "t-inline t-" in recs # tinted record chips
month = client.get(f"{B}/climate/{SLUG}/july").text
assert "t-inline t-" in month # tinted hero numbers + records
def test_records_show_both_extremes_per_metric(client):
# Each metric column shows BOTH its record warmest (▲) and coldest (▼) — so the
# daytime high carries its record-low high, and the overnight low its record-high low.
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
# 12 months × 2 metric columns × 2 extremes = 48 chips just in the monthly table.
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