thermograph/tests/test_content.py

242 lines
9.9 KiB
Python
Raw Normal View History

SEO: crawlable programmatic climate pages + technical hygiene (#96) * SEO: generate curated city set for crawlable climate pages gen_cities.py reuses the GeoNames index places.py already parses to select the top ~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when it repeats the city name), and writes committed backend/cities.json. cities.py loads it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping for the upcoming hub + sitemap. * SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene - content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt (disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates the home/static pages plus every city, month, and records URL from cities.py). Registered on the app before the StaticFiles mount so the routes win. - templates/base.html.j2: shared layout with unique title/description, self- referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate link) and a footer link graph. - Give each existing page a unique <meta description> (were 5x identical) and a self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page. - Pin jinja2. * SEO: server-rendered per-city climate page (/climate/{slug}) The keystone crawlable page: for a city it snaps to the grid cell, loads the archive (fetching once if missing, self-healing), and renders as real HTML — a 'how today compares' block (grade + percentile per metric from grade_day, tinted by tier), a monthly normals table (climatology at each month's 15th, shown in °F and °C), all-time records (new grading.all_time_records helper), a breadcrumb, Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into the interactive tool + month/records pages. Content-page CSS added to style.css (renamed the table class to avoid colliding with the app's .normals flex row). * SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages Month pages render the exact-month long-tail ('average weather in {city} in {month}') with that month's average high/low, typical p10-p90 range, month-specific records, and prev/next month links. Records pages show all-time record highs/lows per metric with dates (grading.all_time_records). Shared _resolve_city helper; the literal /records route is registered before the {month} param and month names are validated (unknown month -> 404). * SEO: climate hub, weather glossary, and about/methodology pages - /climate: crawlable directory of all ~500 cities grouped by country — the internal-link graph that lets search engines discover every city page. - /glossary + /glossary/{term}: plain-language definitions (climate normal, percentile, temperature anomaly, feels-like, heat index, wind chill, humidity, reanalysis) with DefinedTerm JSON-LD and cross-links into the tool. - /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window, percentile grading) for E-E-A-T. All linked from the shared footer. * SEO: archive warmer, content-page tests, and deploy docs - warm_cities.py: paced, idempotent offline warmer that pre-fetches each city cell's archive so /climate pages serve from cache and a crawl can't burst the archive quota (pages self-heal if hit before warming). - tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap enumerating city/month/records URLs, and that a rendered city page carries the stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/ about routing and 404s. - DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
"""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
SEO: crawlable programmatic climate pages + technical hygiene (#96) * SEO: generate curated city set for crawlable climate pages gen_cities.py reuses the GeoNames index places.py already parses to select the top ~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when it repeats the city name), and writes committed backend/cities.json. cities.py loads it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping for the upcoming hub + sitemap. * SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene - content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt (disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates the home/static pages plus every city, month, and records URL from cities.py). Registered on the app before the StaticFiles mount so the routes win. - templates/base.html.j2: shared layout with unique title/description, self- referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate link) and a footer link graph. - Give each existing page a unique <meta description> (were 5x identical) and a self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page. - Pin jinja2. * SEO: server-rendered per-city climate page (/climate/{slug}) The keystone crawlable page: for a city it snaps to the grid cell, loads the archive (fetching once if missing, self-healing), and renders as real HTML — a 'how today compares' block (grade + percentile per metric from grade_day, tinted by tier), a monthly normals table (climatology at each month's 15th, shown in °F and °C), all-time records (new grading.all_time_records helper), a breadcrumb, Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into the interactive tool + month/records pages. Content-page CSS added to style.css (renamed the table class to avoid colliding with the app's .normals flex row). * SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages Month pages render the exact-month long-tail ('average weather in {city} in {month}') with that month's average high/low, typical p10-p90 range, month-specific records, and prev/next month links. Records pages show all-time record highs/lows per metric with dates (grading.all_time_records). Shared _resolve_city helper; the literal /records route is registered before the {month} param and month names are validated (unknown month -> 404). * SEO: climate hub, weather glossary, and about/methodology pages - /climate: crawlable directory of all ~500 cities grouped by country — the internal-link graph that lets search engines discover every city page. - /glossary + /glossary/{term}: plain-language definitions (climate normal, percentile, temperature anomaly, feels-like, heat index, wind chill, humidity, reanalysis) with DefinedTerm JSON-LD and cross-links into the tool. - /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window, percentile grading) for E-E-A-T. All linked from the shared footer. * SEO: archive warmer, content-page tests, and deploy docs - warm_cities.py: paced, idempotent offline warmer that pre-fetches each city cell's archive so /climate pages serve from cache and a crawl can't burst the archive quota (pages self-heal if hit before warming). - tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap enumerating city/month/records URLs, and that a rendered city page carries the stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/ about routing and 404s. - DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
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
SEO: monthly & seasonal records + colour-coded climate pages (#107) * 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.
2026-07-16 03:50:59 +00:00
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
SEO: monthly & seasonal records + colour-coded climate pages (#107) * 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.
2026-07-16 03:50:59 +00:00
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
SEO: crawlable programmatic climate pages + technical hygiene (#96) * SEO: generate curated city set for crawlable climate pages gen_cities.py reuses the GeoNames index places.py already parses to select the top ~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when it repeats the city name), and writes committed backend/cities.json. cities.py loads it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping for the upcoming hub + sitemap. * SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene - content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt (disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates the home/static pages plus every city, month, and records URL from cities.py). Registered on the app before the StaticFiles mount so the routes win. - templates/base.html.j2: shared layout with unique title/description, self- referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate link) and a footer link graph. - Give each existing page a unique <meta description> (were 5x identical) and a self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page. - Pin jinja2. * SEO: server-rendered per-city climate page (/climate/{slug}) The keystone crawlable page: for a city it snaps to the grid cell, loads the archive (fetching once if missing, self-healing), and renders as real HTML — a 'how today compares' block (grade + percentile per metric from grade_day, tinted by tier), a monthly normals table (climatology at each month's 15th, shown in °F and °C), all-time records (new grading.all_time_records helper), a breadcrumb, Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into the interactive tool + month/records pages. Content-page CSS added to style.css (renamed the table class to avoid colliding with the app's .normals flex row). * SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages Month pages render the exact-month long-tail ('average weather in {city} in {month}') with that month's average high/low, typical p10-p90 range, month-specific records, and prev/next month links. Records pages show all-time record highs/lows per metric with dates (grading.all_time_records). Shared _resolve_city helper; the literal /records route is registered before the {month} param and month names are validated (unknown month -> 404). * SEO: climate hub, weather glossary, and about/methodology pages - /climate: crawlable directory of all ~500 cities grouped by country — the internal-link graph that lets search engines discover every city page. - /glossary + /glossary/{term}: plain-language definitions (climate normal, percentile, temperature anomaly, feels-like, heat index, wind chill, humidity, reanalysis) with DefinedTerm JSON-LD and cross-links into the tool. - /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window, percentile grading) for E-E-A-T. All linked from the shared footer. * SEO: archive warmer, content-page tests, and deploy docs - warm_cities.py: paced, idempotent offline warmer that pre-fetches each city cell's archive so /climate pages serve from cache and a crawl can't burst the archive quota (pages self-heal if hit before warming). - tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap enumerating city/month/records URLs, and that a rendered city page carries the stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/ about routing and 404s. - DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
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_climate_pages_carry_convertible_temps(client):
# Every temperature is a client-convertible span (default °F text so crawlers
# and no-JS visitors still see a real value); the old inline "(NN°C)" dual form
# is gone (the toggle now supplies Celsius).
import re
for path in (f"{B}/climate/{SLUG}", f"{B}/climate/{SLUG}/july", f"{B}/climate/{SLUG}/records"):
b = client.get(path).text
assert 'class="temp" data-temp-f=' in b
assert "°F" in b
assert re.search(r"\d+°F \(-?\d+°C\)", b) is None # no leftover dual-unit strings
def test_seo_pages_load_unit_and_account_scripts(client):
# Header parity with the interactive pages: the °F/°C toggle, account/bell, and
# the temperature converter are all loaded.
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):
import indexnow
k = indexnow.key()
r = client.get(f"{B}/{k}.txt")
assert r.status_code == 200
assert r.text.strip() == k
assert r.headers["content-type"].startswith("text/plain")
def test_sitemap_lastmod_is_stable(client):
import datetime
import re
body = client.get(f"{B}/sitemap.xml").text
mods = set(re.findall(r"<lastmod>([^<]+)</lastmod>", body))
assert len(mods) == 1 # one build date, not a per-request churn
datetime.date.fromisoformat(next(iter(mods))) # a valid ISO date
def test_indexnow_submit_all_builds_payload(monkeypatch):
import indexnow
calls = []
class _Resp:
status_code = 200
text = "ok"
monkeypatch.setattr(indexnow.httpx, "post",
lambda url, json=None, **kw: (calls.append((url, json)), _Resp())[1])
res = indexnow.submit_all("https://thermograph.org")
assert res["submitted"] == res["total"] > 100
first = calls[0][1]
assert first["host"] == "thermograph.org"
assert first["keyLocation"] == f"https://thermograph.org/{indexnow.key()}.txt"
assert first["urlList"][0] == "https://thermograph.org/"
assert all(len(c[1]["urlList"]) <= 10000 for c in calls) # batched under the IndexNow cap
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 # Jinja SEO page
home = client.get(f"{B}/").text # static homepage via _page()
for b in (seo, home):
assert '<meta name="google-site-verification" content="gtok123">' in b
assert '<meta name="msvalidate.01" content="btok456">' in b