Files
school_compare/backend/tests/test_sixth_form_flag.py
T

179 lines
6.6 KiB
Python
Raw Normal View History

"""Tests for the GIAS-driven has_sixth_form flag (spec 2026-07-07 §3).
The filter and payloads must use dim_school.has_sixth_form, not the old
age_range-contains-"18" substring heuristic. The key regression case is a
16-19 sixth-form college: flag true, but "16-19" contains no "18".
"""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
def _schools_df() -> pd.DataFrame:
"""Latest-year snapshot rows as produced by load_latest_school_data."""
base = {
"local_authority": "Testshire",
"school_type": "Academy",
"phase": "Secondary",
"address": "1 Test Street",
"town": "Testtown",
"postcode": "TS1 1AA",
"religious_denomination": None,
"gender": "Mixed",
"admissions_policy": None,
"ofsted_grade": np.nan,
"ofsted_date": None,
"ofsted_framework": None,
"latitude": 51.5,
"longitude": -0.1,
"year": 202425,
"total_pupils": 1000,
"rwm_expected_pct": np.nan,
"attainment_8_score": 50.0,
}
return pd.DataFrame(
[
# 11-18 school WITH a registered sixth form
{**base, "urn": 100001, "school_name": "Alpha High",
"age_range": "11-18", "has_sixth_form": True},
# 16-19 college: old heuristic said NO ("16-19" has no "18"),
# GIAS flag says YES — must appear in the yes-filter results
{**base, "urn": 100002, "school_name": "Beta Sixth Form College",
"age_range": "16-19", "has_sixth_form": True},
# 11-18 age range on paper but NO registered sixth form:
# old heuristic said YES, GIAS flag says NO
{**base, "urn": 100003, "school_name": "Gamma Academy",
"age_range": "11-18", "has_sixth_form": False},
# Missing flag (pipeline not yet re-run) — must not crash,
# must not match the yes-filter
{**base, "urn": 100004, "school_name": "Delta School",
"age_range": "11-16", "has_sixth_form": None},
]
)
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
return TestClient(app_module.app, raise_server_exceptions=False)
def _urns(resp):
return sorted(s["urn"] for s in resp.json()["schools"])
def test_filter_yes_uses_flag_not_age_range(client):
resp = client.get("/api/schools?has_sixth_form=yes")
assert resp.status_code == 200, resp.text
# 16-19 college included; 11-18-without-sixth-form excluded
assert _urns(resp) == [100001, 100002]
def test_filter_no_uses_flag_not_age_range(client):
resp = client.get("/api/schools?has_sixth_form=no")
assert resp.status_code == 200, resp.text
# Gamma (flag false) and Delta (flag missing => not true)
assert _urns(resp) == [100003, 100004]
def test_list_payload_includes_flag(client):
resp = client.get("/api/schools")
assert resp.status_code == 200, resp.text
by_urn = {s["urn"]: s for s in resp.json()["schools"]}
assert by_urn[100002]["has_sixth_form"] is True
assert by_urn[100003]["has_sixth_form"] is False
assert by_urn[100004]["has_sixth_form"] is None
def test_detail_payload_includes_flag(client):
resp = client.get("/api/schools/100002")
assert resp.status_code == 200, resp.text
assert resp.json()["school_info"]["has_sixth_form"] is True
def test_detail_payload_serializes_numpy_bool(monkeypatch):
"""Once the pipeline has run, has_sixth_form is a real bool dtype column
(dbt not_null test guarantees no NULLs), so row access yields
numpy.bool_ rather than a Python bool. convert_to_native must handle it —
otherwise FastAPI's jsonable_encoder raises ValueError and the detail
endpoint 500s (C2)."""
from backend import app as app_module
df = _schools_df()
# Drop the row with a None flag — this fixture models the post-pipeline
# state where the column is a genuine, fully-populated bool dtype.
df = df[df["has_sixth_form"].notna()].reset_index(drop=True)
df["has_sixth_form"] = df["has_sixth_form"].astype(bool)
assert df["has_sixth_form"].dtype == bool
monkeypatch.setattr(app_module, "load_school_data", lambda: df)
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
client = TestClient(app_module.app, raise_server_exceptions=False)
resp = client.get("/api/schools/100002")
assert resp.status_code == 200, resp.text
assert resp.json()["school_info"]["has_sixth_form"] is True
class _FakeProgrammingError(Exception):
"""Stand-in for sqlalchemy.exc.ProgrammingError wrapping psycopg2's
UndefinedColumn, without needing a real DB connection to construct one."""
def test_load_school_data_survives_missing_has_sixth_form_column(monkeypatch):
"""Real prod state until the nightly pipeline first rebuilds the mart:
marts.dim_school lacks has_sixth_form entirely. The first query raises
UndefinedColumn; load_school_data_as_dataframe must retry without the
column (synthesizing it as None) rather than swallow the error and
return (and then have load_school_data cache) an empty DataFrame (C1)."""
import sqlalchemy.exc
from backend import data_loader
data_loader._df_cache = None
data_loader._df_latest_cache = None
good_df = pd.DataFrame(
[
{
"urn": 1,
"school_name": "Fallback School",
"school_type": "Academy",
"has_sixth_form": None,
}
]
)
calls = []
def fake_read_sql(query, con):
calls.append(query)
if len(calls) == 1:
raise sqlalchemy.exc.ProgrammingError(
"SELECT ...",
None,
Exception(
"(psycopg2.errors.UndefinedColumn) column s.has_sixth_form "
"does not exist"
),
)
return good_df.copy()
monkeypatch.setattr(data_loader.pd, "read_sql", fake_read_sql)
try:
df = data_loader.load_school_data_as_dataframe()
finally:
data_loader._df_cache = None
data_loader._df_latest_cache = None
assert len(calls) == 2, "must retry with the no-sixth-form query variant"
assert calls[1] is data_loader._MAIN_QUERY_NO_SIXTH_FORM
assert not df.empty
assert "has_sixth_form" in df.columns
assert df["has_sixth_form"].iloc[0] is None