PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m44s
The location tables carried one column: a percentage. A parent shortlisting from a town page is asking a different question first — does it take my child's age, is it a faith school, does it have a nursery — and the page could not answer any of it. Primary tables gain Ages, Religious character, Nursery and Constituency; secondary tables the same minus Nursery, which is a question about a different intake. An all-through school renders in both groups, so its nursery shows under primary alone. The measure moves to the second column rather than the last. Six columns overflow a phone and .tableWrap turns that into a horizontal swipe; with the measure last, the one number the page exists for is the one scrolled off the screen. Cell rules are the ones the school page already uses, so the two surfaces cannot disagree about the same school: "Does not apply", "None" and "Not applicable" all read as no religious character, and the en-dash age normalisation moves into formatAgeSpan, which formatAgeRange now delegates to. Backend: nursery_provision and parliamentary_constituency were not in the place response. Both are optional GIAS mart columns that data_loader degrades to NULL, and the `in rows.columns` guard keeps a mart the pipeline has not rebuilt working. Also fixes a live bug on the same line: SCHOOL_COLUMNS already ends with latitude and longitude, and the endpoint concatenated them again, so pandas dropped one of every duplicated pair and warned "columns are not unique" on each request. Ordered de-duplication removes the warning and the silent drop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FuPUioHpxtaiDNagQvjxyM
186 lines
6.9 KiB
Python
186 lines
6.9 KiB
Python
"""Tests for the places API (spec 2026-08-21)."""
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
def _schools_df() -> pd.DataFrame:
|
|
base = {
|
|
"local_authority": "Essex", "school_type": "Academy",
|
|
"phase": "Primary", "year": 202425, "ofsted_grade": 2.0,
|
|
"ofsted_date": None, "attainment_8_score": np.nan,
|
|
"town": "Brentwood", "postcode": "CM13 1AA", "status": "Open",
|
|
"address": "1 Test Street", "latitude": 51.6, "longitude": 0.3,
|
|
}
|
|
return pd.DataFrame([
|
|
{**base, "urn": 100000 + i, "school_name": f"Brentwood School {i}",
|
|
"rwm_expected_pct": 50.0 + i}
|
|
for i in range(6)
|
|
])
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(monkeypatch):
|
|
from backend import app as app_module
|
|
|
|
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
|
|
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
|
|
monkeypatch.setattr(app_module, "_place_registry", None)
|
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
|
|
|
|
|
def test_registry_lists_each_published_place(client):
|
|
body = client.get("/api/places").json()
|
|
slugs = {(p["kind"], p["slug"]) for p in body["places"]}
|
|
assert ("town", "brentwood") in slugs
|
|
assert ("authority", "essex") in slugs
|
|
assert ("outcode", "cm13") in slugs
|
|
|
|
|
|
def test_registry_carries_a_count_per_place(client):
|
|
body = client.get("/api/places").json()
|
|
town = next(p for p in body["places"] if p["slug"] == "brentwood")
|
|
assert town["count"] == 6
|
|
|
|
|
|
def test_place_detail_returns_its_schools_alphabetically(client):
|
|
"""A place page is read by someone looking for a school they can name.
|
|
|
|
Scanning for it is what the order should serve, so the list is A-Z.
|
|
/api/rankings is where the league-table ordering lives.
|
|
"""
|
|
body = client.get("/api/places/town/brentwood").json()
|
|
assert body["place"]["name"] == "Brentwood"
|
|
names = [s["school_name"] for s in body["schools"]]
|
|
assert names == sorted(names, key=str.lower)
|
|
|
|
|
|
def test_place_ordering_ignores_case(client):
|
|
body = client.get("/api/places/town/brentwood").json()
|
|
names = [s["school_name"] for s in body["schools"]]
|
|
# A capitalised name must not sort ahead of every lowercase one.
|
|
assert names == sorted(names, key=str.lower)
|
|
|
|
|
|
def test_the_rankings_endpoint_still_ranks_by_metric(client):
|
|
# Alphabetical is a place-page decision, not a site-wide one.
|
|
body = client.get("/api/rankings?metric=rwm_expected_pct&phase=primary").json()
|
|
scores = [r["rwm_expected_pct"] for r in body.get("rankings", [])
|
|
if r.get("rwm_expected_pct") is not None]
|
|
assert scores == sorted(scores, reverse=True)
|
|
|
|
|
|
def test_place_detail_carries_the_local_average(client):
|
|
body = client.get("/api/places/town/brentwood").json()
|
|
# 50..55 inclusive
|
|
assert body["averages"]["rwm_expected_pct"] == pytest.approx(52.5)
|
|
|
|
|
|
def test_phase_filter_narrows_the_school_list(client):
|
|
body = client.get("/api/places/town/brentwood?phase=secondary").json()
|
|
assert body["schools"] == []
|
|
|
|
|
|
def test_unknown_place_404s(client):
|
|
assert client.get("/api/places/town/atlantis").status_code == 404
|
|
|
|
|
|
def test_unknown_kind_404s(client):
|
|
assert client.get("/api/places/planet/mars").status_code == 404
|
|
|
|
|
|
def _straddling_df() -> pd.DataFrame:
|
|
"""Eight schools in CM13: six in Essex, which has a page, and two in an
|
|
authority too small to have one.
|
|
|
|
Two, not one: the registry ignores an authority holding a single school in
|
|
a place, because GIAS carries occasional postcode errors."""
|
|
df = _schools_df()
|
|
extra = df.iloc[:2].copy()
|
|
extra["urn"] = [200000, 200001]
|
|
extra["school_name"] = ["Scilly School 0", "Scilly School 1"]
|
|
extra["local_authority"] = "Isles Of Scilly"
|
|
return pd.concat([df, extra], ignore_index=True)
|
|
|
|
|
|
@pytest.fixture()
|
|
def straddling_client(monkeypatch):
|
|
from backend import app as app_module
|
|
|
|
monkeypatch.setattr(app_module, "load_school_data", _straddling_df)
|
|
monkeypatch.setattr(app_module, "load_latest_school_data", _straddling_df)
|
|
monkeypatch.setattr(app_module, "_place_registry", None)
|
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
|
|
|
|
|
def test_an_outcode_reports_no_phases_because_it_has_no_phase_route(client):
|
|
body = client.get("/api/places/outcode/cm13").json()
|
|
assert body["place"]["phases"] == []
|
|
|
|
|
|
def test_an_authority_reports_the_phases_it_publishes(client):
|
|
body = client.get("/api/places/authority/essex").json()
|
|
assert body["place"]["phases"] == ["primary"]
|
|
|
|
|
|
def test_an_authority_without_a_page_is_named_but_carries_no_slug(straddling_client):
|
|
"""Two English authorities — City of London and the Isles of Scilly — hold
|
|
fewer than the five schools a page needs, so they have no page.
|
|
|
|
Naming them is still right: the page says where the place is. Linking them
|
|
would not be. A null slug is what tells the page to print the name plainly
|
|
rather than invent a URL that 404s.
|
|
"""
|
|
body = straddling_client.get("/api/places/outcode/cm13").json()
|
|
by_name = {a["name"]: a for a in body["place"]["authorities"]}
|
|
assert by_name["Essex"]["slug"] == "essex"
|
|
assert by_name["Isles Of Scilly"]["slug"] is None
|
|
|
|
|
|
def _attributed_df() -> pd.DataFrame:
|
|
"""The same town, with the four attributes the place table now shows."""
|
|
df = _schools_df()
|
|
df["age_range"] = "4-11"
|
|
df["religious_denomination"] = "Church of England"
|
|
df["nursery_provision"] = True
|
|
df["parliamentary_constituency"] = "Brentwood and Ongar"
|
|
return df
|
|
|
|
|
|
@pytest.fixture()
|
|
def attributed_client(monkeypatch):
|
|
from backend import app as app_module
|
|
|
|
monkeypatch.setattr(app_module, "load_school_data", _attributed_df)
|
|
monkeypatch.setattr(app_module, "load_latest_school_data", _attributed_df)
|
|
monkeypatch.setattr(app_module, "_place_registry", None)
|
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
|
|
|
|
|
def test_place_detail_carries_the_attributes_the_table_shows(attributed_client):
|
|
"""age_range and religious_denomination ride in on SCHOOL_COLUMNS.
|
|
|
|
nursery_provision and parliamentary_constituency do not, and the place
|
|
table needs all four — a column the response cannot fill is a column of
|
|
dashes on ~3,900 pages.
|
|
"""
|
|
body = attributed_client.get("/api/places/town/brentwood").json()
|
|
school = body["schools"][0]
|
|
assert school["age_range"] == "4-11"
|
|
assert school["religious_denomination"] == "Church of England"
|
|
assert school["nursery_provision"] is True
|
|
assert school["parliamentary_constituency"] == "Brentwood and Ongar"
|
|
|
|
|
|
def test_place_detail_survives_a_mart_without_the_optional_columns(client):
|
|
"""The base fixture has neither column, as an unrebuilt mart does not.
|
|
|
|
data_loader degrades those to NULL rather than failing the load, so the
|
|
endpoint must not assume they are present.
|
|
"""
|
|
res = client.get("/api/places/town/brentwood")
|
|
assert res.status_code == 200
|
|
assert "nursery_provision" not in res.json()["schools"][0]
|