Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0269eec9c4 | ||
|
|
81de008e4a | ||
|
|
9feb37b974 | ||
|
|
ab25d83b9d | ||
|
|
69da9d84db | ||
|
|
2b5e681482 | ||
|
|
3bd1dbd276 | ||
|
|
870af949ee | ||
|
|
c6ff77f05b | ||
|
|
c7ddf0505d | ||
|
|
e659867590 |
No files matched your search
+62
-8
@@ -1048,6 +1048,11 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
"age_range": latest.get("age_range", ""),
|
"age_range": latest.get("age_range", ""),
|
||||||
"has_sixth_form": latest.get("has_sixth_form"),
|
"has_sixth_form": latest.get("has_sixth_form"),
|
||||||
"nursery_provision": latest.get("nursery_provision"),
|
"nursery_provision": latest.get("nursery_provision"),
|
||||||
|
# What the Ofsted badge may say when there is no inspection record
|
||||||
|
# (audit H4): the inspectorate GIAS names, and whether Ofsted's own
|
||||||
|
# data lists the school at all.
|
||||||
|
"inspectorate": latest.get("inspectorate"),
|
||||||
|
"ofsted_listed": latest.get("ofsted_listed"),
|
||||||
# The header's Selective flag reads it (lib/schoolFacts).
|
# The header's Selective flag reads it (lib/schoolFacts).
|
||||||
"admissions_policy": latest.get("admissions_policy"),
|
"admissions_policy": latest.get("admissions_policy"),
|
||||||
"status": latest.get("status"),
|
"status": latest.get("status"),
|
||||||
@@ -1210,6 +1215,9 @@ async def compare_schools(
|
|||||||
# schools get entrance-test framing, never the distance template.
|
# schools get entrance-test framing, never the distance template.
|
||||||
"admissions_policy": convert_to_native(latest.get("admissions_policy")),
|
"admissions_policy": convert_to_native(latest.get("admissions_policy")),
|
||||||
"has_sixth_form": convert_to_native(latest.get("has_sixth_form")),
|
"has_sixth_form": convert_to_native(latest.get("has_sixth_form")),
|
||||||
|
# Audit H4: what compare may say when there is no inspection record.
|
||||||
|
"inspectorate": convert_to_native(latest.get("inspectorate")),
|
||||||
|
"ofsted_listed": convert_to_native(latest.get("ofsted_listed")),
|
||||||
"capacity": convert_to_native(latest.get("capacity")),
|
"capacity": convert_to_native(latest.get("capacity")),
|
||||||
"gias_total_pupils": convert_to_native(latest.get("gias_total_pupils")),
|
"gias_total_pupils": convert_to_native(latest.get("gias_total_pupils")),
|
||||||
"trust_name": convert_to_native(latest.get("trust_name")),
|
"trust_name": convert_to_native(latest.get("trust_name")),
|
||||||
@@ -1273,17 +1281,63 @@ async def get_filter_options(request: Request):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _la_averages_payload(df: pd.DataFrame) -> dict:
|
||||||
|
"""Per-LA Attainment 8 for the "vs LA avg" comparison: DfE's own LA
|
||||||
|
averages (fact_ks4_la_averages, all state-funded schools), never a mean of
|
||||||
|
the dataframe. That mean counted independent and special schools and put
|
||||||
|
most LAs about 7 points low (audit H2).
|
||||||
|
|
||||||
|
The year is the latest with any school Attainment 8, so the average and
|
||||||
|
the scores set against it are the same year. Figures are keyed by our LA
|
||||||
|
name through the LA code. An LA without a DfE figure for that year (City of
|
||||||
|
London) is absent; no figures for the year, or no mart, give an empty map,
|
||||||
|
so rows show no comparison, never another year's figure.
|
||||||
|
"""
|
||||||
|
empty = {"year": 0, "secondary": {"attainment_8_by_la": {}}}
|
||||||
|
if df.empty or "attainment_8_score" not in df.columns:
|
||||||
|
return empty
|
||||||
|
scored = df[df["attainment_8_score"].notna()]
|
||||||
|
if scored.empty:
|
||||||
|
return empty
|
||||||
|
year = int(scored["year"].max())
|
||||||
|
|
||||||
|
la = (df[["local_authority_code", "local_authority"]]
|
||||||
|
.dropna()
|
||||||
|
.drop_duplicates("local_authority_code"))
|
||||||
|
name_by_code = {int(code): name for code, name in
|
||||||
|
zip(la["local_authority_code"], la["local_authority"])}
|
||||||
|
|
||||||
|
from . import database
|
||||||
|
from .models import Ks4LaAverage
|
||||||
|
|
||||||
|
rows: list = []
|
||||||
|
db = None
|
||||||
|
try:
|
||||||
|
db = database.SessionLocal()
|
||||||
|
rows = db.query(Ks4LaAverage).filter(Ks4LaAverage.year == year).all()
|
||||||
|
except Exception:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"DfE LA averages unavailable for %s", year, exc_info=True)
|
||||||
|
if db is not None:
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
if db is not None:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
by_la = {
|
||||||
|
name_by_code[row.la_code]: row.attainment_8_score
|
||||||
|
for row in rows
|
||||||
|
if row.attainment_8_score is not None and row.la_code in name_by_code
|
||||||
|
}
|
||||||
|
return {"year": year, "secondary": {"attainment_8_by_la": by_la}}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/la-averages")
|
@app.get("/api/la-averages")
|
||||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||||
async def get_la_averages(request: Request):
|
async def get_la_averages(request: Request):
|
||||||
"""Get per-LA average Attainment 8 score for secondary schools in the latest year."""
|
"""DfE's per-LA Attainment 8 averages for the latest year with results."""
|
||||||
df = load_school_data()
|
return _la_averages_payload(load_school_data())
|
||||||
if df.empty:
|
|
||||||
return {"year": 0, "secondary": {"attainment_8_by_la": {}}}
|
|
||||||
latest_year = int(df["year"].max())
|
|
||||||
sec_df = df[(df["year"] == latest_year) & df["attainment_8_score"].notna()]
|
|
||||||
la_avg = sec_df.groupby("local_authority")["attainment_8_score"].mean().round(1).to_dict()
|
|
||||||
return {"year": latest_year, "secondary": {"attainment_8_by_la": la_avg}}
|
|
||||||
|
|
||||||
|
|
||||||
_KS2_NATIONAL_METRICS = [
|
_KS2_NATIONAL_METRICS = [
|
||||||
|
|||||||
@@ -251,6 +251,8 @@ _MAIN_QUERY = text("""
|
|||||||
s.website,
|
s.website,
|
||||||
s.telephone,
|
s.telephone,
|
||||||
s.nursery_provision,
|
s.nursery_provision,
|
||||||
|
s.inspectorate,
|
||||||
|
s.ofsted_listed,
|
||||||
foi.current_grade AS ofsted_grade,
|
foi.current_grade AS ofsted_grade,
|
||||||
foi.current_grade_date AS ofsted_grade_date,
|
foi.current_grade_date AS ofsted_grade_date,
|
||||||
foi.latest_visit_date AS ofsted_date,
|
foi.latest_visit_date AS ofsted_date,
|
||||||
@@ -376,7 +378,8 @@ for _old, _new in _LEGACY_NAME_REPLACEMENTS:
|
|||||||
_MAIN_QUERY_LEGACY_NAMES = text(_MAIN_QUERY_LEGACY_NAMES)
|
_MAIN_QUERY_LEGACY_NAMES = text(_MAIN_QUERY_LEGACY_NAMES)
|
||||||
|
|
||||||
# Fallback used when marts predate the optional GIAS/location detail columns
|
# Fallback used when marts predate the optional GIAS/location detail columns
|
||||||
# (telephone, nursery provision, county, parliamentary constituency) — i.e. the
|
# (telephone, nursery provision, county, parliamentary constituency, and the
|
||||||
|
# inspectorate / Ofsted-listing columns added for audit H4) — i.e. the
|
||||||
# nightly dbt pipeline hasn't rebuilt the mart yet on this DB. Keeps each column
|
# nightly dbt pipeline hasn't rebuilt the mart yet on this DB. Keeps each column
|
||||||
# present as NULL so the whole data load degrades gracefully instead of failing.
|
# present as NULL so the whole data load degrades gracefully instead of failing.
|
||||||
_EXTRA_OPTIONAL_COLUMNS = (
|
_EXTRA_OPTIONAL_COLUMNS = (
|
||||||
@@ -384,6 +387,8 @@ _EXTRA_OPTIONAL_COLUMNS = (
|
|||||||
"nursery_provision",
|
"nursery_provision",
|
||||||
"county",
|
"county",
|
||||||
"parliamentary_constituency",
|
"parliamentary_constituency",
|
||||||
|
"inspectorate",
|
||||||
|
"ofsted_listed",
|
||||||
)
|
)
|
||||||
_MAIN_QUERY_NO_EXTRA_COLS = str(_MAIN_QUERY)
|
_MAIN_QUERY_NO_EXTRA_COLS = str(_MAIN_QUERY)
|
||||||
for _col, _tbl in (
|
for _col, _tbl in (
|
||||||
@@ -391,6 +396,8 @@ for _col, _tbl in (
|
|||||||
("nursery_provision", "s"),
|
("nursery_provision", "s"),
|
||||||
("county", "l"),
|
("county", "l"),
|
||||||
("parliamentary_constituency", "l"),
|
("parliamentary_constituency", "l"),
|
||||||
|
("inspectorate", "s"),
|
||||||
|
("ofsted_listed", "s"),
|
||||||
):
|
):
|
||||||
_src = f"{_tbl}.{_col},"
|
_src = f"{_tbl}.{_col},"
|
||||||
assert _src in _MAIN_QUERY_NO_EXTRA_COLS, (
|
assert _src in _MAIN_QUERY_NO_EXTRA_COLS, (
|
||||||
|
|||||||
@@ -344,6 +344,18 @@ class Ks4NationalAverage(Base):
|
|||||||
gcse_grade_91_pct = Column(Float)
|
gcse_grade_91_pct = Column(Float)
|
||||||
|
|
||||||
|
|
||||||
|
class Ks4LaAverage(Base):
|
||||||
|
"""Official DfE KS4 local-authority averages (all state-funded schools) —
|
||||||
|
one row per academic year and LA. la_code is the GIAS LA code."""
|
||||||
|
__tablename__ = "fact_ks4_la_averages"
|
||||||
|
__table_args__ = MARTS
|
||||||
|
|
||||||
|
year = Column(Integer, primary_key=True)
|
||||||
|
la_code = Column(Integer, primary_key=True)
|
||||||
|
la_name = Column(String)
|
||||||
|
attainment_8_score = Column(Float)
|
||||||
|
|
||||||
|
|
||||||
class Ks2NationalAverage(Base):
|
class Ks2NationalAverage(Base):
|
||||||
"""Official DfE KS2 national headline averages — one row per academic year."""
|
"""Official DfE KS2 national headline averages — one row per academic year."""
|
||||||
__tablename__ = "fact_ks2_national_averages"
|
__tablename__ = "fact_ks2_national_averages"
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ def report_card_labels(ofsted: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def ofsted_page_url(urn: int) -> str:
|
def ofsted_page_url(urn: int) -> str:
|
||||||
"""The school's page on ofsted.gov.uk (all its reports live there —
|
"""The school's page on ofsted.gov.uk (all its reports live there — we
|
||||||
we never deep-link an individual report)."""
|
never deep-link an individual report). Ofsted's provider pages sit under a
|
||||||
return f"https://reports.ofsted.gov.uk/provider/21/{urn}"
|
category (21 primary, 23 secondary, 25 special, 40 FE colleges, …); this
|
||||||
|
address redirects to the right one, where provider/21 404s for every
|
||||||
|
non-primary school."""
|
||||||
|
return f"https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/{urn}"
|
||||||
@@ -578,6 +578,8 @@ SCHOOL_COLUMNS = [
|
|||||||
"ofsted_date",
|
"ofsted_date",
|
||||||
"ofsted_framework",
|
"ofsted_framework",
|
||||||
"ofsted_rc_date",
|
"ofsted_rc_date",
|
||||||
|
"inspectorate",
|
||||||
|
"ofsted_listed",
|
||||||
"latitude",
|
"latitude",
|
||||||
"longitude",
|
"longitude",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Audit H4: every payload that feeds an Ofsted badge carries the inspectorate
|
||||||
|
GIAS names and whether Ofsted's own data lists the school, so the site never
|
||||||
|
says "Not yet inspected" on the strength of missing data."""
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def _df():
|
||||||
|
base = dict(year=202425, local_authority='Cambridgeshire', local_authority_code=873,
|
||||||
|
postcode='CB1 1AA', latitude=52.2, longitude=0.12, address='1 High St',
|
||||||
|
status='Open', town='Cambridge')
|
||||||
|
return pd.DataFrame([
|
||||||
|
dict(base, urn=136083, school_name='Abbey College Cambridge', phase='Secondary',
|
||||||
|
school_type='Other independent school', inspectorate='ISI', ofsted_listed=False),
|
||||||
|
dict(base, urn=150031, school_name='Abbas and Templecombe Church School', phase='Primary',
|
||||||
|
school_type='Academy converter', inspectorate='Not applicable', ofsted_listed=True),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(monkeypatch):
|
||||||
|
from backend import app as app_module
|
||||||
|
from backend import database as database_module
|
||||||
|
|
||||||
|
def _no_db():
|
||||||
|
raise RuntimeError('no database in this test')
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, 'load_latest_school_data', _df)
|
||||||
|
monkeypatch.setattr(app_module, 'load_school_data', _df)
|
||||||
|
monkeypatch.setattr(app_module, 'get_supplementary_data', lambda db, urn: {})
|
||||||
|
monkeypatch.setattr(app_module, '_place_registry', None)
|
||||||
|
monkeypatch.setattr(database_module, 'SessionLocal', _no_db)
|
||||||
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_list_carries_inspectorate_and_listing(client):
|
||||||
|
resp = client.get('/api/schools?page_size=50')
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
by_urn = {s['urn']: s for s in resp.json()['schools']}
|
||||||
|
assert by_urn[136083]['inspectorate'] == 'ISI'
|
||||||
|
assert by_urn[150031]['ofsted_listed'] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_school_page_carries_them(client):
|
||||||
|
info = client.get('/api/schools/136083').json()['school_info']
|
||||||
|
assert info['inspectorate'] == 'ISI'
|
||||||
|
assert info['ofsted_listed'] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_carries_them(client):
|
||||||
|
body = client.get('/api/compare?urns=136083,150031').json()
|
||||||
|
assert body['comparison']['150031']['school_info']['ofsted_listed'] is True
|
||||||
|
assert body['comparison']['136083']['school_info']['inspectorate'] == 'ISI'
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_mart_built_before_the_columns_still_loads():
|
||||||
|
# Missing columns make the main query retry with NULLs (data_loader).
|
||||||
|
from backend import data_loader
|
||||||
|
assert {'inspectorate', 'ofsted_listed'} <= set(data_loader._EXTRA_OPTIONAL_COLUMNS)
|
||||||
|
fallback = str(data_loader._MAIN_QUERY_NO_EXTRA_COLS)
|
||||||
|
assert 'NULL AS inspectorate,' in fallback and 'NULL AS ofsted_listed,' in fallback
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""/api/la-averages serves DfE's own LA averages (fact_ks4_la_averages).
|
||||||
|
|
||||||
|
It used to average the dataframe: every school with an Attainment 8,
|
||||||
|
independent and special schools included. Kensington and Chelsea came out at
|
||||||
|
35.2 against DfE's 54.5, and most LAs about 7 points low (audit H2).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
LATEST = 202425
|
||||||
|
|
||||||
|
|
||||||
|
def _df():
|
||||||
|
return pd.DataFrame([
|
||||||
|
# A state school and an independent: their mean, 37.65, is not DfE's figure.
|
||||||
|
dict(year=LATEST, local_authority="Kensington and Chelsea", local_authority_code=207, attainment_8_score=54.9),
|
||||||
|
dict(year=LATEST, local_authority="Kensington and Chelsea", local_authority_code=207, attainment_8_score=20.4),
|
||||||
|
dict(year=LATEST, local_authority="Bristol, City of", local_authority_code=801, attainment_8_score=45.0),
|
||||||
|
dict(year=LATEST, local_authority="West Sussex", local_authority_code=938, attainment_8_score=48.0),
|
||||||
|
# DfE publishes no LA figure for City of London.
|
||||||
|
dict(year=LATEST, local_authority="City of London", local_authority_code=201, attainment_8_score=30.0),
|
||||||
|
# A newer year with primary results only.
|
||||||
|
dict(year=202526, local_authority="Kensington and Chelsea", local_authority_code=207, attainment_8_score=np.nan),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
class _Row:
|
||||||
|
def __init__(self, year, la_code, la_name, attainment_8_score):
|
||||||
|
self.year = year
|
||||||
|
self.la_code = la_code
|
||||||
|
self.la_name = la_name
|
||||||
|
self.attainment_8_score = attainment_8_score
|
||||||
|
|
||||||
|
|
||||||
|
class _StubSession:
|
||||||
|
rows = [
|
||||||
|
_Row(LATEST, 207, "Kensington and Chelsea", 54.5),
|
||||||
|
_Row(LATEST, 801, "Bristol City", 46.3), # DfE's spelling, not ours
|
||||||
|
_Row(LATEST, 938, "West Sussex", None), # suppressed
|
||||||
|
_Row(LATEST, 330, "Birmingham", 44.0), # no school of ours there
|
||||||
|
_Row(202324, 207, "Kensington and Chelsea", 54.5),
|
||||||
|
]
|
||||||
|
|
||||||
|
def query(self, model):
|
||||||
|
assert model.__name__ == "Ks4LaAverage"
|
||||||
|
return self
|
||||||
|
|
||||||
|
def filter(self, condition):
|
||||||
|
# The payload filters on year == <year>; apply it as Postgres would.
|
||||||
|
self._year = condition.right.value
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return [r for r in self.rows if r.year == self._year]
|
||||||
|
|
||||||
|
def rollback(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _OldYearOnly(_StubSession):
|
||||||
|
rows = [_Row(202324, 207, "Kensington and Chelsea", 54.5)]
|
||||||
|
|
||||||
|
|
||||||
|
class _NoMart(_StubSession):
|
||||||
|
def all(self):
|
||||||
|
raise RuntimeError('relation "marts.fact_ks4_la_averages" does not exist')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def payload(monkeypatch):
|
||||||
|
from backend import app as app_module
|
||||||
|
from backend import database as database_module
|
||||||
|
|
||||||
|
def _run(session_cls):
|
||||||
|
monkeypatch.setattr(database_module, "SessionLocal", session_cls)
|
||||||
|
return app_module._la_averages_payload(_df())
|
||||||
|
|
||||||
|
return _run
|
||||||
|
|
||||||
|
|
||||||
|
def test_serves_dfe_figures_keyed_by_our_la_names(payload):
|
||||||
|
out = payload(_StubSession)
|
||||||
|
assert out["year"] == LATEST
|
||||||
|
assert out["secondary"]["attainment_8_by_la"] == {
|
||||||
|
"Kensington and Chelsea": 54.5,
|
||||||
|
"Bristol, City of": 46.3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_la_without_a_dfe_figure_is_absent(payload):
|
||||||
|
by_la = payload(_StubSession)["secondary"]["attainment_8_by_la"]
|
||||||
|
assert "City of London" not in by_la
|
||||||
|
assert "West Sussex" not in by_la
|
||||||
|
assert None not in by_la.values()
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_dfe_figures_for_the_year_give_an_empty_map(payload):
|
||||||
|
assert payload(_OldYearOnly) == {"year": LATEST, "secondary": {"attainment_8_by_la": {}}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_mart_gives_an_empty_map(payload):
|
||||||
|
assert payload(_NoMart)["secondary"]["attainment_8_by_la"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_endpoint_serves_dfe_figures(monkeypatch):
|
||||||
|
from backend import app as app_module
|
||||||
|
from backend import database as database_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, "load_school_data", _df)
|
||||||
|
monkeypatch.setattr(database_module, "SessionLocal", _StubSession)
|
||||||
|
resp = TestClient(app_module.app).get("/api/la-averages")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["secondary"]["attainment_8_by_la"]["Kensington and Chelsea"] == 54.5
|
||||||
@@ -42,4 +42,4 @@ def test_unknown_code_is_skipped_not_crashed():
|
|||||||
|
|
||||||
|
|
||||||
def test_provider_url():
|
def test_provider_url():
|
||||||
assert ofsted_page_url(138690) == "https://reports.ofsted.gov.uk/provider/21/138690"
|
assert ofsted_page_url(138690) == "https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/138690"
|
||||||
@@ -33,7 +33,7 @@ def test_report_card_block_and_provider_url():
|
|||||||
assert block["report_card"]["rc_achievement"]["label"] == "Strong standard"
|
assert block["report_card"]["rc_achievement"]["label"] == "Strong standard"
|
||||||
assert "rc_safeguarding_met" not in block["report_card"]
|
assert "rc_safeguarding_met" not in block["report_card"]
|
||||||
assert block["rc_safeguarding_met"] is True
|
assert block["rc_safeguarding_met"] is True
|
||||||
assert block["ofsted_page_url"] == "https://reports.ofsted.gov.uk/provider/21/100140"
|
assert block["ofsted_page_url"] == "https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/100140"
|
||||||
|
|
||||||
|
|
||||||
def test_no_grade_is_carried_past_a_newer_inspection():
|
def test_no_grade_is_carried_past_a_newer_inspection():
|
||||||
|
|||||||
@@ -417,12 +417,15 @@ test('a secondary search row compares its Attainment 8 with the LA average', asy
|
|||||||
// guards the comparison itself; the unit test pins the cache mode.
|
// guards the comparison itself; the unit test pins the cache mode.
|
||||||
const la = await (await page.request.get('/api/la-averages')).json();
|
const la = await (await page.request.get('/api/la-averages')).json();
|
||||||
const averages: Record<string, number> = la.secondary?.attainment_8_by_la ?? {};
|
const averages: Record<string, number> = la.secondary?.attainment_8_by_la ?? {};
|
||||||
|
// DfE publishes about 152 LA averages. An empty map (a missing mart, or a
|
||||||
|
// year the LA data set has not reached) hides every comparison: fail, not skip.
|
||||||
|
expect(Object.keys(averages).length).toBeGreaterThan(100);
|
||||||
const res = await page.request.get('/api/schools?search=school&phase=secondary&page_size=50');
|
const res = await page.request.get('/api/schools?search=school&phase=secondary&page_size=50');
|
||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
const school = ((await res.json()).schools ?? []).find(
|
const school = ((await res.json()).schools ?? []).find(
|
||||||
(s: { attainment_8_score?: number | null; local_authority?: string; school_type?: string }) =>
|
(s: { attainment_8_score?: number | null; local_authority?: string; school_type?: string }) =>
|
||||||
s.attainment_8_score != null && s.local_authority != null && averages[s.local_authority] != null
|
s.attainment_8_score != null && s.local_authority != null && averages[s.local_authority] != null
|
||||||
&& !/special|pupil referral|alternative provision/i.test(s.school_type ?? ''));
|
&& !/special|pupil referral|alternative provision|independent/i.test(s.school_type ?? ''));
|
||||||
test.skip(!school, 'no mainstream secondary with an LA average here');
|
test.skip(!school, 'no mainstream secondary with an LA average here');
|
||||||
|
|
||||||
await searchByName(page, school.school_name);
|
await searchByName(page, school.school_name);
|
||||||
@@ -433,6 +436,52 @@ test('a secondary search row compares its Attainment 8 with the LA average', asy
|
|||||||
await expect(stats.getByText(/vs LA avg/)).toBeVisible();
|
await expect(stats.getByText(/vs LA avg/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an independent secondary shows its Attainment 8 without an LA comparison', async ({ page }) => {
|
||||||
|
// DfE's LA averages cover state-funded schools, and an independent school's
|
||||||
|
// Attainment 8 leaves out IGCSEs, so a gap would mislead (audit H2). A state
|
||||||
|
// school on the same page must show its gap first, so the absence is real.
|
||||||
|
const la = await (await page.request.get('/api/la-averages')).json();
|
||||||
|
const averages: Record<string, number> = la.secondary?.attainment_8_by_la ?? {};
|
||||||
|
expect(Object.keys(averages).length).toBeGreaterThan(100);
|
||||||
|
|
||||||
|
type Row = { urn: number; school_type?: string; local_authority?: string; attainment_8_score?: number | null };
|
||||||
|
const compared = (s: Row) => s.attainment_8_score != null && s.local_authority != null
|
||||||
|
&& averages[s.local_authority] != null
|
||||||
|
&& !/special|pupil referral|alternative provision/i.test(s.school_type ?? '');
|
||||||
|
let found: { la: string; state: Row; independent: Row } | null = null;
|
||||||
|
for (const name of ['Kensington and Chelsea', 'Westminster', 'Camden', 'Hammersmith and Fulham', 'Barnet']) {
|
||||||
|
// The search page asks for the same first 50 schools.
|
||||||
|
const res = await page.request.get(`/api/schools?search=${encodeURIComponent(name)}&phase=secondary&page_size=50`);
|
||||||
|
const schools: Row[] = ((await res.json()).schools ?? []).filter(compared);
|
||||||
|
const independent = schools.find(s => /independent/i.test(s.school_type ?? ''));
|
||||||
|
const state = schools.find(s => !/independent/i.test(s.school_type ?? ''));
|
||||||
|
if (independent && state) { found = { la: name, state, independent }; break; }
|
||||||
|
}
|
||||||
|
test.skip(!found, 'no LA here lists a state and an independent secondary on one page');
|
||||||
|
|
||||||
|
await page.goto(`/?search=${encodeURIComponent(found!.la)}&phase=secondary`);
|
||||||
|
const stats = (urn: number) => page.locator(`a[href^="/school/${urn}-"]`).first()
|
||||||
|
.locator('xpath=ancestor::div[contains(@class, "__rowContent")][1]')
|
||||||
|
.locator('[class*="__line3"]');
|
||||||
|
await expect(stats(found!.state.urn).getByText(/vs LA avg/)).toBeVisible({ timeout: 15_000 });
|
||||||
|
const independent = stats(found!.independent.urn);
|
||||||
|
await expect(independent.getByText(found!.independent.attainment_8_score!.toFixed(1))).toBeVisible();
|
||||||
|
await expect(independent.getByText(/vs LA avg/)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a secondary shows its 2023/24 GCSE results, the last year DfE published Progress 8', async ({ page }) => {
|
||||||
|
// DfE re-issued its 2023/24 file under older column names, and every
|
||||||
|
// school's 2023/24 row loaded empty (audit C2). These are DfE's final
|
||||||
|
// figures for Bishop Stopford School, so they do not change.
|
||||||
|
await page.goto('/school/137086');
|
||||||
|
const history = page.locator('#history');
|
||||||
|
await history.getByText('View raw year-by-year data').click();
|
||||||
|
const row = history.getByRole('row', { name: /2023\/24/ });
|
||||||
|
await expect(row).toContainText('64.1');
|
||||||
|
await expect(row).toContainText('+1.0');
|
||||||
|
await expect(row).toContainText('91.7%');
|
||||||
|
});
|
||||||
|
|
||||||
test('a phase outside primary/secondary filters to that phase, not to everything', async ({ page }) => {
|
test('a phase outside primary/secondary filters to that phase, not to everything', async ({ page }) => {
|
||||||
// The search page offers every GIAS phase, but the API only knew the grouped
|
// The search page offers every GIAS phase, but the API only knew the grouped
|
||||||
// ones and silently dropped the rest — so "Nursery" returned primaries.
|
// ones and silently dropped the rest — so "Nursery" returned primaries.
|
||||||
@@ -505,6 +554,49 @@ test('a grade confirmed at an ungraded visit says so and is dated by it', async
|
|||||||
await expect(section.getByText('Confirmed at an ungraded inspection, 18 July 2024')).toBeVisible();
|
await expect(section.getByText('Confirmed at an ungraded inspection, 18 July 2024')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a further education college shows its Ofsted result, not "Not yet inspected"', async ({ page }) => {
|
||||||
|
// Queen Mary's College (145175) is inspected under Ofsted's further education
|
||||||
|
// framework, which the site did not load: its badge said "Not yet inspected"
|
||||||
|
// (audit H4). Its result may change; the label must never be absent or wrong.
|
||||||
|
const res = await page.request.get('/api/schools/145175');
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
await page.goto('/school/145175');
|
||||||
|
const ofsted = page.locator('#ofsted');
|
||||||
|
await expect(ofsted).toBeVisible();
|
||||||
|
await expect(ofsted.getByText('Further education and skills')).toBeVisible();
|
||||||
|
await expect(ofsted).not.toContainText('Not yet inspected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an ISI-inspected school says so instead of "Not yet inspected"', async ({ page }) => {
|
||||||
|
const res = await page.request.get('/api/schools?school_type=independent&phase=secondary&page_size=100');
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const school = ((await res.json()).schools ?? []).find(
|
||||||
|
(s: { inspectorate?: string | null; ofsted_date?: string | null; ofsted_rc_date?: string | null }) =>
|
||||||
|
s.inspectorate === 'ISI' && !s.ofsted_date && !s.ofsted_rc_date);
|
||||||
|
test.skip(!school, 'no ISI school without an Ofsted record here');
|
||||||
|
await page.goto(`/school/${school.urn}`);
|
||||||
|
await expect(page.getByText('Inspected by the Independent Schools Inspectorate (ISI)')).toBeVisible();
|
||||||
|
await expect(page.locator('#ofsted')).not.toContainText('Not yet inspected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"Not yet inspected" appears only for a school Ofsted lists without an inspection', async ({ page }) => {
|
||||||
|
const res = await page.request.get('/api/schools?phase=primary&page_size=500');
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
type Row = { urn: number; inspectorate?: string | null; ofsted_listed?: boolean | null; ofsted_date?: string | null; ofsted_rc_date?: string | null; ofsted_grade?: number | null };
|
||||||
|
const schools: Row[] = (await res.json()).schools ?? [];
|
||||||
|
// No Ofsted record, and not an ISI school (whose badge names ISI instead).
|
||||||
|
const uninspected = (s: Row) => !s.ofsted_date && !s.ofsted_rc_date && !s.ofsted_grade && s.inspectorate !== 'ISI';
|
||||||
|
const unlisted = schools.find(s => !s.ofsted_listed && uninspected(s));
|
||||||
|
const listed = schools.find(s => s.ofsted_listed && uninspected(s));
|
||||||
|
test.skip(!listed, 'no listed, uninspected primary on this page');
|
||||||
|
await page.goto(`/school/${listed!.urn}`);
|
||||||
|
await expect(page.locator('#ofsted').getByText('Not yet inspected')).toBeVisible();
|
||||||
|
if (unlisted) {
|
||||||
|
await page.goto(`/school/${unlisted.urn}`);
|
||||||
|
await expect(page.getByText('Not yet inspected')).toHaveCount(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('school detail page renders name and performance data', async ({ page }) => {
|
test('school detail page renders name and performance data', async ({ page }) => {
|
||||||
await searchByName(page, 'primary');
|
await searchByName(page, 'primary');
|
||||||
const firstSchool = schoolLinks(page).first();
|
const firstSchool = schoolLinks(page).first();
|
||||||
|
|||||||
@@ -69,6 +69,18 @@ const data: Record<string, ComparisonData> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('CompareOfsted', () => {
|
describe('CompareOfsted', () => {
|
||||||
|
it('words a school without an Ofsted record from the evidence (audit H4)', () => {
|
||||||
|
const isi = { ...school(4, 'ISI School'), inspectorate: 'ISI' } as School;
|
||||||
|
const listed = { ...school(5, 'New Academy'), ofsted_listed: true } as School;
|
||||||
|
render(<CompareOfsted schools={[isi, listed]} data={{
|
||||||
|
'4': { school_info: isi, yearly_data: [] } as unknown as ComparisonData,
|
||||||
|
'5': { school_info: listed, yearly_data: [] } as unknown as ComparisonData,
|
||||||
|
}} />);
|
||||||
|
expect(screen.getAllByText('Inspected by ISI').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText('Not yet inspected').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.queryByText(/No inspection outcome in our dataset/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the three regimes without inventing an overall grade for report cards', () => {
|
it('renders the three regimes without inventing an overall grade for report cards', () => {
|
||||||
render(<CompareOfsted schools={schools} data={data} />);
|
render(<CompareOfsted schools={schools} data={data} />);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { InspectionNotice } from '@/components/school/InspectionNotice';
|
||||||
|
|
||||||
|
it('names ISI and links its reports for an ISI school (audit H4)', () => {
|
||||||
|
render(<InspectionNotice school={{ urn: 136083, inspectorate: 'ISI', ofsted_listed: false }} />);
|
||||||
|
expect(screen.getByText('Inspected by the Independent Schools Inspectorate (ISI)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: /ISI reports/ })).toHaveAttribute('href', 'https://www.isi.net/reports/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says "Not yet inspected" for a school Ofsted lists without an inspection', () => {
|
||||||
|
render(<InspectionNotice school={{ urn: 150031, inspectorate: 'Not applicable', ofsted_listed: true }} />);
|
||||||
|
expect(screen.getByText('Not yet inspected')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: /Ofsted reports/ })).toHaveAttribute(
|
||||||
|
'href', 'https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/150031');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing without inspection data, including an unknown listing', () => {
|
||||||
|
const { container } = render(<InspectionNotice school={{ urn: 1, inspectorate: null, ofsted_listed: null }} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
@@ -105,3 +105,20 @@ it('puts the card back when the pins are rebuilt for a reason other than the sch
|
|||||||
expect(container.querySelector('.sc-popup')).toHaveTextContent('Southmead Primary School');
|
expect(container.querySelector('.sc-popup')).toHaveTextContent('Southmead Primary School');
|
||||||
expect(container.querySelectorAll('.sc-pin--selected')).toHaveLength(1);
|
expect(container.querySelectorAll('.sc-pin--selected')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('compares a state secondary with its LA average on the card, but not an independent one', () => {
|
||||||
|
const state: School = { ...base, urn: 3, school_name: 'Holland Park School', school_type: 'Academy converter',
|
||||||
|
phase: 'Secondary', local_authority: 'Kensington and Chelsea', attainment_8_score: 60,
|
||||||
|
latitude: 51.5, longitude: -0.2, distance: 0.3 };
|
||||||
|
const independent: School = { ...state, urn: 4, school_name: 'Abbey Gate College',
|
||||||
|
school_type: 'Other independent school', attainment_8_score: 20.4 };
|
||||||
|
const laAverages = { 'Kensington and Chelsea': 54.5 };
|
||||||
|
|
||||||
|
const { container, rerender } = renderMap({ schools: [state, independent], laAverages, selectedUrn: 3 });
|
||||||
|
expect(container.querySelector('.sc-popup')).toHaveTextContent('60.0 Att 8 +5.5 vs LA');
|
||||||
|
|
||||||
|
rerender({ schools: [state, independent], laAverages, selectedUrn: 4 });
|
||||||
|
const card = container.querySelector('.sc-popup')!;
|
||||||
|
expect(card).toHaveTextContent('20.4 Att 8');
|
||||||
|
expect(card).not.toHaveTextContent(/vs LA/);
|
||||||
|
});
|
||||||
@@ -82,3 +82,14 @@ it('prints an ungraded outcome when there is no grade (Oakgrove)', () => {
|
|||||||
expect(screen.getByText('No overall grade')).toBeInTheDocument();
|
expect(screen.getByText('No overall grade')).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Ungraded inspection, 13 November 2024: Standards maintained/)).toBeInTheDocument();
|
expect(screen.getByText(/Ungraded inspection, 13 November 2024: Standards maintained/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('labels a further education record and prints its short inspection (audit H4)', () => {
|
||||||
|
renderSection({
|
||||||
|
framework: 'Further education and skills',
|
||||||
|
current_grade: { grade: 2, date: '2019-03-05', basis: 'graded' },
|
||||||
|
latest_visit: { date: '2023-02-01', kind: 'short', outcome: null },
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Further education and skills')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Good')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Latest visit: Short inspection, 1 February 2023')).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -52,3 +52,8 @@ describe('SchoolRow shares the school page flags', () => {
|
|||||||
expect(screen.getByText("Boys' school")).toBeInTheDocument();
|
expect(screen.getByText("Boys' school")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders no Ofsted badge for a school with no inspection data (audit H4)', () => {
|
||||||
|
const { container } = render(<SchoolRow school={{ ...base, ofsted_grade: null, ofsted_date: null, ofsted_listed: false, inspectorate: null }} />);
|
||||||
|
expect(container.textContent).not.toMatch(/Not yet inspected|Inspected/);
|
||||||
|
});
|
||||||
@@ -116,3 +116,38 @@ describe('SecondarySchoolRow shares the school page flags', () => {
|
|||||||
expect(screen.getByText('Fee-paying')).toBeInTheDocument();
|
expect(screen.getByText('Fee-paying')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('SecondarySchoolRow LA comparison', () => {
|
||||||
|
it('compares a state school with its LA average', () => {
|
||||||
|
render(
|
||||||
|
<SecondarySchoolRow
|
||||||
|
school={{ ...base, school_type: 'Academy converter', attainment_8_score: 60 }}
|
||||||
|
laAvgAttainment8={54.5}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/\+5\.5 vs LA avg/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the comparison for a school whose type is unknown', () => {
|
||||||
|
render(
|
||||||
|
<SecondarySchoolRow
|
||||||
|
school={{ ...base, school_type: null, attainment_8_score: 60 } as unknown as School}
|
||||||
|
laAvgAttainment8={54.5}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/\+5\.5 vs LA avg/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an independent school's Attainment 8 without an LA comparison", () => {
|
||||||
|
// DfE's LA average covers state-funded schools; an independent's
|
||||||
|
// Attainment 8 leaves out IGCSEs (audit H2).
|
||||||
|
render(
|
||||||
|
<SecondarySchoolRow
|
||||||
|
school={{ ...base, school_type: 'Other independent school', attainment_8_score: 20.4 }}
|
||||||
|
laAvgAttainment8={54.5}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('20.4')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/vs LA avg/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
summariseAdmissions,
|
summariseAdmissions,
|
||||||
summariseReportCard,
|
summariseReportCard,
|
||||||
verdict,
|
verdict,
|
||||||
|
noInspectionLabel,
|
||||||
} from '@/lib/compareLogic';
|
} from '@/lib/compareLogic';
|
||||||
import type { OfstedInspection, SchoolAdmissions } from '@/lib/types';
|
import type { OfstedInspection, SchoolAdmissions } from '@/lib/types';
|
||||||
|
|
||||||
@@ -359,3 +360,14 @@ describe('basket limits per comparison group', () => {
|
|||||||
expect(kept).toEqual([0, 1, 2, 3, 4, 7, 8, 9]);
|
expect(kept).toEqual([0, 1, 2, 3, 4, 7, 8, 9]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows an FE report card with only a safeguarding result as a report card (audit H4)', () => {
|
||||||
|
const display = ofstedDisplay(ofsted({ rc_inspection_date: '2026-05-12', rc_safeguarding_met: true, report_card: {} }));
|
||||||
|
expect(display.kind).toBe('report_card');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('words the no-record compare cell from the evidence', () => {
|
||||||
|
expect(noInspectionLabel({ inspectorate: 'ISI' })).toBe('Inspected by ISI');
|
||||||
|
expect(noInspectionLabel({ ofsted_listed: true })).toBe('Not yet inspected');
|
||||||
|
expect(noInspectionLabel({ ofsted_listed: null })).toBe('No inspection in our dataset');
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { gradeSourceLine, latestVisitLine, showLatestVisitLine } from '@/lib/ofstedStatus';
|
import { gradeSourceLine, hasReportCard, latestVisitLine, showLatestVisitLine } from '@/lib/ofstedStatus';
|
||||||
import type { OfstedInspection } from '@/lib/types';
|
import type { OfstedInspection } from '@/lib/types';
|
||||||
|
|
||||||
const base = { report_card: {} } as unknown as OfstedInspection;
|
const base = { report_card: {} } as unknown as OfstedInspection;
|
||||||
@@ -45,3 +45,12 @@ describe('showLatestVisitLine', () => {
|
|||||||
latest_visit: { date: '2025-06-17', kind: 'graded', outcome: null } })).toBe(false);
|
latest_visit: { date: '2025-06-17', kind: 'graded', outcome: null } })).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('names a short inspection as Ofsted does', () => {
|
||||||
|
expect(latestVisitLine({ date: '2023-02-01', kind: 'short', outcome: null })).toBe('Short inspection, 1 February 2023');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a report card with only a safeguarding result as a report card (FE)', () => {
|
||||||
|
expect(hasReportCard({ rc_inspection_date: '2026-05-12', report_card: {} })).toBe(true);
|
||||||
|
expect(hasReportCard({ rc_inspection_date: null, report_card: {} })).toBe(false);
|
||||||
|
});
|
||||||
@@ -148,40 +148,40 @@ describe('ofstedLegacyAreas', () => {
|
|||||||
describe('buildOfstedListBadge', () => {
|
describe('buildOfstedListBadge', () => {
|
||||||
it('returns grade word + year for OEIF Outstanding', () => {
|
it('returns grade word + year for OEIF Outstanding', () => {
|
||||||
const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_grade_date: '2023-11-15', ofsted_date: '2023-11-15', ofsted_framework: 'OEIF' });
|
const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_grade_date: '2023-11-15', ofsted_date: '2023-11-15', ofsted_framework: 'OEIF' });
|
||||||
expect(badge.label).toBe('Outstanding · 2023');
|
expect(badge!.label).toBe('Outstanding · 2023');
|
||||||
expect(badge.cssClass).toBe('ofsted1');
|
expect(badge!.cssClass).toBe('ofsted1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns grade word for each OEIF grade', () => {
|
it('returns grade word for each OEIF grade', () => {
|
||||||
expect(buildOfstedListBadge({ ofsted_grade: 2, ofsted_grade_date: '2022-05-01' }).label).toBe('Good · 2022');
|
expect(buildOfstedListBadge({ ofsted_grade: 2, ofsted_grade_date: '2022-05-01' })!.label).toBe('Good · 2022');
|
||||||
expect(buildOfstedListBadge({ ofsted_grade: 3, ofsted_grade_date: '2021-01-01' }).label).toBe('Req. Improvement · 2021');
|
expect(buildOfstedListBadge({ ofsted_grade: 3, ofsted_grade_date: '2021-01-01' })!.label).toBe('Req. Improvement · 2021');
|
||||||
expect(buildOfstedListBadge({ ofsted_grade: 4, ofsted_grade_date: '2020-03-01' }).label).toBe('Inadequate · 2020');
|
expect(buildOfstedListBadge({ ofsted_grade: 4, ofsted_grade_date: '2020-03-01' })!.label).toBe('Inadequate · 2020');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prints a grade without a year when its date is missing', () => {
|
it('prints a grade without a year when its date is missing', () => {
|
||||||
const badge = buildOfstedListBadge({ ofsted_grade: 2, ofsted_grade_date: null, ofsted_date: '2025-01-01' });
|
const badge = buildOfstedListBadge({ ofsted_grade: 2, ofsted_grade_date: null, ofsted_date: '2025-01-01' });
|
||||||
expect(badge.label).toBe('Good');
|
expect(badge!.label).toBe('Good');
|
||||||
expect(badge.cssClass).toBe('ofsted2');
|
expect(badge!.cssClass).toBe('ofsted2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dates a grade by the inspection that awarded it, not the latest visit', () => {
|
it('dates a grade by the inspection that awarded it, not the latest visit', () => {
|
||||||
// Washwood Heath: Good from a March 2020 graded inspection; latest visit
|
// Washwood Heath: Good from a March 2020 graded inspection; latest visit
|
||||||
// an ungraded one in May 2025 (audit M1).
|
// an ungraded one in May 2025 (audit M1).
|
||||||
const badge = buildOfstedListBadge({ ofsted_grade: 2, ofsted_grade_date: '2020-03-03', ofsted_date: '2025-05-21' });
|
const badge = buildOfstedListBadge({ ofsted_grade: 2, ofsted_grade_date: '2020-03-03', ofsted_date: '2025-05-21' });
|
||||||
expect(badge.label).toBe('Good · 2020');
|
expect(badge!.label).toBe('Good · 2020');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows Inspected for a latest inspection that gave no grade (Rabbsfarm)', () => {
|
it('shows Inspected for a latest inspection that gave no grade (Rabbsfarm)', () => {
|
||||||
// Audit C1: this used to read "Good · 2025", from a 2020 ungraded visit.
|
// Audit C1: this used to read "Good · 2025", from a 2020 ungraded visit.
|
||||||
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_grade_date: null, ofsted_date: '2025-06-17' });
|
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_grade_date: null, ofsted_date: '2025-06-17' });
|
||||||
expect(badge.label).toBe('Inspected · 2025');
|
expect(badge!.label).toBe('Inspected · 2025');
|
||||||
expect(badge.cssClass).toBe('ofstedInspected');
|
expect(badge!.cssClass).toBe('ofstedInspected');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns a Report Card badge when ofsted_rc_date is present', () => {
|
it('returns a Report Card badge when ofsted_rc_date is present', () => {
|
||||||
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_rc_date: '2026-02-03' });
|
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_rc_date: '2026-02-03' });
|
||||||
expect(badge.label).toBe('Report Card · 2026');
|
expect(badge!.label).toBe('Report Card · 2026');
|
||||||
expect(badge.cssClass).toBe('ofstedRc');
|
expect(badge!.cssClass).toBe('ofstedRc');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a report card wins over a carried-forward legacy grade', () => {
|
it('a report card wins over a carried-forward legacy grade', () => {
|
||||||
@@ -194,34 +194,46 @@ describe('buildOfstedListBadge', () => {
|
|||||||
ofsted_framework: 'Schools - S5',
|
ofsted_framework: 'Schools - S5',
|
||||||
ofsted_rc_date: '2026-02-03',
|
ofsted_rc_date: '2026-02-03',
|
||||||
});
|
});
|
||||||
expect(badge.label).toBe('Report Card · 2026');
|
expect(badge!.label).toBe('Report Card · 2026');
|
||||||
expect(badge.cssClass).toBe('ofstedRc');
|
expect(badge!.cssClass).toBe('ofstedRc');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns an "Inspected" badge for an OEIF inspection with no overall grade (post-Sept-2024)', () => {
|
it('returns an "Inspected" badge for an OEIF inspection with no overall grade (post-Sept-2024)', () => {
|
||||||
// Inspected after Sept 2024: inspection on record (date + framework) but
|
// Inspected after Sept 2024: inspection on record (date + framework) but
|
||||||
// Ofsted no longer issues an overall grade. Must NOT read as "Not yet inspected".
|
// Ofsted no longer issues an overall grade. Must NOT read as "Not yet inspected".
|
||||||
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: '2024-11-01', ofsted_framework: 'OEIF' });
|
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: '2024-11-01', ofsted_framework: 'OEIF' });
|
||||||
expect(badge.label).toBe('Inspected · 2024');
|
expect(badge!.label).toBe('Inspected · 2024');
|
||||||
expect(badge.cssClass).toBe('ofstedInspected');
|
expect(badge!.cssClass).toBe('ofstedInspected');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns an "Inspected" badge without a year when the grade is missing and date is absent but a record exists', () => {
|
it('returns an "Inspected" badge without a year when the grade is missing and date is absent but a record exists', () => {
|
||||||
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: null, ofsted_framework: 'OEIF' });
|
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: null, ofsted_framework: 'OEIF' });
|
||||||
expect(badge.label).toBe('Inspected');
|
expect(badge!.label).toBe('Inspected');
|
||||||
expect(badge.cssClass).toBe('ofstedInspected');
|
expect(badge!.cssClass).toBe('ofstedInspected');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns pending badge when no grade and no inspection on record', () => {
|
it('says "Not yet inspected" only when Ofsted lists the school uninspected (audit H4)', () => {
|
||||||
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: null, ofsted_framework: null });
|
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: null, ofsted_listed: true });
|
||||||
expect(badge.label).toBe('Not yet inspected');
|
expect(badge).toEqual({ label: 'Not yet inspected', cssClass: 'ofstedPending' });
|
||||||
expect(badge.cssClass).toBe('ofstedPending');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns pending badge when all fields are undefined', () => {
|
it('names ISI for a school GIAS says ISI inspects', () => {
|
||||||
const badge = buildOfstedListBadge({});
|
expect(buildOfstedListBadge({ ofsted_date: null, inspectorate: 'ISI', ofsted_listed: false }))
|
||||||
expect(badge.label).toBe('Not yet inspected');
|
.toEqual({ label: 'Inspected by ISI', cssClass: 'ofstedPending' });
|
||||||
expect(badge.cssClass).toBe('ofstedPending');
|
});
|
||||||
|
|
||||||
|
it('an Ofsted record wins over ISI', () => {
|
||||||
|
expect(buildOfstedListBadge({ ofsted_grade: 2, ofsted_grade_date: '2022-03-01', inspectorate: 'ISI' })!.label)
|
||||||
|
.toBe('Good · 2022');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives no badge without any inspection data', () => {
|
||||||
|
expect(buildOfstedListBadge({ ofsted_grade: null, ofsted_date: null, ofsted_framework: null })).toBeNull();
|
||||||
|
expect(buildOfstedListBadge({ ofsted_listed: null, inspectorate: 'Not applicable' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives no badge when all fields are undefined (audit H4)', () => {
|
||||||
|
expect(buildOfstedListBadge({})).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -391,3 +403,20 @@ describe('singleSexLabel', () => {
|
|||||||
expect(singleSexLabel(undefined)).toBeNull();
|
expect(singleSexLabel(undefined)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isIndependentSchool', () => {
|
||||||
|
const { isIndependentSchool } = require('@/lib/utils');
|
||||||
|
|
||||||
|
it('matches both GIAS independent types', () => {
|
||||||
|
expect(isIndependentSchool({ school_type: 'Other independent school' })).toBe(true);
|
||||||
|
expect(isIndependentSchool({ school_type: 'Other independent special school' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match state-funded types or a missing type', () => {
|
||||||
|
for (const t of ['Academy converter', 'Community school', 'Free schools', 'Non-maintained special school']) {
|
||||||
|
expect(isIndependentSchool({ school_type: t })).toBe(false);
|
||||||
|
}
|
||||||
|
expect(isIndependentSchool({ school_type: null })).toBe(false);
|
||||||
|
expect(isIndependentSchool({})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
import { schoolUrl, isSpecialSchool, buildOfstedListBadge, listRwmValue } from '@/lib/utils';
|
import { schoolUrl, isSpecialSchool, isIndependentSchool, buildOfstedListBadge, listRwmValue } from '@/lib/utils';
|
||||||
|
|
||||||
interface LeafletMapInnerProps {
|
interface LeafletMapInnerProps {
|
||||||
schools: School[];
|
schools: School[];
|
||||||
@@ -70,7 +70,7 @@ function metricHtml(school: School, { nationalAvgRwm, laAverages }: CardContext)
|
|||||||
const score = school.attainment_8_score;
|
const score = school.attainment_8_score;
|
||||||
const laAvg = school.local_authority ? (laAverages?.[school.local_authority] ?? null) : null;
|
const laAvg = school.local_authority ? (laAverages?.[school.local_authority] ?? null) : null;
|
||||||
let delta = '';
|
let delta = '';
|
||||||
if (!special && laAvg != null) {
|
if (!special && !isIndependentSchool(school) && laAvg != null) {
|
||||||
const diff = Math.round((score - laAvg) * 10) / 10;
|
const diff = Math.round((score - laAvg) * 10) / 10;
|
||||||
// Att8 runs 0–90 in 0.1 steps; ±0.5 is meaningful, where RWM needs ±2.
|
// Att8 runs 0–90 in 0.1 steps; ±0.5 is meaningful, where RWM needs ±2.
|
||||||
const cls = diff >= 0.5 ? 'sc-up' : diff <= -0.5 ? 'sc-down' : '';
|
const cls = diff >= 0.5 ? 'sc-up' : diff <= -0.5 ? 'sc-down' : '';
|
||||||
@@ -104,7 +104,7 @@ function cardHtml(school: School, ctx: CardContext): string {
|
|||||||
? `${metric ? ' · ' : ''}${school.total_pupils.toLocaleString('en-GB')} pupils` : '';
|
? `${metric ? ' · ' : ''}${school.total_pupils.toLocaleString('en-GB')} pupils` : '';
|
||||||
return `<div class="sc-card">
|
return `<div class="sc-card">
|
||||||
<div class="sc-card-head"><a class="sc-card-name" href="${href}">${escapeHtml(school.school_name)}</a>${distance}</div>
|
<div class="sc-card-head"><a class="sc-card-name" href="${href}">${escapeHtml(school.school_name)}</a>${distance}</div>
|
||||||
<div class="sc-card-tags"><span class="sc-ofsted sc-${badge.cssClass}">${escapeHtml(badge.label)}</span>${type}</div>
|
<div class="sc-card-tags">${badge ? `<span class="sc-ofsted sc-${badge.cssClass}">${escapeHtml(badge.label)}</span>` : ''}${type}</div>
|
||||||
${metric || pupils ? `<div class="sc-card-metric">${metric}${pupils}</div>` : ''}
|
${metric || pupils ? `<div class="sc-card-metric">${metric}${pupils}</div>` : ''}
|
||||||
<div class="sc-card-actions">
|
<div class="sc-card-actions">
|
||||||
<a class="btn btn-tertiary btn-sm" href="${href}">View</a>
|
<a class="btn btn-tertiary btn-sm" href="${href}">View</a>
|
||||||
|
|||||||
@@ -66,9 +66,11 @@ export function SchoolRow({
|
|||||||
<a href={schoolUrl(school.urn, school.school_name)} className={styles.schoolName}>
|
<a href={schoolUrl(school.urn, school.school_name)} className={styles.schoolName}>
|
||||||
{school.school_name}
|
{school.school_name}
|
||||||
</a>
|
</a>
|
||||||
<span className={`${styles.ofstedBadge} ${styles[ofstedBadge.cssClass]}`}>
|
{ofstedBadge && (
|
||||||
{ofstedBadge.label}
|
<span className={`${styles.ofstedBadge} ${styles[ofstedBadge.cssClass]}`}>
|
||||||
</span>
|
{ofstedBadge.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Line 2: Context tags */}
|
{/* Line 2: Context tags */}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
|
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool, isIndependentSchool } from '@/lib/utils';
|
||||||
import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
|
import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
|
||||||
import styles from './SecondarySchoolRow.module.css';
|
import styles from './SecondarySchoolRow.module.css';
|
||||||
|
|
||||||
@@ -45,10 +45,12 @@ export function SecondarySchoolRow({
|
|||||||
const att8 = school.attainment_8_score;
|
const att8 = school.attainment_8_score;
|
||||||
// The school's own Attainment 8 is a same-school figure — shown whenever it
|
// The school's own Attainment 8 is a same-school figure — shown whenever it
|
||||||
// exists (special schools included; their type tag on line 2 gives context).
|
// exists (special schools included; their type tag on line 2 gives context).
|
||||||
// Only the vs-LA-average delta, a benchmark comparison, is dropped for
|
// Only the vs-LA-average delta, a benchmark comparison, is dropped: for
|
||||||
// special schools / PRUs / AP, whose pupils aren't measured against it fairly.
|
// special schools / PRUs / AP, whose pupils aren't measured against it
|
||||||
|
// fairly, and for independent schools, because DfE's LA average covers
|
||||||
|
// state-funded schools and an independent's Attainment 8 leaves out IGCSEs.
|
||||||
const laDelta =
|
const laDelta =
|
||||||
att8 != null && !isSpecialSchool(school) && laAvgAttainment8 != null
|
att8 != null && !isSpecialSchool(school) && !isIndependentSchool(school) && laAvgAttainment8 != null
|
||||||
? att8 - laAvgAttainment8
|
? att8 - laAvgAttainment8
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -65,9 +67,11 @@ export function SecondarySchoolRow({
|
|||||||
<a href={schoolUrl(school.urn, school.school_name)} className={styles.schoolName}>
|
<a href={schoolUrl(school.urn, school.school_name)} className={styles.schoolName}>
|
||||||
{school.school_name}
|
{school.school_name}
|
||||||
</a>
|
</a>
|
||||||
<span className={`${styles.ofstedBadge} ${styles[ofstedBadge.cssClass]}`}>
|
{ofstedBadge && (
|
||||||
{ofstedBadge.label}
|
<span className={`${styles.ofstedBadge} ${styles[ofstedBadge.cssClass]}`}>
|
||||||
</span>
|
{ofstedBadge.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Line 2: Context tags */}
|
{/* Line 2: Context tags */}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
summariseAdmissions,
|
summariseAdmissions,
|
||||||
verdict,
|
verdict,
|
||||||
type ReportCardSummary,
|
type ReportCardSummary,
|
||||||
|
noInspectionLabel,
|
||||||
} from '@/lib/compareLogic';
|
} from '@/lib/compareLogic';
|
||||||
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
|
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
|
||||||
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||||||
@@ -91,7 +92,7 @@ export function CompareAtAGlance({
|
|||||||
No overall grade
|
No overall grade
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{display.kind === 'none' && <span className={s.small}>No inspection in our dataset</span>}
|
{display.kind === 'none' && <span className={s.small}>{noInspectionLabel(data[String(school.urn)]?.school_info ?? school)}</span>}
|
||||||
</Cell>
|
</Cell>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
ofstedDisplay,
|
ofstedDisplay,
|
||||||
rcAreaLabel,
|
rcAreaLabel,
|
||||||
type OfstedDisplay,
|
type OfstedDisplay,
|
||||||
|
noInspectionLabel,
|
||||||
} from '@/lib/compareLogic';
|
} from '@/lib/compareLogic';
|
||||||
import { gradeSourceLine } from '@/lib/ofstedStatus';
|
import { gradeSourceLine } from '@/lib/ofstedStatus';
|
||||||
import type { ComparisonData, OfstedCurrentGrade, OfstedInspection, School } from '@/lib/types';
|
import type { ComparisonData, OfstedCurrentGrade, OfstedInspection, School } from '@/lib/types';
|
||||||
@@ -41,9 +42,11 @@ function yearsSince(iso: string | null): number | null {
|
|||||||
return (Date.now() - d.getTime()) / (365.25 * 24 * 3600 * 1000);
|
return (Date.now() - d.getTime()) / (365.25 * 24 * 3600 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResultCell({ display, current }: { display: OfstedDisplay; current: OfstedCurrentGrade | null }) {
|
function ResultCell({ display, current, school }: {
|
||||||
|
display: OfstedDisplay; current: OfstedCurrentGrade | null; school: School;
|
||||||
|
}) {
|
||||||
if (display.kind === 'none') {
|
if (display.kind === 'none') {
|
||||||
return <span className={s.small}>No inspection outcome in our dataset</span>;
|
return <span className={s.small}>{noInspectionLabel(school)}</span>;
|
||||||
}
|
}
|
||||||
if (display.kind === 'report_card') {
|
if (display.kind === 'report_card') {
|
||||||
return (
|
return (
|
||||||
@@ -175,7 +178,7 @@ export function CompareOfsted({
|
|||||||
<Measure label="Result">
|
<Measure label="Result">
|
||||||
{schools.map((school, i) => (
|
{schools.map((school, i) => (
|
||||||
<Cell key={school.urn} school={school} index={i}>
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
<ResultCell display={displays[i]} current={data[String(school.urn)]?.ofsted?.current_grade ?? null} />
|
<ResultCell display={displays[i]} current={data[String(school.urn)]?.ofsted?.current_grade ?? null} school={data[String(school.urn)]?.school_info ?? school} />
|
||||||
</Cell>
|
</Cell>
|
||||||
))}
|
))}
|
||||||
</Measure>
|
</Measure>
|
||||||
@@ -213,7 +216,7 @@ export function CompareOfsted({
|
|||||||
schoolName={school.school_name}
|
schoolName={school.school_name}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className={s.small}>No inspection in our dataset</span>
|
<span className={s.small}>{noInspectionLabel(data[String(school.urn)]?.school_info ?? school)}</span>
|
||||||
)}
|
)}
|
||||||
</Cell>
|
</Cell>
|
||||||
);
|
);
|
||||||
@@ -228,7 +231,7 @@ export function CompareOfsted({
|
|||||||
{schools.map((school, i) => {
|
{schools.map((school, i) => {
|
||||||
const url =
|
const url =
|
||||||
data[String(school.urn)]?.ofsted?.ofsted_page_url ??
|
data[String(school.urn)]?.ofsted?.ofsted_page_url ??
|
||||||
`https://reports.ofsted.gov.uk/provider/21/${school.urn}`;
|
`https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/${school.urn}`;
|
||||||
return (
|
return (
|
||||||
<Cell key={school.urn} school={school} index={i}>
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
{/* Short visible label to save space on mobile (the coloured
|
{/* Short visible label to save space on mobile (the coloured
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* InspectionNotice — the school page's inspection section when there is no
|
||||||
|
* Ofsted inspection record (audit H4): the inspectorate GIAS names (ISI), or
|
||||||
|
* Ofsted's own "not yet inspected" when its data lists the school. Nothing
|
||||||
|
* otherwise: no claim the data cannot support. Server component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { School } from '@/lib/types';
|
||||||
|
import { Section, sectionStyles as styles } from './sectionShared';
|
||||||
|
|
||||||
|
const OFSTED_REPORTS = 'https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/';
|
||||||
|
const ISI_REPORTS = 'https://www.isi.net/reports/';
|
||||||
|
|
||||||
|
export function InspectionNotice({ school }: { school: Pick<School, 'urn' | 'inspectorate' | 'ofsted_listed'> }) {
|
||||||
|
if (school.inspectorate === 'ISI') {
|
||||||
|
return (
|
||||||
|
<Section id="ofsted">
|
||||||
|
<h2 className={styles.sectionTitle}>
|
||||||
|
Inspection
|
||||||
|
<a href={ISI_REPORTS} target="_blank" rel="noopener noreferrer" className={styles.ofstedReportLink}
|
||||||
|
data-umami-event="external_link_clicked" data-umami-event-target="isi">
|
||||||
|
ISI reports ↗
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
<p className={styles.ofstedDisclaimer}>Inspected by the Independent Schools Inspectorate (ISI)</p>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (school.ofsted_listed) {
|
||||||
|
return (
|
||||||
|
<Section id="ofsted">
|
||||||
|
<h2 className={styles.sectionTitle}>
|
||||||
|
Ofsted Rating
|
||||||
|
<a href={`${OFSTED_REPORTS}${school.urn}`} target="_blank" rel="noopener noreferrer"
|
||||||
|
className={styles.ofstedReportLink} data-umami-event="external_link_clicked" data-umami-event-target="ofsted">
|
||||||
|
Ofsted reports ↗
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
<div className={styles.ofstedHeader}>
|
||||||
|
<span className={styles.ofstedGrade}>Not yet inspected</span>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ofstedDisplay } from '@/lib/compareLogic';
|
import { ofstedDisplay } from '@/lib/compareLogic';
|
||||||
import { formatOfstedDate, gradeSourceLine, latestVisitLine, showLatestVisitLine } from '@/lib/ofstedStatus';
|
import { FE_FRAMEWORK, formatOfstedDate, gradeSourceLine, latestVisitLine, showLatestVisitLine } from '@/lib/ofstedStatus';
|
||||||
import type { OfstedInspection } from '@/lib/types';
|
import type { OfstedInspection } from '@/lib/types';
|
||||||
import { Section, sectionStyles as styles } from './sectionShared';
|
import { Section, sectionStyles as styles } from './sectionShared';
|
||||||
|
|
||||||
@@ -71,6 +71,10 @@ export function OfstedSection({
|
|||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
{ofsted.framework === FE_FRAMEWORK && (
|
||||||
|
<p className={styles.ofstedDisclaimer}>{FE_FRAMEWORK}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{isReportCard ? (
|
{isReportCard ? (
|
||||||
/* ── New Report Card layout ── */
|
/* ── New Report Card layout ── */
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import type {
|
|||||||
import { ofstedLegacyAreas } from '@/lib/utils';
|
import { ofstedLegacyAreas } from '@/lib/utils';
|
||||||
import type { SchoolFlags } from '@/lib/schoolSections';
|
import type { SchoolFlags } from '@/lib/schoolSections';
|
||||||
import { OfstedSection } from './OfstedSection';
|
import { OfstedSection } from './OfstedSection';
|
||||||
|
import { InspectionNotice } from './InspectionNotice';
|
||||||
|
import { hasReportCard } from '@/lib/ofstedStatus';
|
||||||
import { ResultsSection } from './ResultsSection';
|
import { ResultsSection } from './ResultsSection';
|
||||||
import { AdmissionsSection } from './AdmissionsSection';
|
import { AdmissionsSection } from './AdmissionsSection';
|
||||||
import { DistanceSection } from './DistanceSection';
|
import { DistanceSection } from './DistanceSection';
|
||||||
@@ -54,7 +56,8 @@ export function PrimarySchoolSections({
|
|||||||
const primaryAvg = nationalAvg?.primary ?? {};
|
const primaryAvg = nationalAvg?.primary ?? {};
|
||||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||||
|
|
||||||
const isReportCard = !!(ofsted?.report_card && Object.keys(ofsted.report_card).length > 0);
|
// By date as well as area labels: an FE report card has only a safeguarding result (audit H4).
|
||||||
|
const isReportCard = !!ofsted && hasReportCard(ofsted);
|
||||||
// Report cards are dated by their own inspection (rc_inspection_date);
|
// Report cards are dated by their own inspection (rc_inspection_date);
|
||||||
// anything else by the school's latest visit, never the older inspection a
|
// anything else by the school's latest visit, never the older inspection a
|
||||||
// grade may come from.
|
// grade may come from.
|
||||||
@@ -71,7 +74,7 @@ export function PrimarySchoolSections({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{ofsted && (
|
{ofsted ? (
|
||||||
<OfstedSection
|
<OfstedSection
|
||||||
ofsted={ofsted}
|
ofsted={ofsted}
|
||||||
urn={schoolInfo.urn}
|
urn={schoolInfo.urn}
|
||||||
@@ -80,7 +83,7 @@ export function PrimarySchoolSections({
|
|||||||
oeifAllSameGrade={oeifAllSameGrade}
|
oeifAllSameGrade={oeifAllSameGrade}
|
||||||
oeifAreas={oeifAreas}
|
oeifAreas={oeifAreas}
|
||||||
/>
|
/>
|
||||||
)}
|
) : <InspectionNotice school={schoolInfo} />}
|
||||||
|
|
||||||
{flags.hasAnyResults && flags.latestResults && (
|
{flags.hasAnyResults && flags.latestResults && (
|
||||||
<ResultsSection
|
<ResultsSection
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import type {
|
|||||||
import { ofstedLegacyAreas } from '@/lib/utils';
|
import { ofstedLegacyAreas } from '@/lib/utils';
|
||||||
import type { SecondaryFlags } from '@/lib/schoolSections';
|
import type { SecondaryFlags } from '@/lib/schoolSections';
|
||||||
import { OfstedSection } from './OfstedSection';
|
import { OfstedSection } from './OfstedSection';
|
||||||
|
import { InspectionNotice } from './InspectionNotice';
|
||||||
|
import { hasReportCard } from '@/lib/ofstedStatus';
|
||||||
import { GcseSection } from './GcseSection';
|
import { GcseSection } from './GcseSection';
|
||||||
import { DestinationsSection } from './DestinationsSection';
|
import { DestinationsSection } from './DestinationsSection';
|
||||||
import { Post16DestinationsSection } from './Post16DestinationsSection';
|
import { Post16DestinationsSection } from './Post16DestinationsSection';
|
||||||
@@ -60,7 +62,8 @@ export function SecondarySchoolSections({
|
|||||||
}: SecondarySchoolSectionsProps) {
|
}: SecondarySchoolSectionsProps) {
|
||||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||||
|
|
||||||
const isReportCard = !!(ofsted?.report_card && Object.keys(ofsted.report_card).length > 0);
|
// By date as well as area labels: an FE report card has only a safeguarding result (audit H4).
|
||||||
|
const isReportCard = !!ofsted && hasReportCard(ofsted);
|
||||||
const ofstedInspectedDate = isReportCard
|
const ofstedInspectedDate = isReportCard
|
||||||
? ofsted?.rc_inspection_date ?? null
|
? ofsted?.rc_inspection_date ?? null
|
||||||
: ofsted?.latest_visit?.date ?? null;
|
: ofsted?.latest_visit?.date ?? null;
|
||||||
@@ -74,7 +77,7 @@ export function SecondarySchoolSections({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.secondaryScope}>
|
<div className={styles.secondaryScope}>
|
||||||
{ofsted && (
|
{ofsted ? (
|
||||||
<OfstedSection
|
<OfstedSection
|
||||||
ofsted={ofsted}
|
ofsted={ofsted}
|
||||||
urn={schoolInfo.urn}
|
urn={schoolInfo.urn}
|
||||||
@@ -83,7 +86,7 @@ export function SecondarySchoolSections({
|
|||||||
oeifAllSameGrade={oeifAllSameGrade}
|
oeifAllSameGrade={oeifAllSameGrade}
|
||||||
oeifAreas={oeifAreas}
|
oeifAreas={oeifAreas}
|
||||||
/>
|
/>
|
||||||
)}
|
) : <InspectionNotice school={schoolInfo} />}
|
||||||
|
|
||||||
{flags.hasResults && flags.latestResults && (
|
{flags.hasResults && flags.latestResults && (
|
||||||
<GcseSection
|
<GcseSection
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
* methodology instead of thresholding point estimates.
|
* methodology instead of thresholding point estimates.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { OfstedInspection, SchoolAdmissions } from './types';
|
import { hasReportCard } from './ofstedStatus';
|
||||||
|
import type { OfstedInspection, School, SchoolAdmissions } from './types';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Verdicts against an anchor (England average or state-school benchmark)
|
// Verdicts against an anchor (England average or state-school benchmark)
|
||||||
@@ -104,6 +105,16 @@ export type OfstedDisplay =
|
|||||||
| { kind: 'no_overall_grade' }
|
| { kind: 'no_overall_grade' }
|
||||||
| { kind: 'report_card'; summary: ReportCardSummary };
|
| { kind: 'report_card'; summary: ReportCardSummary };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The compare cell when a school has no Ofsted record (audit H4): ISI per GIAS,
|
||||||
|
* Ofsted's own "not yet inspected", or plainly that we hold nothing.
|
||||||
|
*/
|
||||||
|
export function noInspectionLabel(school: Pick<School, 'inspectorate' | 'ofsted_listed'>): string {
|
||||||
|
if (school.inspectorate === 'ISI') return 'Inspected by ISI';
|
||||||
|
if (school.ofsted_listed) return 'Not yet inspected';
|
||||||
|
return 'No inspection in our dataset';
|
||||||
|
}
|
||||||
|
|
||||||
export function ofstedDisplay(
|
export function ofstedDisplay(
|
||||||
ofsted: OfstedInspection | null | undefined,
|
ofsted: OfstedInspection | null | undefined,
|
||||||
): OfstedDisplay {
|
): OfstedDisplay {
|
||||||
@@ -111,7 +122,7 @@ export function ofstedDisplay(
|
|||||||
|
|
||||||
// A report card is the newest inspection format; when present it wins —
|
// A report card is the newest inspection format; when present it wins —
|
||||||
// never derive or prefer an overall grade alongside it.
|
// never derive or prefer an overall grade alongside it.
|
||||||
if (ofsted.report_card && Object.keys(ofsted.report_card).length > 0) {
|
if (hasReportCard(ofsted)) {
|
||||||
return { kind: 'report_card', summary: summariseReportCard(ofsted) };
|
return { kind: 'report_card', summary: summariseReportCard(ofsted) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,22 @@ const VISIT_KIND: Record<OfstedLatestVisit['kind'], string> = {
|
|||||||
report_card: 'Report card inspection',
|
report_card: 'Report card inspection',
|
||||||
graded: 'Graded inspection',
|
graded: 'Graded inspection',
|
||||||
ungraded: 'Ungraded inspection',
|
ungraded: 'Ungraded inspection',
|
||||||
|
short: 'Short inspection',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** fact_ofsted_latest.framework for Ofsted's further education and skills records (audit H4). */
|
||||||
|
export const FE_FRAMEWORK = 'Further education and skills';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A report card is in force. Checked by its date as well as its area labels:
|
||||||
|
* an FE report card carries a safeguarding result and no school areas.
|
||||||
|
*/
|
||||||
|
export function hasReportCard(
|
||||||
|
ofsted: Pick<OfstedInspection, 'rc_inspection_date' | 'report_card'>,
|
||||||
|
): boolean {
|
||||||
|
return !!ofsted.rc_inspection_date || Object.keys(ofsted.report_card ?? {}).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** "Graded inspection, 6 July 2016" or "Confirmed at an ungraded inspection, 14 March 2023". */
|
/** "Graded inspection, 6 July 2016" or "Confirmed at an ungraded inspection, 14 March 2023". */
|
||||||
export function gradeSourceLine(current: OfstedCurrentGrade): string {
|
export function gradeSourceLine(current: OfstedCurrentGrade): string {
|
||||||
const when = formatOfstedDate(current.date);
|
const when = formatOfstedDate(current.date);
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ export interface School {
|
|||||||
/** The school's latest inspection of any kind. */
|
/** The school's latest inspection of any kind. */
|
||||||
ofsted_date?: string | null;
|
ofsted_date?: string | null;
|
||||||
ofsted_framework?: string | null;
|
ofsted_framework?: string | null;
|
||||||
|
/** The inspectorate GIAS names ("ISI", "Ofsted", "Not applicable"). Audit H4. */
|
||||||
|
inspectorate?: string | null;
|
||||||
|
/** Whether Ofsted's own data lists the school, inspected or not. Audit H4. */
|
||||||
|
ofsted_listed?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -93,7 +97,8 @@ export interface School {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface OfstedInspection {
|
export interface OfstedInspection {
|
||||||
framework: 'OEIF' | 'ReportCard' | null;
|
/** Ofsted's event grouping (e.g. "Schools - S5"), or "Further education and skills" for FE records (audit H4). */
|
||||||
|
framework: string | null;
|
||||||
inspection_date: string | null;
|
inspection_date: string | null;
|
||||||
/** Start date of the report-card inspection itself (Nov 2025+); null otherwise. */
|
/** Start date of the report-card inspection itself (Nov 2025+); null otherwise. */
|
||||||
rc_inspection_date?: string | null;
|
rc_inspection_date?: string | null;
|
||||||
@@ -143,7 +148,7 @@ export interface OfstedCurrentGrade {
|
|||||||
|
|
||||||
export interface OfstedLatestVisit {
|
export interface OfstedLatestVisit {
|
||||||
date: string;
|
date: string;
|
||||||
kind: 'report_card' | 'graded' | 'ungraded';
|
kind: 'report_card' | 'graded' | 'ungraded' | 'short';
|
||||||
outcome: string | null;
|
outcome: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-3
@@ -678,7 +678,9 @@ export interface OfstedListBadge {
|
|||||||
* or an ungraded visit whose outcome names no grade): "Inspected · YYYY",
|
* or an ungraded visit whose outcome names no grade): "Inspected · YYYY",
|
||||||
* dated by the latest visit, so a school never reads as both inspected and
|
* dated by the latest visit, so a school never reads as both inspected and
|
||||||
* "Not yet inspected"
|
* "Not yet inspected"
|
||||||
* - No inspection on record: "Not yet inspected" in grey
|
* - No inspection on record (audit H4): "Inspected by ISI" when GIAS names
|
||||||
|
* ISI; "Not yet inspected" only when Ofsted's own data lists the school;
|
||||||
|
* otherwise no badge, never a claim the data cannot support.
|
||||||
*/
|
*/
|
||||||
export function buildOfstedListBadge(school: {
|
export function buildOfstedListBadge(school: {
|
||||||
ofsted_grade?: 1 | 2 | 3 | 4 | null;
|
ofsted_grade?: 1 | 2 | 3 | 4 | null;
|
||||||
@@ -686,7 +688,9 @@ export function buildOfstedListBadge(school: {
|
|||||||
ofsted_date?: string | null;
|
ofsted_date?: string | null;
|
||||||
ofsted_framework?: string | null;
|
ofsted_framework?: string | null;
|
||||||
ofsted_rc_date?: string | null;
|
ofsted_rc_date?: string | null;
|
||||||
}): OfstedListBadge {
|
inspectorate?: string | null;
|
||||||
|
ofsted_listed?: boolean | null;
|
||||||
|
}): OfstedListBadge | null {
|
||||||
// A report card wins over any carried-forward legacy grade — signalled by
|
// A report card wins over any carried-forward legacy grade — signalled by
|
||||||
// ofsted_rc_date. ofsted_framework is the raw event grouping ("Schools -
|
// ofsted_rc_date. ofsted_framework is the raw event grouping ("Schools -
|
||||||
// S5"), never "ReportCard", so it can't detect report cards.
|
// S5"), never "ReportCard", so it can't detect report cards.
|
||||||
@@ -719,7 +723,9 @@ export function buildOfstedListBadge(school: {
|
|||||||
return { label: `Inspected${visitYear ? ` · ${visitYear}` : ''}`, cssClass: 'ofstedInspected' };
|
return { label: `Inspected${visitYear ? ` · ${visitYear}` : ''}`, cssClass: 'ofstedInspected' };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { label: 'Not yet inspected', cssClass: 'ofstedPending' };
|
if (school.inspectorate === 'ISI') return { label: 'Inspected by ISI', cssClass: 'ofstedPending' };
|
||||||
|
if (school.ofsted_listed) return { label: 'Not yet inspected', cssClass: 'ofstedPending' };
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -758,6 +764,16 @@ export function isSpecialSchool(school: { school_type?: string | null }): boolea
|
|||||||
return /\bspecial\b/.test(t) || /pupil referral/.test(t) || /alternative provision/.test(t);
|
return /\bspecial\b/.test(t) || /pupil referral/.test(t) || /alternative provision/.test(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Independent (fee-paying) schools: GIAS types "Other independent school" and
|
||||||
|
* "Other independent special school". DfE's Attainment 8 for them leaves out
|
||||||
|
* IGCSEs, and DfE's LA averages cover state-funded schools only, so callers
|
||||||
|
* drop the "vs LA avg" comparison for them (audit H2).
|
||||||
|
*/
|
||||||
|
export function isIndependentSchool(school: { school_type?: string | null }): boolean {
|
||||||
|
return /\bindependent\b/i.test(school.school_type ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether GIAS records a religious character. "None", "Does not apply" and
|
* Whether GIAS records a religious character. "None", "Does not apply" and
|
||||||
* "Not applicable" are the register's ways of saying it has none; the place
|
* "Not applicable" are the register's ways of saying it has none; the place
|
||||||
|
|||||||
Reference in new issue
Block a user