Subtree-merge thermograph-frontend origin/dev into frontend/ (two-tier test suite; CI workflow ported to root)
# Conflicts: # frontend/.forgejo/workflows/build.yml
This commit is contained in:
commit
2b775abe6f
21 changed files with 77170 additions and 741 deletions
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
|
|
@ -3,3 +3,4 @@ __pycache__/
|
|||
*.pyc
|
||||
.DS_Store
|
||||
.env
|
||||
.venv-test/
|
||||
|
|
|
|||
26
frontend/Makefile
Normal file
26
frontend/Makefile
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# thermograph-frontend — local dev/test targets.
|
||||
.PHONY: help test test-unit test-integration backend-up backend-down capture-fixtures clean-venv
|
||||
|
||||
help: ## List targets
|
||||
@grep -hE '^[a-z][a-zA-Z0-9_-]*:.*## ' $(MAKEFILE_LIST) | awk -F':.*## ' '{printf " %-18s %s\n", $$1, $$2}'
|
||||
|
||||
test: ## Run the whole suite (unit hermetic + integration against a real backend)
|
||||
./scripts/test.sh $(ARGS)
|
||||
|
||||
test-unit: ## Hermetic unit tier — SSR rendering fed committed fixtures, no Docker
|
||||
./scripts/test.sh tests/unit $(ARGS)
|
||||
|
||||
test-integration: ## Integration tier — pulls + runs the real backend image, tests the live contract
|
||||
./scripts/test.sh tests/integration $(ARGS)
|
||||
|
||||
backend-up: ## Pull + run the backend container for local dev (prints its base URL)
|
||||
./scripts/backend-for-tests.sh up
|
||||
|
||||
backend-down: ## Stop the local backend container
|
||||
./scripts/backend-for-tests.sh down
|
||||
|
||||
capture-fixtures: ## Refresh tests/fixtures/*.json from a live backend
|
||||
./scripts/capture-fixtures.sh
|
||||
|
||||
clean-venv: ## Remove the test venv
|
||||
rm -rf .venv-test
|
||||
40
frontend/docker-compose.test.yml
Normal file
40
frontend/docker-compose.test.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Test harness: pull the published BACKEND image and run it (+ a throwaway TimescaleDB)
|
||||
# so the frontend's integration tests exercise the real HTTP contract instead of
|
||||
# importing backend source. Used by scripts/backend-for-tests.sh; not a deploy file.
|
||||
# The frontend itself is NOT a service here — the tests run the SSR app in-process
|
||||
# (TestClient) with api_client pointed at this backend. DB data is tmpfs (ephemeral).
|
||||
services:
|
||||
db:
|
||||
image: timescale/timescaledb:${TIMESCALEDB_TAG:-latest-pg18}
|
||||
environment:
|
||||
POSTGRES_USER: thermograph
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-testing}
|
||||
POSTGRES_DB: thermograph
|
||||
tmpfs:
|
||||
# pg18 places PGDATA in a version subdir under the mount — mount at /var/lib/postgresql.
|
||||
- /var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U thermograph -d thermograph"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
backend:
|
||||
# The published backend image is pulled (no local build). Default a published tag;
|
||||
# override THERMOGRAPH_BACKEND_TEST_TAG to pin a specific backend build (e.g. a sha).
|
||||
image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph-backend/app}:${THERMOGRAPH_BACKEND_TEST_TAG:-v0.0.2-split-ci}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph:${POSTGRES_PASSWORD:-testing}@db:5432/thermograph
|
||||
# Required at import; the frontend tests never drive the backend's reverse proxy,
|
||||
# so an unreachable placeholder is fine.
|
||||
THERMOGRAPH_FRONTEND_BASE_INTERNAL: http://127.0.0.1:1
|
||||
THERMOGRAPH_BASE: "/"
|
||||
THERMOGRAPH_ENABLE_NOTIFIER: "0"
|
||||
PORT: "8137"
|
||||
WORKERS: "1"
|
||||
ports:
|
||||
# Host port defaults to 18137 to avoid a dev server on 8137.
|
||||
- "127.0.0.1:${BACKEND_TEST_HOST_PORT:-18137}:8137"
|
||||
2
frontend/requirements-dev.txt
Normal file
2
frontend/requirements-dev.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
-r requirements.txt
|
||||
pytest==8.4.1
|
||||
44
frontend/scripts/backend-for-tests.sh
Executable file
44
frontend/scripts/backend-for-tests.sh
Executable file
|
|
@ -0,0 +1,44 @@
|
|||
#!/usr/bin/env bash
|
||||
# Pull the published backend image and run it (+ a throwaway DB) for the frontend's
|
||||
# integration tests, or for local dev. The frontend's api_client then makes real HTTP
|
||||
# to it instead of importing backend source.
|
||||
#
|
||||
# scripts/backend-for-tests.sh up # pull + start, wait until healthy, print base URL
|
||||
# scripts/backend-for-tests.sh down # stop + remove (incl. the throwaway db volume)
|
||||
# scripts/backend-for-tests.sh url # print the base URL (no lifecycle change)
|
||||
#
|
||||
# Which backend build: THERMOGRAPH_BACKEND_TEST_TAG (default a published tag; set a
|
||||
# sha-<12hex> to pin one). Host port: BACKEND_TEST_HOST_PORT (default 18137).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-testing}"
|
||||
export BACKEND_TEST_HOST_PORT="${BACKEND_TEST_HOST_PORT:-18137}"
|
||||
BASE_URL="http://127.0.0.1:${BACKEND_TEST_HOST_PORT}"
|
||||
COMPOSE=(docker compose -f docker-compose.test.yml)
|
||||
|
||||
case "${1:-up}" in
|
||||
up)
|
||||
echo "==> pulling backend image (${THERMOGRAPH_BACKEND_TEST_TAG:-v0.0.2-split-ci})" >&2
|
||||
"${COMPOSE[@]}" pull backend >&2
|
||||
echo "==> starting backend + db" >&2
|
||||
"${COMPOSE[@]}" up -d >&2
|
||||
echo "==> waiting for backend /healthz (up to 90s)" >&2
|
||||
for _ in $(seq 1 45); do
|
||||
curl -fsS -o /dev/null "$BASE_URL/healthz" 2>/dev/null && { echo "$BASE_URL"; exit 0; }
|
||||
sleep 2
|
||||
done
|
||||
echo "!! backend never became healthy" >&2
|
||||
"${COMPOSE[@]}" logs --tail=40 backend >&2
|
||||
exit 1
|
||||
;;
|
||||
down)
|
||||
"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
echo "==> backend stopped" >&2
|
||||
;;
|
||||
url)
|
||||
echo "$BASE_URL"
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 {up|down|url}" >&2; exit 2 ;;
|
||||
esac
|
||||
31
frontend/scripts/capture-fixtures.sh
Executable file
31
frontend/scripts/capture-fixtures.sh
Executable file
|
|
@ -0,0 +1,31 @@
|
|||
#!/usr/bin/env bash
|
||||
# Refresh the committed unit-test fixtures (tests/fixtures/*.json) by capturing the
|
||||
# real payloads a live backend returns for a known city. Run when the backend's content
|
||||
# contract changes. Brings the backend harness up, saves each api_client endpoint's
|
||||
# response, and tears it down. Requires jq for pretty output.
|
||||
#
|
||||
# scripts/capture-fixtures.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
SLUG="${FIXTURE_CITY:-london-england-gb}" # GB city -> Celsius rendering
|
||||
MONTH="${FIXTURE_MONTH:-july}"
|
||||
OUT=tests/fixtures
|
||||
mkdir -p "$OUT"
|
||||
|
||||
BASE=$(scripts/backend-for-tests.sh up)
|
||||
trap 'scripts/backend-for-tests.sh down >/dev/null 2>&1 || true' EXIT
|
||||
|
||||
fetch() { # <endpoint-path> <fixture-name>
|
||||
echo "==> $2.json <- $1"
|
||||
curl -fsS "$BASE/$1" --max-time 40 | { command -v jq >/dev/null && jq . || cat; } > "$OUT/$2.json"
|
||||
}
|
||||
|
||||
fetch "api/v2/content/home" home
|
||||
fetch "api/v2/content/sitemap" sitemap
|
||||
fetch "api/v2/content/hub" hub
|
||||
fetch "api/v2/content/city/$SLUG" city
|
||||
fetch "api/v2/content/city/$SLUG/month/$MONTH" month
|
||||
fetch "api/v2/content/city/$SLUG/records" records
|
||||
|
||||
echo "==> captured $(ls "$OUT"/*.json | wc -l) fixtures for $SLUG into $OUT/"
|
||||
26
frontend/scripts/test.sh
Executable file
26
frontend/scripts/test.sh
Executable file
|
|
@ -0,0 +1,26 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build a Python 3.12 dev venv and run pytest. Pass pytest args through:
|
||||
# ./scripts/test.sh tests/unit # hermetic unit tier (no Docker)
|
||||
# ./scripts/test.sh tests/integration # integration tier (pulls + runs the backend)
|
||||
# ./scripts/test.sh # everything
|
||||
# 3.12 explicitly because a common box python (pyenv 3.10) lacks _sqlite3 and the pinned
|
||||
# deps target 3.12.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
VENV="${VENV:-.venv-test}"
|
||||
if [ ! -x "$VENV/bin/pytest" ]; then
|
||||
echo "==> Building dev venv ($VENV) on Python 3.12"
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
uv venv --python 3.12 "$VENV"
|
||||
VIRTUAL_ENV="$VENV" uv pip install -q -r requirements-dev.txt
|
||||
else
|
||||
PY="$(command -v python3.12 || echo "$HOME/.local/bin/python3.12")"
|
||||
"$PY" -m venv "$VENV"
|
||||
"$VENV/bin/pip" install -q --upgrade pip
|
||||
"$VENV/bin/pip" install -q -r requirements-dev.txt
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "==> pytest ($("$VENV/bin/python" --version))"
|
||||
exec "$VENV/bin/python" -m pytest "$@"
|
||||
|
|
@ -1,108 +1,45 @@
|
|||
"""Shared test setup for the SSR service.
|
||||
"""Shared SSR test setup — self-contained, NO backend checkout.
|
||||
|
||||
KNOWN GAP (repo-split Stage 7b): this suite does NOT run standalone in this
|
||||
repo. It still assumes the layout from before the split -- a sibling
|
||||
`backend/` checkout with its own `tests/conftest.py` (`make_history`/
|
||||
`make_recent`) and `api.content_payloads` module, which this repo no longer
|
||||
has. Extracted verbatim as reference material for the real fix (a genuine
|
||||
cross-repo contract-test job, or a self-contained set of fixtures owned
|
||||
here), not yet built -- see this repo's `.forgejo/workflows/build.yml` for
|
||||
the same gap on the boot-check side. Do not assume `pytest tests` passes
|
||||
here until that follow-up lands.
|
||||
|
||||
Original docstring, still accurate for what the suite is trying to prove:
|
||||
this package used to live in the same repo as the backend, so a `client`
|
||||
fixture could fake api_client by calling the REAL backend payload builders
|
||||
(api.content_payloads) against the same synthetic history/recent fixtures
|
||||
backend/tests/conftest.py uses, instead of hand-maintaining a parallel set of
|
||||
literal JSON fixtures -- so a shape drift between content_payloads.py and how
|
||||
content.py consumes it failed here too, not just in the backend's own suite.
|
||||
Replaces the old sibling-`backend/` coupling. Two tiers:
|
||||
* unit (tests/unit/) — hermetic. The `client` fixture serves the SSR app with
|
||||
`api_client` monkeypatched to return committed fixtures (tests/fixtures/, refreshed
|
||||
by scripts/capture-fixtures.sh). No backend, no Docker.
|
||||
* integration (tests/integration/) — the `live_client` fixture serves the SSR app with
|
||||
`api_client` pointed at a REAL backend container (scripts/backend-for-tests.sh pulls
|
||||
+ runs it). Skips automatically when Docker/the image isn't available.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
FRONTEND_SSR = os.path.dirname(_HERE)
|
||||
REPO_ROOT = os.path.dirname(FRONTEND_SSR)
|
||||
BACKEND = os.path.join(REPO_ROOT, "backend")
|
||||
|
||||
# BACKEND first: content_payloads.py's own dependency chain (data/cities.py,
|
||||
# data/store.py, ...) does a bare `import paths` that must resolve to
|
||||
# backend/paths.py while THAT chain loads below.
|
||||
sys.path.insert(0, BACKEND)
|
||||
REPO = os.path.dirname(_HERE)
|
||||
if REPO not in sys.path:
|
||||
sys.path.insert(0, REPO) # SSR modules (content.py, app.py, api_client.py) live at repo root
|
||||
|
||||
# api_client raises at import if this is unset. Unit tests monkeypatch api_client so it is
|
||||
# never used; the integration fixture overrides it to the live backend.
|
||||
os.environ.setdefault("THERMOGRAPH_API_BASE_INTERNAL", "http://backend.invalid")
|
||||
os.environ.setdefault("THERMOGRAPH_BASE", "/thermograph")
|
||||
|
||||
import content # noqa: E402 — imports api_client under the env set above
|
||||
|
||||
def _load_module(name: str, path: str):
|
||||
"""importlib rather than a bare `import conftest` -- pytest registers
|
||||
THIS file's own module under the plain name "conftest" too, so a bare
|
||||
import here could self-collide instead of resolving to backend's file."""
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
# Reuse the exact synthetic weather fixtures backend/tests/conftest.py
|
||||
# defines (its module-level code also sandboxes data/store/audit paths into a
|
||||
# tmp dir), so a page rendered here and the same page rendered by the
|
||||
# backend's own test_content.py agree on every number.
|
||||
_backend_conftest = _load_module("_backend_test_conftest", os.path.join(BACKEND, "tests", "conftest.py"))
|
||||
make_history = _backend_conftest.make_history
|
||||
make_recent = _backend_conftest.make_recent
|
||||
|
||||
from api import content_payloads # noqa: E402
|
||||
from api import sitemap as sitemap_mod # noqa: E402
|
||||
from data import cities # noqa: E402
|
||||
|
||||
# frontend_ssr/ has its OWN paths.py and app.py -- genuinely different modules
|
||||
# that happen to share backend's bare top-level names. data/cities.py etc.
|
||||
# above already bound their own `paths` name to backend's module object
|
||||
# (permanent, unaffected by anything below); swap sys.modules["paths"] to
|
||||
# frontend_ssr's version just for the span of importing content.py (which
|
||||
# cascades into content_loader.py, the other consumer), then restore it so any
|
||||
# LATER lazy backend import (api.homepage's own `import paths`, first
|
||||
# triggered whenever a test actually calls content_payloads.home_payload())
|
||||
# still sees backend's version, not frontend's.
|
||||
_backend_paths_module = sys.modules.get("paths")
|
||||
_frontend_paths_spec = importlib.util.spec_from_file_location("paths", os.path.join(FRONTEND_SSR, "paths.py"))
|
||||
_frontend_paths_module = importlib.util.module_from_spec(_frontend_paths_spec)
|
||||
sys.modules["paths"] = _frontend_paths_module
|
||||
_frontend_paths_spec.loader.exec_module(_frontend_paths_module)
|
||||
|
||||
# Reprioritize sys.path so "app" -- imported lazily, per-test, by the `client`
|
||||
# fixture below -- resolves to frontend_ssr/app.py rather than backend's
|
||||
# app.py (the `app:app` launch shim). Nothing triggers a bare `import app`
|
||||
# before that point, so ordering (not a swap) is enough for this one name.
|
||||
sys.path.insert(0, FRONTEND_SSR)
|
||||
|
||||
import api_client # noqa: E402
|
||||
import content # noqa: E402
|
||||
|
||||
if _backend_paths_module is not None:
|
||||
sys.modules["paths"] = _backend_paths_module
|
||||
else:
|
||||
del sys.modules["paths"]
|
||||
|
||||
B = content.BASE
|
||||
SLUG = "london-england-gb" # present in cities.json; GB -> renders in Celsius
|
||||
B = content.BASE # "/thermograph"
|
||||
SLUG = "london-england-gb" # captured into tests/fixtures/ (GB -> Celsius)
|
||||
MONTH = "july"
|
||||
FAKE_INDEXNOW_KEY = "test0000indexnow0000key000000000"
|
||||
_FIXTURE_NAMES = ("home", "sitemap", "hub", "city", "month", "records")
|
||||
|
||||
_HARNESS = os.path.join(REPO, "scripts", "backend-for-tests.sh")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def history():
|
||||
return make_history()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def recent(history):
|
||||
return make_recent(history)
|
||||
def load_fixture(name: str):
|
||||
with open(os.path.join(_HERE, "fixtures", f"{name}.json"), encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def _not_found(detail: str) -> httpx.HTTPStatusError:
|
||||
|
|
@ -111,47 +48,75 @@ def _not_found(detail: str) -> httpx.HTTPStatusError:
|
|||
return httpx.HTTPStatusError(detail, request=req, response=resp)
|
||||
|
||||
|
||||
# --- unit tier ---------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, history, recent):
|
||||
"""A TestClient over the SSR app, with api_client's HTTP calls replaced by
|
||||
direct calls into the real backend payload builders -- see module
|
||||
docstring. Origin defaults to the TestClient's own base URL, matching what
|
||||
a real request through content.py's _origin(request) would forward."""
|
||||
def client(monkeypatch):
|
||||
"""TestClient over the SSR app with api_client replaced by the committed fixtures.
|
||||
Exercises the rendering layer (content.py + format.py) with zero backend."""
|
||||
from fastapi.testclient import TestClient
|
||||
import api_client
|
||||
import app as ssr_app
|
||||
|
||||
fx = {n: load_fixture(n) for n in _FIXTURE_NAMES}
|
||||
monkeypatch.setattr(api_client, "home", lambda: fx["home"])
|
||||
monkeypatch.setattr(api_client, "sitemap", lambda: fx["sitemap"])
|
||||
monkeypatch.setattr(api_client, "hub", lambda: fx["hub"])
|
||||
monkeypatch.setattr(api_client, "indexnow_key", lambda: FAKE_INDEXNOW_KEY)
|
||||
|
||||
def _city(slug, *, origin=None):
|
||||
c = cities.get(slug)
|
||||
if c is None:
|
||||
if slug != SLUG:
|
||||
raise _not_found("Unknown city.")
|
||||
return content_payloads.city_payload(origin or "http://testserver", B, c, history, recent)
|
||||
return fx["city"]
|
||||
|
||||
def _city_month(slug, month):
|
||||
c = cities.get(slug)
|
||||
if c is None:
|
||||
raise _not_found("Unknown city.")
|
||||
if month not in content_payloads.MONTH_INDEX:
|
||||
def _month(slug, month):
|
||||
if slug != SLUG or month != MONTH:
|
||||
raise _not_found("Unknown month.")
|
||||
return content_payloads.month_payload(B, c, history, content_payloads.MONTH_INDEX[month])
|
||||
return fx["month"]
|
||||
|
||||
def _city_records(slug, *, origin=None):
|
||||
c = cities.get(slug)
|
||||
if c is None:
|
||||
def _records(slug, *, origin=None):
|
||||
if slug != SLUG:
|
||||
raise _not_found("Unknown city.")
|
||||
return content_payloads.records_payload(origin or "http://testserver", B, c, history)
|
||||
|
||||
def _sitemap():
|
||||
return [{"path": p, "changefreq": cf, "priority": pr} for p, cf, pr in sitemap_mod.sitemap_entries()]
|
||||
return fx["records"]
|
||||
|
||||
monkeypatch.setattr(api_client, "city", _city)
|
||||
monkeypatch.setattr(api_client, "city_month", _city_month)
|
||||
monkeypatch.setattr(api_client, "city_records", _city_records)
|
||||
monkeypatch.setattr(api_client, "hub", content_payloads.hub_payload)
|
||||
monkeypatch.setattr(api_client, "home", content_payloads.home_payload)
|
||||
monkeypatch.setattr(api_client, "sitemap", _sitemap)
|
||||
monkeypatch.setattr(api_client, "indexnow_key", lambda: FAKE_INDEXNOW_KEY)
|
||||
# api_client's TTL cache is keyed by path/origin, not by test -- stale
|
||||
# entries from an earlier test would otherwise leak real-looking but
|
||||
# wrong data into this one.
|
||||
api_client._cache.clear()
|
||||
monkeypatch.setattr(api_client, "city_month", _month)
|
||||
monkeypatch.setattr(api_client, "city_records", _records)
|
||||
return TestClient(ssr_app.app)
|
||||
|
||||
import app as appmod
|
||||
|
||||
# --- integration tier --------------------------------------------------------
|
||||
def _docker_ok() -> bool:
|
||||
try:
|
||||
return subprocess.run(["docker", "info"], capture_output=True, timeout=15).returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def backend_service():
|
||||
"""Pull + run a real backend container; yield its base URL. Skips if Docker isn't
|
||||
available or the image can't be pulled/started, so the unit tier still runs anywhere."""
|
||||
if not _docker_ok():
|
||||
pytest.skip("Docker not available — skipping integration tier")
|
||||
try:
|
||||
url = subprocess.run([_HARNESS, "up"], capture_output=True, text=True, timeout=300, check=True).stdout.strip()
|
||||
except subprocess.CalledProcessError as e:
|
||||
pytest.skip(f"backend harness failed to start (image unavailable?): {e.stderr[-300:]}")
|
||||
try:
|
||||
yield url
|
||||
finally:
|
||||
subprocess.run([_HARNESS, "down"], capture_output=True, timeout=60)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live_client(backend_service, monkeypatch):
|
||||
"""TestClient over the SSR app with api_client making REAL HTTP to the live backend
|
||||
(which runs THERMOGRAPH_BASE=/, so the API is at /api/v{N}, no prefix)."""
|
||||
from fastapi.testclient import TestClient
|
||||
return TestClient(appmod.app)
|
||||
import api_client
|
||||
import app as ssr_app
|
||||
|
||||
monkeypatch.setattr(api_client, "API_BASE", backend_service)
|
||||
monkeypatch.setattr(api_client, "_BASE_PREFIX", "")
|
||||
api_client._cache.clear()
|
||||
return TestClient(ssr_app.app)
|
||||
|
|
|
|||
328
frontend/tests/fixtures/city.json
vendored
Normal file
328
frontend/tests/fixtures/city.json
vendored
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
{
|
||||
"city": {
|
||||
"slug": "london-england-gb",
|
||||
"name": "London",
|
||||
"admin1": "England",
|
||||
"country": "United Kingdom",
|
||||
"country_code": "GB",
|
||||
"lat": 51.50853,
|
||||
"lon": -0.12574,
|
||||
"population": 8961989
|
||||
},
|
||||
"display": "London, England, United Kingdom",
|
||||
"title": "London, GB",
|
||||
"year_range": [
|
||||
1980,
|
||||
2026
|
||||
],
|
||||
"n_years": 46,
|
||||
"months": [
|
||||
{
|
||||
"name": "January",
|
||||
"slug": "january",
|
||||
"high_f": 44.5,
|
||||
"low_f": 35.3,
|
||||
"range_lo_f": 27.2,
|
||||
"range_hi_f": 51.9,
|
||||
"precip_f": 0.1
|
||||
},
|
||||
{
|
||||
"name": "February",
|
||||
"slug": "february",
|
||||
"high_f": 45.6,
|
||||
"low_f": 35.1,
|
||||
"range_lo_f": 26.9,
|
||||
"range_hi_f": 52.8,
|
||||
"precip_f": 0.1
|
||||
},
|
||||
{
|
||||
"name": "March",
|
||||
"slug": "march",
|
||||
"high_f": 50.7,
|
||||
"low_f": 38.3,
|
||||
"range_lo_f": 30.9,
|
||||
"range_hi_f": 57.1,
|
||||
"precip_f": 0.1
|
||||
},
|
||||
{
|
||||
"name": "April",
|
||||
"slug": "april",
|
||||
"high_f": 55.5,
|
||||
"low_f": 40.1,
|
||||
"range_lo_f": 33.4,
|
||||
"range_hi_f": 63.4,
|
||||
"precip_f": 0.1
|
||||
},
|
||||
{
|
||||
"name": "May",
|
||||
"slug": "may",
|
||||
"high_f": 61.5,
|
||||
"low_f": 46.5,
|
||||
"range_lo_f": 40.3,
|
||||
"range_hi_f": 69.9,
|
||||
"precip_f": 0.1
|
||||
},
|
||||
{
|
||||
"name": "June",
|
||||
"slug": "june",
|
||||
"high_f": 67.0,
|
||||
"low_f": 52.2,
|
||||
"range_lo_f": 46.5,
|
||||
"range_hi_f": 75.3,
|
||||
"precip_f": 0.1
|
||||
},
|
||||
{
|
||||
"name": "July",
|
||||
"slug": "july",
|
||||
"high_f": 71.1,
|
||||
"low_f": 56.2,
|
||||
"range_lo_f": 51.3,
|
||||
"range_hi_f": 79.8,
|
||||
"precip_f": 0.1
|
||||
},
|
||||
{
|
||||
"name": "August",
|
||||
"slug": "august",
|
||||
"high_f": 71.0,
|
||||
"low_f": 56.5,
|
||||
"range_lo_f": 51.2,
|
||||
"range_hi_f": 78.9,
|
||||
"precip_f": 0.1
|
||||
},
|
||||
{
|
||||
"name": "September",
|
||||
"slug": "september",
|
||||
"high_f": 65.6,
|
||||
"low_f": 52.4,
|
||||
"range_lo_f": 46.7,
|
||||
"range_hi_f": 72.1,
|
||||
"precip_f": 0.1
|
||||
},
|
||||
{
|
||||
"name": "October",
|
||||
"slug": "october",
|
||||
"high_f": 58.4,
|
||||
"low_f": 47.2,
|
||||
"range_lo_f": 39.6,
|
||||
"range_hi_f": 64.2,
|
||||
"precip_f": 0.1
|
||||
},
|
||||
{
|
||||
"name": "November",
|
||||
"slug": "november",
|
||||
"high_f": 50.5,
|
||||
"low_f": 41.0,
|
||||
"range_lo_f": 32.1,
|
||||
"range_hi_f": 57.3,
|
||||
"precip_f": 0.1
|
||||
},
|
||||
{
|
||||
"name": "December",
|
||||
"slug": "december",
|
||||
"high_f": 45.7,
|
||||
"low_f": 36.9,
|
||||
"range_lo_f": 27.5,
|
||||
"range_hi_f": 53.5,
|
||||
"precip_f": 0.1
|
||||
}
|
||||
],
|
||||
"warmest_month_slug": "july",
|
||||
"coldest_month_slug": "february",
|
||||
"wettest_month_slug": "january",
|
||||
"all_time_records": {
|
||||
"tmax": {
|
||||
"max": 97.5,
|
||||
"max_date": "2022-07-19",
|
||||
"min": 20.3,
|
||||
"min_date": "1987-01-12"
|
||||
},
|
||||
"tmin": {
|
||||
"max": 71.8,
|
||||
"max_date": "2022-07-19",
|
||||
"min": 3.3,
|
||||
"min_date": "1981-12-13"
|
||||
},
|
||||
"feels": {
|
||||
"max": 100.6,
|
||||
"max_date": "2019-07-25",
|
||||
"min": -5.3,
|
||||
"min_date": "1986-02-10"
|
||||
},
|
||||
"fmax": {
|
||||
"max": 100.6,
|
||||
"max_date": "2019-07-25",
|
||||
"min": 11.8,
|
||||
"min_date": "1987-01-12"
|
||||
},
|
||||
"fmin": {
|
||||
"max": 73.2,
|
||||
"max_date": "2026-06-26",
|
||||
"min": -5.3,
|
||||
"min_date": "1986-02-10"
|
||||
},
|
||||
"humid": {
|
||||
"max": 17.4,
|
||||
"max_date": "2026-06-24",
|
||||
"min": 1.9,
|
||||
"min_date": "1987-01-12"
|
||||
},
|
||||
"wind": {
|
||||
"max": 41.5,
|
||||
"max_date": "1987-10-16",
|
||||
"min": 2.3,
|
||||
"min_date": "2024-12-27"
|
||||
},
|
||||
"gust": {
|
||||
"max": 81.7,
|
||||
"max_date": "2000-10-30",
|
||||
"min": 5.8,
|
||||
"min_date": "2024-12-27"
|
||||
},
|
||||
"precip": {
|
||||
"max": 1.23,
|
||||
"max_date": "2021-06-18",
|
||||
"min": 0.0,
|
||||
"min_date": "1980-01-01"
|
||||
}
|
||||
},
|
||||
"today_vs_normal": {
|
||||
"date": "2026-07-22",
|
||||
"cards": [
|
||||
{
|
||||
"label": "High",
|
||||
"metric": "tmax",
|
||||
"value_f": 79.4,
|
||||
"percentile": 89.1,
|
||||
"grade": "High",
|
||||
"cls": "hot"
|
||||
},
|
||||
{
|
||||
"label": "Low",
|
||||
"metric": "tmin",
|
||||
"value_f": 62.8,
|
||||
"percentile": 94.3,
|
||||
"grade": "Very High",
|
||||
"cls": "very-hot"
|
||||
},
|
||||
{
|
||||
"label": "Feels-like",
|
||||
"metric": "feels",
|
||||
"value_f": 78.0,
|
||||
"percentile": 82.5,
|
||||
"grade": "High",
|
||||
"cls": "hot"
|
||||
},
|
||||
{
|
||||
"label": "Humidity",
|
||||
"metric": "humid",
|
||||
"value_f": 10.9,
|
||||
"percentile": 46.4,
|
||||
"grade": "Normal",
|
||||
"cls": "normal"
|
||||
},
|
||||
{
|
||||
"label": "Wind",
|
||||
"metric": "wind",
|
||||
"value_f": 9.6,
|
||||
"percentile": 36.8,
|
||||
"grade": "Below Normal",
|
||||
"cls": "cool"
|
||||
},
|
||||
{
|
||||
"label": "Gust",
|
||||
"metric": "gust",
|
||||
"value_f": 21.5,
|
||||
"percentile": 41.3,
|
||||
"grade": "Normal",
|
||||
"cls": "normal"
|
||||
},
|
||||
{
|
||||
"label": "Precip",
|
||||
"metric": "precip",
|
||||
"value_f": 0.0,
|
||||
"percentile": null,
|
||||
"grade": "Dry",
|
||||
"cls": "dry"
|
||||
}
|
||||
]
|
||||
},
|
||||
"flavor": {
|
||||
"extract": "London is the capital and largest city of England and the United Kingdom, with a population of 9.1 million people in 2024. Its wider metropolitan area is the largest in Western Europe, with a population of 15.4 million.",
|
||||
"title": "London",
|
||||
"url": "https://en.wikipedia.org/wiki/London"
|
||||
},
|
||||
"event": {
|
||||
"text": "The Great Smog of December 1952 blanketed London in toxic coal fog for days, killing thousands and prompting the UK's landmark Clean Air Act.",
|
||||
"url": "https://en.wikipedia.org/wiki/Great_Smog_of_London"
|
||||
},
|
||||
"default_unit": "C",
|
||||
"breadcrumb": [
|
||||
{
|
||||
"name": "Home",
|
||||
"href": "/"
|
||||
},
|
||||
{
|
||||
"name": "Climate",
|
||||
"href": "/climate"
|
||||
},
|
||||
{
|
||||
"name": "United Kingdom",
|
||||
"href": null
|
||||
},
|
||||
{
|
||||
"name": "London",
|
||||
"href": null
|
||||
}
|
||||
],
|
||||
"canonical_path": "/climate/london-england-gb",
|
||||
"page_title": "London, GB climate: daily normals, records & how unusual it is now",
|
||||
"page_description": "London, GB averages highs of 22°C in July and lows of 2°C in February. Every day graded against 46 years of local history.",
|
||||
"jsonld": {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Dataset",
|
||||
"name": "London, England, United Kingdom climate normals and records",
|
||||
"description": "Average temperatures, precipitation and record highs and lows for London, England, United Kingdom, from ~46 years of daily climate history.",
|
||||
"url": "http://127.0.0.1:18137/climate/london-england-gb",
|
||||
"temporalCoverage": "1980/2026",
|
||||
"spatialCoverage": {
|
||||
"@type": "Place",
|
||||
"name": "London, England, United Kingdom",
|
||||
"geo": {
|
||||
"@type": "GeoCoordinates",
|
||||
"latitude": 51.50853,
|
||||
"longitude": -0.12574
|
||||
}
|
||||
},
|
||||
"creator": {
|
||||
"@type": "Organization",
|
||||
"name": "Thermograph"
|
||||
},
|
||||
"isBasedOn": "https://open-meteo.com/ (ERA5 reanalysis)"
|
||||
},
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Home",
|
||||
"item": "http://127.0.0.1:18137/"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "Climate",
|
||||
"item": "http://127.0.0.1:18137/climate"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 3,
|
||||
"name": "London"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
55
frontend/tests/fixtures/home.json
vendored
Normal file
55
frontend/tests/fixtures/home.json
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"unusual": null,
|
||||
"stale": false,
|
||||
"ranked": [],
|
||||
"cities": [
|
||||
{
|
||||
"slug": "new-york-city-new-york-us",
|
||||
"name": "New York City"
|
||||
},
|
||||
{
|
||||
"slug": "london-england-gb",
|
||||
"name": "London"
|
||||
},
|
||||
{
|
||||
"slug": "tokyo-jp",
|
||||
"name": "Tokyo"
|
||||
},
|
||||
{
|
||||
"slug": "sydney-new-south-wales-au",
|
||||
"name": "Sydney"
|
||||
},
|
||||
{
|
||||
"slug": "sao-paulo-br",
|
||||
"name": "São Paulo"
|
||||
},
|
||||
{
|
||||
"slug": "lagos-ng",
|
||||
"name": "Lagos"
|
||||
},
|
||||
{
|
||||
"slug": "mumbai-maharashtra-in",
|
||||
"name": "Mumbai"
|
||||
},
|
||||
{
|
||||
"slug": "mexico-city-mx",
|
||||
"name": "Mexico City"
|
||||
},
|
||||
{
|
||||
"slug": "cairo-eg",
|
||||
"name": "Cairo"
|
||||
},
|
||||
{
|
||||
"slug": "toronto-ontario-ca",
|
||||
"name": "Toronto"
|
||||
},
|
||||
{
|
||||
"slug": "berlin-state-of-berlin-de",
|
||||
"name": "Berlin"
|
||||
},
|
||||
{
|
||||
"slug": "seattle-washington-us",
|
||||
"name": "Seattle"
|
||||
}
|
||||
]
|
||||
}
|
||||
5626
frontend/tests/fixtures/hub.json
vendored
Normal file
5626
frontend/tests/fixtures/hub.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
114
frontend/tests/fixtures/month.json
vendored
Normal file
114
frontend/tests/fixtures/month.json
vendored
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
{
|
||||
"month_name": "July",
|
||||
"month_slug": "july",
|
||||
"year_range": [
|
||||
1980,
|
||||
2026
|
||||
],
|
||||
"n_years": 46,
|
||||
"avg_high_f": 71.1,
|
||||
"avg_low_f": 56.2,
|
||||
"typical_high_range_f": [
|
||||
64.3,
|
||||
79.8
|
||||
],
|
||||
"typical_low_range_f": [
|
||||
51.3,
|
||||
61.1
|
||||
],
|
||||
"avg_precip_f": 0.1,
|
||||
"records": [
|
||||
{
|
||||
"label": "High",
|
||||
"high_f": 97.5,
|
||||
"high_date": "2022-07-19",
|
||||
"high_tag": "Highest",
|
||||
"low_f": 57.1,
|
||||
"low_date": "1980-07-01",
|
||||
"low_tag": "Lowest"
|
||||
},
|
||||
{
|
||||
"label": "Low",
|
||||
"high_f": 71.8,
|
||||
"high_date": "2022-07-19",
|
||||
"high_tag": "Highest",
|
||||
"low_f": 45.9,
|
||||
"low_date": "1984-07-03",
|
||||
"low_tag": "Lowest"
|
||||
},
|
||||
{
|
||||
"label": "Feels-like",
|
||||
"high_f": 100.6,
|
||||
"high_date": "2019-07-25",
|
||||
"high_tag": "Highest",
|
||||
"low_f": 40.3,
|
||||
"low_date": "1980-07-01",
|
||||
"low_tag": "Lowest"
|
||||
},
|
||||
{
|
||||
"label": "Humidity",
|
||||
"high_f": 16.0,
|
||||
"high_date": "1994-07-31",
|
||||
"high_tag": "Highest",
|
||||
"low_f": 6.7,
|
||||
"low_date": "2000-07-11",
|
||||
"low_tag": "Lowest"
|
||||
},
|
||||
{
|
||||
"label": "Wind",
|
||||
"high_f": 24.7,
|
||||
"high_date": "1985-07-22",
|
||||
"high_tag": "Highest",
|
||||
"low_f": 3.0,
|
||||
"low_date": "1992-07-08",
|
||||
"low_tag": "Lowest"
|
||||
},
|
||||
{
|
||||
"label": "Gust",
|
||||
"high_f": 54.1,
|
||||
"high_date": "2023-07-15",
|
||||
"high_tag": "Highest",
|
||||
"low_f": 7.4,
|
||||
"low_date": "1992-07-08",
|
||||
"low_tag": "Lowest"
|
||||
},
|
||||
{
|
||||
"label": "Precip",
|
||||
"high_f": 4.224,
|
||||
"high_date": "2012",
|
||||
"high_tag": "Wettest",
|
||||
"low_f": 0.607,
|
||||
"low_date": "2022",
|
||||
"low_tag": "Driest"
|
||||
}
|
||||
],
|
||||
"prev": {
|
||||
"name": "June",
|
||||
"slug": "june"
|
||||
},
|
||||
"next": {
|
||||
"name": "August",
|
||||
"slug": "august"
|
||||
},
|
||||
"breadcrumb": [
|
||||
{
|
||||
"name": "Home",
|
||||
"href": "/"
|
||||
},
|
||||
{
|
||||
"name": "Climate",
|
||||
"href": "/climate"
|
||||
},
|
||||
{
|
||||
"name": "London",
|
||||
"href": "/climate/london-england-gb"
|
||||
},
|
||||
{
|
||||
"name": "July",
|
||||
"href": null
|
||||
}
|
||||
],
|
||||
"canonical_path": "/climate/london-england-gb/july",
|
||||
"page_title": "London, GB in July: normal weather & records · Thermograph",
|
||||
"page_description": "London, GB averages 22°C highs and 13°C lows in July. Every day graded against 46 years of local history."
|
||||
}
|
||||
585
frontend/tests/fixtures/records.json
vendored
Normal file
585
frontend/tests/fixtures/records.json
vendored
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
{
|
||||
"year_range": [
|
||||
1980,
|
||||
2026
|
||||
],
|
||||
"n_years": 46,
|
||||
"rows": [
|
||||
{
|
||||
"label": "High",
|
||||
"high_f": 97.5,
|
||||
"high_date": "2022-07-19",
|
||||
"low_f": 20.3,
|
||||
"low_date": "1987-01-12",
|
||||
"dry_streak_days": null
|
||||
},
|
||||
{
|
||||
"label": "Low",
|
||||
"high_f": 71.8,
|
||||
"high_date": "2022-07-19",
|
||||
"low_f": 3.3,
|
||||
"low_date": "1981-12-13",
|
||||
"dry_streak_days": null
|
||||
},
|
||||
{
|
||||
"label": "Feels-like",
|
||||
"high_f": 100.6,
|
||||
"high_date": "2019-07-25",
|
||||
"low_f": -5.3,
|
||||
"low_date": "1986-02-10",
|
||||
"dry_streak_days": null
|
||||
},
|
||||
{
|
||||
"label": "Humidity",
|
||||
"high_f": 17.4,
|
||||
"high_date": "2026-06-24",
|
||||
"low_f": 1.9,
|
||||
"low_date": "1987-01-12",
|
||||
"dry_streak_days": null
|
||||
},
|
||||
{
|
||||
"label": "Wind",
|
||||
"high_f": 41.5,
|
||||
"high_date": "1987-10-16",
|
||||
"low_f": 2.3,
|
||||
"low_date": "2024-12-27",
|
||||
"dry_streak_days": null
|
||||
},
|
||||
{
|
||||
"label": "Gust",
|
||||
"high_f": 81.7,
|
||||
"high_date": "2000-10-30",
|
||||
"low_f": 5.8,
|
||||
"low_date": "2024-12-27",
|
||||
"dry_streak_days": null
|
||||
},
|
||||
{
|
||||
"label": "Precip",
|
||||
"high_f": 1.23,
|
||||
"high_date": "2021-06-18",
|
||||
"low_f": null,
|
||||
"low_date": "1995-07-31",
|
||||
"dry_streak_days": 33
|
||||
}
|
||||
],
|
||||
"all_time_records": {
|
||||
"tmax": {
|
||||
"max": 97.5,
|
||||
"max_date": "2022-07-19",
|
||||
"min": 20.3,
|
||||
"min_date": "1987-01-12"
|
||||
},
|
||||
"tmin": {
|
||||
"max": 71.8,
|
||||
"max_date": "2022-07-19",
|
||||
"min": 3.3,
|
||||
"min_date": "1981-12-13"
|
||||
},
|
||||
"feels": {
|
||||
"max": 100.6,
|
||||
"max_date": "2019-07-25",
|
||||
"min": -5.3,
|
||||
"min_date": "1986-02-10"
|
||||
},
|
||||
"fmax": {
|
||||
"max": 100.6,
|
||||
"max_date": "2019-07-25",
|
||||
"min": 11.8,
|
||||
"min_date": "1987-01-12"
|
||||
},
|
||||
"fmin": {
|
||||
"max": 73.2,
|
||||
"max_date": "2026-06-26",
|
||||
"min": -5.3,
|
||||
"min_date": "1986-02-10"
|
||||
},
|
||||
"humid": {
|
||||
"max": 17.4,
|
||||
"max_date": "2026-06-24",
|
||||
"min": 1.9,
|
||||
"min_date": "1987-01-12"
|
||||
},
|
||||
"wind": {
|
||||
"max": 41.5,
|
||||
"max_date": "1987-10-16",
|
||||
"min": 2.3,
|
||||
"min_date": "2024-12-27"
|
||||
},
|
||||
"gust": {
|
||||
"max": 81.7,
|
||||
"max_date": "2000-10-30",
|
||||
"min": 5.8,
|
||||
"min_date": "2024-12-27"
|
||||
},
|
||||
"precip": {
|
||||
"max": 1.23,
|
||||
"max_date": "2021-06-18",
|
||||
"min": 0.0,
|
||||
"min_date": "1980-01-01"
|
||||
}
|
||||
},
|
||||
"monthly": [
|
||||
{
|
||||
"name": "January",
|
||||
"slug": "january",
|
||||
"high": {
|
||||
"warm": {
|
||||
"value_f": 57.9,
|
||||
"date": "2022-01-01"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 20.3,
|
||||
"date": "1987-01-12"
|
||||
}
|
||||
},
|
||||
"low": {
|
||||
"warm": {
|
||||
"value_f": 54.4,
|
||||
"date": "2008-01-19"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 11.4,
|
||||
"date": "1982-01-14"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "February",
|
||||
"slug": "february",
|
||||
"high": {
|
||||
"warm": {
|
||||
"value_f": 61.0,
|
||||
"date": "1990-02-23"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 25.5,
|
||||
"date": "1991-02-07"
|
||||
}
|
||||
},
|
||||
"low": {
|
||||
"warm": {
|
||||
"value_f": 53.8,
|
||||
"date": "2004-02-04"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 3.3,
|
||||
"date": "1986-02-10"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "March",
|
||||
"slug": "march",
|
||||
"high": {
|
||||
"warm": {
|
||||
"value_f": 70.1,
|
||||
"date": "2021-03-31"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 31.1,
|
||||
"date": "1986-03-01"
|
||||
}
|
||||
},
|
||||
"low": {
|
||||
"warm": {
|
||||
"value_f": 51.8,
|
||||
"date": "2006-03-26"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 17.7,
|
||||
"date": "1986-03-03"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "April",
|
||||
"slug": "april",
|
||||
"high": {
|
||||
"warm": {
|
||||
"value_f": 76.7,
|
||||
"date": "2011-04-23"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 38.6,
|
||||
"date": "2013-04-04"
|
||||
}
|
||||
},
|
||||
"low": {
|
||||
"warm": {
|
||||
"value_f": 56.0,
|
||||
"date": "2024-04-06"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 25.0,
|
||||
"date": "1996-04-02"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "May",
|
||||
"slug": "may",
|
||||
"high": {
|
||||
"warm": {
|
||||
"value_f": 88.7,
|
||||
"date": "2026-05-26"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 46.1,
|
||||
"date": "1996-05-18"
|
||||
}
|
||||
},
|
||||
"low": {
|
||||
"warm": {
|
||||
"value_f": 61.3,
|
||||
"date": "2018-05-27"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 31.1,
|
||||
"date": "1981-05-05"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "June",
|
||||
"slug": "june",
|
||||
"high": {
|
||||
"warm": {
|
||||
"value_f": 90.4,
|
||||
"date": "2026-06-26"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 53.0,
|
||||
"date": "1991-06-01"
|
||||
}
|
||||
},
|
||||
"low": {
|
||||
"warm": {
|
||||
"value_f": 71.5,
|
||||
"date": "2026-06-26"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 38.0,
|
||||
"date": "1989-06-05"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "July",
|
||||
"slug": "july",
|
||||
"high": {
|
||||
"warm": {
|
||||
"value_f": 97.5,
|
||||
"date": "2022-07-19"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 57.1,
|
||||
"date": "1980-07-01"
|
||||
}
|
||||
},
|
||||
"low": {
|
||||
"warm": {
|
||||
"value_f": 71.8,
|
||||
"date": "2022-07-19"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 45.9,
|
||||
"date": "1984-07-03"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "August",
|
||||
"slug": "august",
|
||||
"high": {
|
||||
"warm": {
|
||||
"value_f": 94.4,
|
||||
"date": "2003-08-10"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 57.7,
|
||||
"date": "1987-08-25"
|
||||
}
|
||||
},
|
||||
"low": {
|
||||
"warm": {
|
||||
"value_f": 67.9,
|
||||
"date": "2020-08-12"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 44.0,
|
||||
"date": "1993-08-25"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "September",
|
||||
"slug": "september",
|
||||
"high": {
|
||||
"warm": {
|
||||
"value_f": 86.2,
|
||||
"date": "2023-09-09"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 49.1,
|
||||
"date": "1993-09-27"
|
||||
}
|
||||
},
|
||||
"low": {
|
||||
"warm": {
|
||||
"value_f": 65.1,
|
||||
"date": "2016-09-06"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 38.9,
|
||||
"date": "1986-09-20"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "October",
|
||||
"slug": "october",
|
||||
"high": {
|
||||
"warm": {
|
||||
"value_f": 79.4,
|
||||
"date": "2011-10-01"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 43.6,
|
||||
"date": "2008-10-29"
|
||||
}
|
||||
},
|
||||
"low": {
|
||||
"warm": {
|
||||
"value_f": 66.2,
|
||||
"date": "2018-10-13"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 28.8,
|
||||
"date": "1983-10-30"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "November",
|
||||
"slug": "november",
|
||||
"high": {
|
||||
"warm": {
|
||||
"value_f": 63.3,
|
||||
"date": "2005-11-02"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 33.2,
|
||||
"date": "2010-11-28"
|
||||
}
|
||||
},
|
||||
"low": {
|
||||
"warm": {
|
||||
"value_f": 57.0,
|
||||
"date": "2010-11-04"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 22.8,
|
||||
"date": "2010-11-28"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "December",
|
||||
"slug": "december",
|
||||
"high": {
|
||||
"warm": {
|
||||
"value_f": 58.4,
|
||||
"date": "1993-12-19"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 24.3,
|
||||
"date": "2010-12-03"
|
||||
}
|
||||
},
|
||||
"low": {
|
||||
"warm": {
|
||||
"value_f": 54.9,
|
||||
"date": "2021-12-30"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 3.3,
|
||||
"date": "1981-12-13"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"seasonal": [
|
||||
{
|
||||
"name": "Winter",
|
||||
"span": "Dec–Feb",
|
||||
"high": {
|
||||
"warm": {
|
||||
"value_f": 61.0,
|
||||
"date": "1990-02-23"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 20.3,
|
||||
"date": "1987-01-12"
|
||||
}
|
||||
},
|
||||
"low": {
|
||||
"warm": {
|
||||
"value_f": 54.9,
|
||||
"date": "2021-12-30"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 3.3,
|
||||
"date": "1981-12-13"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Spring",
|
||||
"span": "Mar–May",
|
||||
"high": {
|
||||
"warm": {
|
||||
"value_f": 88.7,
|
||||
"date": "2026-05-26"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 31.1,
|
||||
"date": "1986-03-01"
|
||||
}
|
||||
},
|
||||
"low": {
|
||||
"warm": {
|
||||
"value_f": 61.3,
|
||||
"date": "2018-05-27"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 17.7,
|
||||
"date": "1986-03-03"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Summer",
|
||||
"span": "Jun–Aug",
|
||||
"high": {
|
||||
"warm": {
|
||||
"value_f": 97.5,
|
||||
"date": "2022-07-19"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 53.0,
|
||||
"date": "1991-06-01"
|
||||
}
|
||||
},
|
||||
"low": {
|
||||
"warm": {
|
||||
"value_f": 71.8,
|
||||
"date": "2022-07-19"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 38.0,
|
||||
"date": "1989-06-05"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Autumn",
|
||||
"span": "Sep–Nov",
|
||||
"high": {
|
||||
"warm": {
|
||||
"value_f": 86.2,
|
||||
"date": "2023-09-09"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 33.2,
|
||||
"date": "2010-11-28"
|
||||
}
|
||||
},
|
||||
"low": {
|
||||
"warm": {
|
||||
"value_f": 66.2,
|
||||
"date": "2018-10-13"
|
||||
},
|
||||
"cold": {
|
||||
"value_f": 22.8,
|
||||
"date": "2010-11-28"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"hemisphere": "Northern",
|
||||
"breadcrumb": [
|
||||
{
|
||||
"name": "Home",
|
||||
"href": "/"
|
||||
},
|
||||
{
|
||||
"name": "Climate",
|
||||
"href": "/climate"
|
||||
},
|
||||
{
|
||||
"name": "London",
|
||||
"href": "/climate/london-england-gb"
|
||||
},
|
||||
{
|
||||
"name": "Records",
|
||||
"href": null
|
||||
}
|
||||
],
|
||||
"canonical_path": "/climate/london-england-gb/records",
|
||||
"page_title": "London, GB weather records: hottest & coldest days since 1980",
|
||||
"page_description": "London, GB's hottest day hit 36°C (Jul 2022); its coldest fell to -16°C (Dec 1981). Every day graded against 46 years of local history.",
|
||||
"jsonld": {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Dataset",
|
||||
"name": "London, England, United Kingdom monthly and seasonal weather records",
|
||||
"description": "Record high and low temperatures for London, England, United Kingdom by month and by meteorological season, with the dates they occurred, from ~46 years of daily climate history.",
|
||||
"url": "http://127.0.0.1:18137/climate/london-england-gb/records",
|
||||
"temporalCoverage": "1980/2026",
|
||||
"spatialCoverage": {
|
||||
"@type": "Place",
|
||||
"name": "London, England, United Kingdom",
|
||||
"geo": {
|
||||
"@type": "GeoCoordinates",
|
||||
"latitude": 51.50853,
|
||||
"longitude": -0.12574
|
||||
}
|
||||
},
|
||||
"creator": {
|
||||
"@type": "Organization",
|
||||
"name": "Thermograph"
|
||||
},
|
||||
"isBasedOn": "https://open-meteo.com/ (ERA5 reanalysis)"
|
||||
},
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Home",
|
||||
"item": "http://127.0.0.1:18137/"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "Climate",
|
||||
"item": "http://127.0.0.1:18137/climate"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 3,
|
||||
"name": "London",
|
||||
"item": "http://127.0.0.1:18137/climate/london-england-gb"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 4,
|
||||
"name": "Records"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
70037
frontend/tests/fixtures/sitemap.json
vendored
Normal file
70037
frontend/tests/fixtures/sitemap.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
72
frontend/tests/integration/test_contract.py
Normal file
72
frontend/tests/integration/test_contract.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Integration tier: run the SSR app against a REAL backend container (pulled by
|
||||
scripts/backend-for-tests.sh). Exercises the actual HTTP contract instead of backend
|
||||
source — catches drift the unit fixtures can't. Asserts the contract (reachability,
|
||||
version negotiation, payload shape, SSR renders 200), not exact climate numbers.
|
||||
Skips automatically when Docker/the image isn't available (see tests/conftest.py).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
B = "/thermograph"
|
||||
SLUG = "london-england-gb"
|
||||
MONTH = "july"
|
||||
API = "v2" # frontend api_client.API_VERSION
|
||||
_FIXTURES = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "fixtures")
|
||||
|
||||
|
||||
def _live(backend_service, path):
|
||||
# The backend container runs THERMOGRAPH_BASE=/, so its API is at /api/<ver>/...
|
||||
return httpx.get(f"{backend_service}{path}", timeout=40.0)
|
||||
|
||||
|
||||
def test_backend_version_is_compatible(backend_service):
|
||||
"""The live backend advertises a contract the frontend (API_VERSION=v2) can talk to."""
|
||||
v = _live(backend_service, "/api/version").json()
|
||||
assert v["backend_version"] == "2", v # frontend targets /api/v2
|
||||
assert int(v["min_frontend"]) <= 2, v # backend still accepts this frontend
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ep", ["home", "sitemap", "hub", "city", "month", "records"])
|
||||
def test_fixture_shapes_match_live_backend(backend_service, ep):
|
||||
"""The committed unit fixtures still match the live payload shapes — proves the two
|
||||
tiers can't silently diverge. Compares top-level structure, not values."""
|
||||
paths = {
|
||||
"home": f"/api/{API}/content/home",
|
||||
"sitemap": f"/api/{API}/content/sitemap",
|
||||
"hub": f"/api/{API}/content/hub",
|
||||
"city": f"/api/{API}/content/city/{SLUG}",
|
||||
"month": f"/api/{API}/content/city/{SLUG}/month/{MONTH}",
|
||||
"records": f"/api/{API}/content/city/{SLUG}/records",
|
||||
}
|
||||
live = _live(backend_service, paths[ep])
|
||||
assert live.status_code == 200, live.text[:200]
|
||||
live_json = live.json()
|
||||
with open(os.path.join(_FIXTURES, f"{ep}.json"), encoding="utf-8") as fh:
|
||||
fixture = json.load(fh)
|
||||
if isinstance(fixture, dict):
|
||||
assert set(live_json.keys()) == set(fixture.keys()), (
|
||||
f"{ep}: live keys {sorted(live_json)} != fixture keys {sorted(fixture)} "
|
||||
"— run scripts/capture-fixtures.sh")
|
||||
else:
|
||||
assert type(live_json) is type(fixture)
|
||||
|
||||
|
||||
def test_unknown_city_404(backend_service):
|
||||
assert _live(backend_service, f"/api/{API}/content/city/nope-not-a-city").status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/", "/climate", f"/climate/{SLUG}", f"/climate/{SLUG}/{MONTH}",
|
||||
f"/climate/{SLUG}/records", "/sitemap.xml", "/robots.txt"])
|
||||
def test_ssr_renders_against_live_backend(live_client, path):
|
||||
"""The SSR app rendering each page end-to-end against the live backend proves every
|
||||
field content.py reads is actually present in the real payload."""
|
||||
r = live_client.get(f"{B}{path}")
|
||||
assert r.status_code == 200, f"{path} -> {r.status_code}"
|
||||
assert "__ORIGIN__" not in r.text
|
||||
|
||||
|
||||
def test_ssr_unknown_city_404(live_client):
|
||||
assert live_client.get(f"{B}/climate/nope-not-a-city").status_code == 404
|
||||
|
|
@ -1,442 +0,0 @@
|
|||
"""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 httpx
|
||||
import pytest
|
||||
|
||||
import api_client
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
"""Tests for the rendered homepage -- ported from backend/tests/web/test_homepage.py
|
||||
(repo-split Stage 4): content.py's Jinja rendering lives in this service now, not
|
||||
backend's. The cache-only "unusual right now" precompute (api/homepage.py) itself
|
||||
stays tested in backend/tests/web/test_homepage.py -- nothing about it moved.
|
||||
"""
|
||||
import datetime
|
||||
import pathlib
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from api import homepage
|
||||
from data import cities
|
||||
from api import content_payloads
|
||||
|
||||
from conftest import B
|
||||
|
||||
|
||||
def _pick(name, pct, cls, grade, tail, slug=None):
|
||||
return {
|
||||
"slug": slug or (name.lower().replace(" ", "-") + "-xx"),
|
||||
"name": name, "display": f"{name}, Somewhere",
|
||||
"lat": 1.0, "lon": 2.0, "cell_id": "1_2",
|
||||
"date": datetime.date.today().isoformat(),
|
||||
"metric": "tmax", "metric_label": "High temp", "window_label": "mid-July",
|
||||
"value": 99.0, "normal": 88.0, "percentile": pct,
|
||||
"pct_label": homepage._pct_label(pct),
|
||||
"grade": grade, "grade_label": homepage._grade_label(grade, tail), "cls": cls,
|
||||
"departure": abs(pct - 50), "tail": tail,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_feed(monkeypatch):
|
||||
"""The homepage with no precomputed feed at all."""
|
||||
monkeypatch.setattr(homepage, "load", lambda: None)
|
||||
return None
|
||||
|
||||
|
||||
def test_homepage_is_complete_without_js(client, no_feed):
|
||||
"""A crawler (or a reader with JS off) gets the whole page as real HTML."""
|
||||
r = client.get(f"{B}/")
|
||||
assert r.status_code == 200
|
||||
html = r.text
|
||||
assert "How unusual is your weather?" in html
|
||||
assert "How it works" in html
|
||||
assert "Climate context for 1,000 cities" in html
|
||||
assert "Free. No ads. No tracking." in html
|
||||
# The interactive tool's DOM contract survives the move to Jinja.
|
||||
for dom_id in ('id="find-btn"', 'id="date-input"', 'id="panel"',
|
||||
'id="placeholder"', 'id="results"'):
|
||||
assert dom_id in html
|
||||
# Exactly one h1: the hero headline. The brand degrades to a <p>.
|
||||
assert html.count("<h1") == 1
|
||||
assert 'p class="site-name"' in html
|
||||
|
||||
|
||||
def test_hero_locate_has_a_status_slot(client, no_feed):
|
||||
"""The locate button needs somewhere to report a declined prompt or a
|
||||
timeout. Without it the control fails silently, which is how it shipped
|
||||
broken: navigator.geolocation exists on plain http:// but always errors."""
|
||||
html = client.get(f"{B}/").text
|
||||
assert 'id="hero-locate"' in html
|
||||
assert 'id="hero-locate-msg"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
|
||||
|
||||
def test_homepage_brand_keeps_the_lockup_styling(client, no_feed):
|
||||
"""The homepage renders the brand as <p class="site-name"> so the hero owns
|
||||
the page's only h1. The lockup styling is written against `.brand h1`, so
|
||||
the class MUST be matched there too — otherwise the brand silently falls
|
||||
back to block layout, which baseline-aligns the mark instead of centring it
|
||||
and drops the monospace wordmark. Nothing else catches this: the page still
|
||||
renders, and every other test still passes, while the header looks wrong.
|
||||
"""
|
||||
html = client.get(f"{B}/").text
|
||||
assert 'class="site-name"' in html
|
||||
assert "<h1 class=\"site-name\"" not in html # the hero owns the h1
|
||||
|
||||
css = (pathlib.Path(__file__).parents[2] / "frontend" / "style.css").read_text()
|
||||
lockup = css.split(".brand h1", 1)[1].split("}", 1)[0]
|
||||
assert ".brand .site-name" in lockup, (
|
||||
"the .brand h1 lockup rule must also match .brand .site-name"
|
||||
)
|
||||
|
||||
|
||||
def test_city_chips_all_resolve(client, no_feed):
|
||||
"""Every homepage chip must be a real, routable city — a stale slug would
|
||||
render an empty chip list and leak a 404 link."""
|
||||
resolved = [{"slug": s, "name": cities.get(s)["name"]}
|
||||
for s in content_payloads.HOME_CITY_SLUGS if cities.get(s)]
|
||||
assert len(resolved) == len(content_payloads.HOME_CITY_SLUGS)
|
||||
html = client.get(f"{B}/").text
|
||||
for city in resolved:
|
||||
assert f"/climate/{city['slug']}" in html
|
||||
assert cities.get(city["slug"]) is not None
|
||||
|
||||
|
||||
def test_records_strip_shows_both_tails(client, monkeypatch):
|
||||
"""Two-sided honesty: a qualifying cold-tail city appears alongside the heat."""
|
||||
feed = {
|
||||
"generated_at": time.time(), "date": datetime.date.today().isoformat(),
|
||||
"considered": 800,
|
||||
"picks": {"extreme": _pick("Phoenix", 99.4, "rec-hot", "Near Record", "warm")},
|
||||
"ranked": [_pick("Phoenix", 99.4, "rec-hot", "Near Record", "warm"),
|
||||
_pick("Ushuaia", 2.1, "very-cold", "Very Low", "cold")],
|
||||
}
|
||||
monkeypatch.setattr(homepage, "load", lambda: feed)
|
||||
html = client.get(f"{B}/").text
|
||||
assert "Unusual right now" in html
|
||||
assert "Phoenix" in html and "Ushuaia" in html
|
||||
# Band colour comes from the shared tokens, and the band word is always
|
||||
# present too — colour is never the only signal.
|
||||
assert "var(--rec-hot)" in html and "var(--very-cold)" in html
|
||||
assert "Near Record" in html and "Very Low" in html
|
||||
|
||||
|
||||
def test_strip_cards_carry_value_metric_and_normal(client, monkeypatch):
|
||||
feed = {
|
||||
"generated_at": time.time(), "date": datetime.date.today().isoformat(),
|
||||
"considered": 800, "picks": {},
|
||||
"ranked": [_pick("Tehran", 99.6, "rec-hot", "Near Record", "warm"),
|
||||
_pick("Ushuaia", 0.4, "rec-cold", "Near Record", "cold")],
|
||||
}
|
||||
monkeypatch.setattr(homepage, "load", lambda: feed)
|
||||
html = client.get(f"{B}/").text
|
||||
# Both tails must be tellable apart in WORDS, not just by colour.
|
||||
assert "Near Record hot" in html and "Near Record cold" in html
|
||||
assert "normal" in html and "High temp" in html
|
||||
assert "100th" not in html and "0th" not in html
|
||||
# Temperatures render as convertible spans, and the converter is loaded, or
|
||||
# the strip would stay in °F while the °F/°C toggle says °C.
|
||||
assert 'class="temp" data-temp-f' in html
|
||||
assert "climate.js" in html
|
||||
|
||||
|
||||
def test_hero_uses_most_unusual_default(client, monkeypatch):
|
||||
feed = {
|
||||
"generated_at": time.time(), "date": datetime.date.today().isoformat(),
|
||||
"considered": 800,
|
||||
"picks": {"extreme": _pick("Phoenix", 96.0, "very-hot", "Very High", "warm")},
|
||||
"ranked": [],
|
||||
}
|
||||
monkeypatch.setattr(homepage, "load", lambda: feed)
|
||||
html = client.get(f"{B}/").text
|
||||
assert "Today in Phoenix, Somewhere" in html
|
||||
assert "96th percentile" in html
|
||||
assert "the most unusual weather we're tracking" in html
|
||||
|
||||
|
||||
def test_homepage_renders_without_feed(client, no_feed):
|
||||
"""A cold checkout with no precomputed feed still serves a complete page."""
|
||||
html = client.get(f"{B}/").text
|
||||
assert "How unusual is your weather?" in html
|
||||
assert "Unusual right now" not in html # strip is omitted, not broken
|
||||
assert 'id="hero-grade"' in html # the frame is still there
|
||||
|
||||
|
||||
def test_homepage_etag_revalidates(client, no_feed):
|
||||
r = client.get(f"{B}/")
|
||||
etag = r.headers["etag"]
|
||||
again = client.get(f"{B}/", headers={"If-None-Match": etag})
|
||||
assert again.status_code == 304
|
||||
|
||||
|
||||
def test_privacy_page(client):
|
||||
r = client.get(f"{B}/privacy")
|
||||
assert r.status_code == 200
|
||||
assert "no analytics library" in r.text.lower() or "no analytics" in r.text.lower()
|
||||
assert "Use my location" in r.text
|
||||
|
||||
|
||||
def test_privacy_is_linked_but_not_in_sitemap(client):
|
||||
assert f"{B}/privacy" in client.get(f"{B}/").text
|
||||
# It's a policy page, not a crawl target we want ranked.
|
||||
assert "/privacy" not in client.get(f"{B}/sitemap.xml").text
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
"""SPA-shell pages (/calendar, /day, /score, /compare, /legend, /alerts) and
|
||||
static-asset serving -- moved here from backend/tests/web/test_api.py
|
||||
(repo-split Stage 7a: frontend now owns these, not backend)."""
|
||||
from conftest import B
|
||||
(repo-split Stage 7a: frontend now owns these, not backend). These routes are static
|
||||
shells/assets that don't call the backend, so the hermetic `client` fixture serves them."""
|
||||
B = "/thermograph" # matches THERMOGRAPH_BASE set in tests/conftest.py
|
||||
|
||||
|
||||
def test_calendar_serves_with_origin_filled_in(client):
|
||||
94
frontend/tests/unit/test_rendering.py
Normal file
94
frontend/tests/unit/test_rendering.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Unit tier: the SSR rendering layer (content.py + format.py) fed committed backend
|
||||
fixtures via the hermetic `client` fixture (tests/conftest.py). Asserts the rendering
|
||||
*contract* — pages render and carry the expected structure — not exact climate numbers
|
||||
(those are the backend's responsibility). Ported from the old backend-coupled
|
||||
test_content.py; refresh the fixtures with scripts/capture-fixtures.sh.
|
||||
"""
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
B = "/thermograph" # matches THERMOGRAPH_BASE in tests/conftest.py
|
||||
SLUG = "london-england-gb"
|
||||
MONTH = "july"
|
||||
|
||||
|
||||
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 f"Disallow: {B}/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}/{MONTH}</loc>" in r.text
|
||||
assert f"/climate/{SLUG}/records</loc>" in r.text
|
||||
|
||||
|
||||
def test_city_page_renders_structure(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 # JSON-LD dataset block
|
||||
assert "average temperatures by month" in b.lower()
|
||||
assert re.search(r"-?\d+°C</span>", b) # GB city -> Celsius
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["", f"/{MONTH}", "/records"])
|
||||
def test_city_subpages_render(client, path):
|
||||
r = client.get(f"{B}/climate/{SLUG}{path}")
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.headers["content-type"]
|
||||
assert "__ORIGIN__" not in r.text # origin placeholder filled in
|
||||
|
||||
|
||||
def test_homepage_renders(client):
|
||||
r = client.get(f"{B}/")
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.headers["content-type"]
|
||||
|
||||
|
||||
def test_hub_renders(client):
|
||||
r = client.get(f"{B}/climate")
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.headers["content-type"]
|
||||
|
||||
|
||||
def test_unknown_city_is_404(client):
|
||||
assert client.get(f"{B}/climate/nope-not-a-city").status_code == 404
|
||||
|
||||
|
||||
def test_unknown_month_is_404(client):
|
||||
assert client.get(f"{B}/climate/{SLUG}/nonemonth").status_code == 404
|
||||
|
||||
|
||||
# --- backend-down resilience (migrated from main's hardening of the old
|
||||
# tests/test_content.py, which the unit-tier restructure deleted) ------------
|
||||
def _unreachable(*_a, **_k):
|
||||
import httpx
|
||||
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."""
|
||||
import api_client
|
||||
monkeypatch.setattr(api_client, attr, _unreachable)
|
||||
assert client.get(f"{B}{path}").status_code == 503
|
||||
Loading…
Reference in a new issue