* SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
147 lines
5.8 KiB
Python
147 lines
5.8 KiB
Python
"""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_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">(Dec–Feb)</span>' in b
|
||
assert 'Summer <span class="season-span">(Jun–Aug)</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 Dec–Feb 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">(Dec–Feb)</span>' in b
|
||
assert 'Winter <span class="season-span">(Jun–Aug)</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-cell t-" in recs
|
||
month = client.get(f"{B}/climate/{SLUG}/july").text
|
||
assert "t-inline t-" in month # tinted hero numbers + records
|
||
|
||
|
||
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
|