Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06e4898c30 | ||
|
|
abc03a0dd3 | ||
|
|
43a2c4a6bc |
+1
-4
@@ -30,7 +30,6 @@ from .data_loader import (
|
|||||||
load_latest_school_data,
|
load_latest_school_data,
|
||||||
geocode_single_postcode,
|
geocode_single_postcode,
|
||||||
get_supplementary_data,
|
get_supplementary_data,
|
||||||
get_supplementary_data_batch,
|
|
||||||
search_schools_typesense,
|
search_schools_typesense,
|
||||||
)
|
)
|
||||||
from .data_loader import get_data_info as get_db_info
|
from .data_loader import get_data_info as get_db_info
|
||||||
@@ -680,10 +679,8 @@ async def compare_schools(
|
|||||||
db = None
|
db = None
|
||||||
try:
|
try:
|
||||||
db = database.SessionLocal()
|
db = database.SessionLocal()
|
||||||
# One query per table for all schools, not ~5 queries per school.
|
|
||||||
batch = get_supplementary_data_batch(db, urn_list)
|
|
||||||
for urn in urn_list:
|
for urn in urn_list:
|
||||||
supp = batch.get(urn, {})
|
supp = get_supplementary_data(db, urn)
|
||||||
supplementary_by_urn[urn] = {
|
supplementary_by_urn[urn] = {
|
||||||
key: supp.get(key, default)
|
key: supp.get(key, default)
|
||||||
for key, default in _EMPTY_SUPPLEMENTARY.items()
|
for key, default in _EMPTY_SUPPLEMENTARY.items()
|
||||||
|
|||||||
+74
-132
@@ -662,150 +662,92 @@ def _admissions_row_dict(a) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _census_dict(pc) -> dict:
|
def get_supplementary_data(db: Session, urn: int) -> dict:
|
||||||
return {
|
"""Fetch all supplementary data for a single school URN."""
|
||||||
"year": pc.year,
|
result = {}
|
||||||
"total_pupils": pc.total_pupils,
|
|
||||||
"female_pupils": pc.female_pupils,
|
|
||||||
"male_pupils": pc.male_pupils,
|
|
||||||
"fsm_pct": pc.fsm_pct,
|
|
||||||
"eal_pct": pc.eal_pct,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
def safe_query(model, pk_field, latest_field=None):
|
||||||
def _deprivation_dict(d) -> dict:
|
|
||||||
return {
|
|
||||||
"lsoa_code": d.lsoa_code,
|
|
||||||
"idaci_score": d.idaci_score,
|
|
||||||
"idaci_decile": d.idaci_decile,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _finance_dict(f) -> dict:
|
|
||||||
return {
|
|
||||||
"year": f.year,
|
|
||||||
"per_pupil_spend": f.per_pupil_spend,
|
|
||||||
"staff_cost_pct": f.staff_cost_pct,
|
|
||||||
"teacher_cost_pct": f.teacher_cost_pct,
|
|
||||||
"support_staff_cost_pct": f.support_staff_cost_pct,
|
|
||||||
"premises_cost_pct": f.premises_cost_pct,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _empty_supplementary() -> dict:
|
|
||||||
return {
|
|
||||||
"ofsted": None,
|
|
||||||
"census": None,
|
|
||||||
"admissions": None,
|
|
||||||
"admissions_history": [],
|
|
||||||
"sen_detail": None,
|
|
||||||
"phonics": None,
|
|
||||||
"deprivation": None,
|
|
||||||
"finance": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_supplementary_data_batch(db: Session, urns: list[int]) -> dict:
|
|
||||||
"""Fetch supplementary data for many URNs with one query per table
|
|
||||||
(WHERE urn IN (...)) instead of ~5 queries per school, collapsing the
|
|
||||||
per-request round-trips from 5*N to a constant 5. Returns {urn: block}
|
|
||||||
with the same shape get_supplementary_data produces per URN.
|
|
||||||
|
|
||||||
Each table is queried independently and failures degrade that table to
|
|
||||||
empty for every URN — a missing mart never blanks the others.
|
|
||||||
"""
|
|
||||||
urns = [int(u) for u in urns]
|
|
||||||
result = {urn: _empty_supplementary() for urn in urns}
|
|
||||||
if not urns:
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _safe(fn):
|
|
||||||
try:
|
try:
|
||||||
fn()
|
q = db.query(model).filter(getattr(model, pk_field) == urn)
|
||||||
|
if latest_field:
|
||||||
|
q = q.order_by(getattr(model, latest_field).desc())
|
||||||
|
return q.first()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
import logging
|
||||||
logging.getLogger(__name__).error("batch supplementary query failed: %s", e)
|
logging.getLogger(__name__).error("safe_query failed for %s: %s", model.__name__, e)
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
return None
|
||||||
|
|
||||||
# Ofsted — latest inspection per URN. Ordered so the first row seen per
|
# Latest Ofsted inspection
|
||||||
# URN is the most recent.
|
o = safe_query(FactOfstedInspection, "urn", "inspection_date")
|
||||||
def _ofsted():
|
result["ofsted"] = _ofsted_block(o, urn) if o else None
|
||||||
rows = (
|
|
||||||
db.query(FactOfstedInspection)
|
|
||||||
.filter(FactOfstedInspection.urn.in_(urns))
|
|
||||||
.order_by(FactOfstedInspection.urn, FactOfstedInspection.inspection_date.desc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
seen = set()
|
|
||||||
for o in rows:
|
|
||||||
if o.urn in seen:
|
|
||||||
continue
|
|
||||||
seen.add(o.urn)
|
|
||||||
result[o.urn]["ofsted"] = _ofsted_block(o, o.urn)
|
|
||||||
_safe(_ofsted)
|
|
||||||
|
|
||||||
# Census — latest year per URN.
|
# Census (latest year of fact_pupil_characteristics)
|
||||||
def _census():
|
pc = safe_query(FactPupilCharacteristics, "urn", "year")
|
||||||
rows = (
|
result["census"] = (
|
||||||
db.query(FactPupilCharacteristics)
|
{
|
||||||
.filter(FactPupilCharacteristics.urn.in_(urns))
|
"year": pc.year,
|
||||||
.order_by(FactPupilCharacteristics.urn, FactPupilCharacteristics.year.desc())
|
"total_pupils": pc.total_pupils,
|
||||||
.all()
|
"female_pupils": pc.female_pupils,
|
||||||
)
|
"male_pupils": pc.male_pupils,
|
||||||
seen = set()
|
"fsm_pct": pc.fsm_pct,
|
||||||
for pc in rows:
|
"eal_pct": pc.eal_pct,
|
||||||
if pc.urn in seen:
|
}
|
||||||
continue
|
if pc
|
||||||
seen.add(pc.urn)
|
else None
|
||||||
result[pc.urn]["census"] = _census_dict(pc)
|
)
|
||||||
_safe(_census)
|
|
||||||
|
|
||||||
# Admissions — all years per URN, oldest first (multi-year trend view).
|
# Admissions — all years, oldest first (for the multi-year trend view).
|
||||||
def _admissions():
|
try:
|
||||||
rows = (
|
admissions_rows = (
|
||||||
db.query(FactAdmissions)
|
db.query(FactAdmissions)
|
||||||
.filter(FactAdmissions.urn.in_(urns))
|
.filter(FactAdmissions.urn == urn)
|
||||||
.order_by(FactAdmissions.urn, FactAdmissions.year.asc())
|
.order_by(FactAdmissions.year.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
history: dict = {urn: [] for urn in urns}
|
except Exception as e:
|
||||||
for a in rows:
|
import logging
|
||||||
history[a.urn].append(_admissions_row_dict(a))
|
logging.getLogger(__name__).error("admissions history query failed: %s", e)
|
||||||
for urn, rows_for_urn in history.items():
|
db.rollback()
|
||||||
result[urn]["admissions_history"] = rows_for_urn
|
admissions_rows = []
|
||||||
result[urn]["admissions"] = rows_for_urn[-1] if rows_for_urn else None
|
|
||||||
_safe(_admissions)
|
|
||||||
|
|
||||||
# Deprivation — one row per URN.
|
history = [_admissions_row_dict(a) for a in admissions_rows]
|
||||||
def _deprivation():
|
result["admissions_history"] = history
|
||||||
rows = (
|
# Keep the single latest-year object for backwards-compatible consumers
|
||||||
db.query(FactDeprivation)
|
# (hero chips, etc.).
|
||||||
.filter(FactDeprivation.urn.in_(urns))
|
result["admissions"] = history[-1] if history else None
|
||||||
.all()
|
|
||||||
)
|
|
||||||
for d in rows:
|
|
||||||
result[d.urn]["deprivation"] = _deprivation_dict(d)
|
|
||||||
_safe(_deprivation)
|
|
||||||
|
|
||||||
# Finance — latest year per URN.
|
# SEN detail — not available in current marts
|
||||||
def _finance():
|
result["sen_detail"] = None
|
||||||
rows = (
|
|
||||||
db.query(FactFinance)
|
# Phonics — no school-level data on EES
|
||||||
.filter(FactFinance.urn.in_(urns))
|
result["phonics"] = None
|
||||||
.order_by(FactFinance.urn, FactFinance.year.desc())
|
|
||||||
.all()
|
# Deprivation
|
||||||
)
|
d = safe_query(FactDeprivation, "urn")
|
||||||
seen = set()
|
result["deprivation"] = (
|
||||||
for f in rows:
|
{
|
||||||
if f.urn in seen:
|
"lsoa_code": d.lsoa_code,
|
||||||
continue
|
"idaci_score": d.idaci_score,
|
||||||
seen.add(f.urn)
|
"idaci_decile": d.idaci_decile,
|
||||||
result[f.urn]["finance"] = _finance_dict(f)
|
}
|
||||||
_safe(_finance)
|
if d
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Finance (latest year)
|
||||||
|
f = safe_query(FactFinance, "urn", "year")
|
||||||
|
result["finance"] = (
|
||||||
|
{
|
||||||
|
"year": f.year,
|
||||||
|
"per_pupil_spend": f.per_pupil_spend,
|
||||||
|
"staff_cost_pct": f.staff_cost_pct,
|
||||||
|
"teacher_cost_pct": f.teacher_cost_pct,
|
||||||
|
"support_staff_cost_pct": f.support_staff_cost_pct,
|
||||||
|
"premises_cost_pct": f.premises_cost_pct,
|
||||||
|
}
|
||||||
|
if f
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def get_supplementary_data(db: Session, urn: int) -> dict:
|
|
||||||
"""Supplementary data for a single URN (thin wrapper over the batch)."""
|
|
||||||
return get_supplementary_data_batch(db, [urn])[int(urn)]
|
|
||||||
|
|||||||
@@ -67,9 +67,7 @@ def client(monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(app_module, "load_school_data", _two_primary_schools_df)
|
monkeypatch.setattr(app_module, "load_school_data", _two_primary_schools_df)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
app_module,
|
app_module, "get_supplementary_data", lambda db, urn: dict(CANNED_SUPPLEMENTARY)
|
||||||
"get_supplementary_data_batch",
|
|
||||||
lambda db, urns: {int(u): dict(CANNED_SUPPLEMENTARY) for u in urns},
|
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(database_module, "SessionLocal", _StubSession)
|
monkeypatch.setattr(database_module, "SessionLocal", _StubSession)
|
||||||
return TestClient(app_module.app, raise_server_exceptions=False)
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||||
@@ -104,10 +102,10 @@ def test_top_level_national_averages_and_benchmarks(client):
|
|||||||
def test_supplementary_failure_degrades_not_500(client, monkeypatch):
|
def test_supplementary_failure_degrades_not_500(client, monkeypatch):
|
||||||
from backend import app as app_module
|
from backend import app as app_module
|
||||||
|
|
||||||
def _boom(db, urns):
|
def _boom(db, urn):
|
||||||
raise RuntimeError("marts unavailable")
|
raise RuntimeError("marts unavailable")
|
||||||
|
|
||||||
monkeypatch.setattr(app_module, "get_supplementary_data_batch", _boom)
|
monkeypatch.setattr(app_module, "get_supplementary_data", _boom)
|
||||||
resp = client.get("/api/compare?urns=100140")
|
resp = client.get("/api/compare?urns=100140")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
school = resp.json()["comparison"]["100140"]
|
school = resp.json()["comparison"]["100140"]
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
"""get_supplementary_data_batch fetches one query per table for all URNs
|
|
||||||
(not ~5 per school) and returns the same per-URN block shape as the
|
|
||||||
single-URN function, picking the latest row per URN where relevant."""
|
|
||||||
|
|
||||||
import types
|
|
||||||
|
|
||||||
from backend import data_loader
|
|
||||||
from backend.data_loader import get_supplementary_data_batch
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeQuery:
|
|
||||||
"""Records that a query ran and serves canned rows filtered by an in-list."""
|
|
||||||
|
|
||||||
def __init__(self, recorder, model_name, rows):
|
|
||||||
self._rec = recorder
|
|
||||||
self._model = model_name
|
|
||||||
self._rows = rows
|
|
||||||
|
|
||||||
def filter(self, *args, **kwargs):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def order_by(self, *args, **kwargs):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def all(self):
|
|
||||||
self._rec.append(self._model)
|
|
||||||
return self._rows
|
|
||||||
|
|
||||||
def first(self):
|
|
||||||
self._rec.append(self._model)
|
|
||||||
return self._rows[0] if self._rows else None
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeSession:
|
|
||||||
def __init__(self, rows_by_model):
|
|
||||||
self.rows_by_model = rows_by_model
|
|
||||||
self.queries: list[str] = []
|
|
||||||
|
|
||||||
def query(self, model):
|
|
||||||
name = model.__name__
|
|
||||||
return _FakeQuery(self.queries, name, self.rows_by_model.get(name, []))
|
|
||||||
|
|
||||||
def rollback(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _ofsted_row(urn, date, oe):
|
|
||||||
base = {f: None for f in (
|
|
||||||
"framework", "inspection_type", "quality_of_education", "behaviour_attitudes",
|
|
||||||
"personal_development", "leadership_management", "early_years_provision",
|
|
||||||
"sixth_form_provision", "ungraded_outcome", "ungraded_grade",
|
|
||||||
"rc_safeguarding_met", "rc_inclusion", "rc_curriculum_teaching", "rc_achievement",
|
|
||||||
"rc_attendance_behaviour", "rc_personal_development", "rc_leadership_governance",
|
|
||||||
"rc_early_years", "rc_sixth_form", "report_url",
|
|
||||||
)}
|
|
||||||
base.update(urn=urn, inspection_date=types.SimpleNamespace(isoformat=lambda: date),
|
|
||||||
overall_effectiveness=oe, grade_source=None)
|
|
||||||
return types.SimpleNamespace(**base)
|
|
||||||
|
|
||||||
|
|
||||||
def _adm_row(urn, year):
|
|
||||||
return types.SimpleNamespace(
|
|
||||||
urn=urn, year=year, school_phase="Primary", places_offered=100,
|
|
||||||
total_applications=200, first_preference_applications=150,
|
|
||||||
first_preference_offers=140, first_preference_offer_pct=93.3,
|
|
||||||
oversubscription_ratio=1.5, oversubscribed=True,
|
|
||||||
total_offers=100, second_preference_offers=5, third_preference_offers=2,
|
|
||||||
cross_la_applications=10, cross_la_offers=3,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_query_per_table_and_latest_row_per_urn():
|
|
||||||
rows = {
|
|
||||||
# URN 1 has two Ofsted rows; the batch must keep the most recent (2023).
|
|
||||||
"FactOfstedInspection": [
|
|
||||||
_ofsted_row(1, "2023-01-01", 2),
|
|
||||||
_ofsted_row(1, "2019-01-01", 3),
|
|
||||||
_ofsted_row(2, "2021-06-01", 1),
|
|
||||||
],
|
|
||||||
"FactAdmissions": [_adm_row(1, 202526), _adm_row(1, 202627), _adm_row(2, 202627)],
|
|
||||||
"FactPupilCharacteristics": [],
|
|
||||||
"FactDeprivation": [],
|
|
||||||
"FactFinance": [],
|
|
||||||
}
|
|
||||||
session = _FakeSession(rows)
|
|
||||||
out = get_supplementary_data_batch(session, [1, 2])
|
|
||||||
|
|
||||||
# Exactly one query per table — five total, regardless of two URNs.
|
|
||||||
assert sorted(session.queries) == [
|
|
||||||
"FactAdmissions", "FactDeprivation", "FactFinance",
|
|
||||||
"FactOfstedInspection", "FactPupilCharacteristics",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Latest Ofsted kept per URN
|
|
||||||
assert out[1]["ofsted"]["overall_effectiveness"] == 2
|
|
||||||
assert out[2]["ofsted"]["overall_effectiveness"] == 1
|
|
||||||
|
|
||||||
# Admissions history grouped per URN, latest exposed as `admissions`
|
|
||||||
assert [r["year"] for r in out[1]["admissions_history"]] == [202526, 202627]
|
|
||||||
assert out[1]["admissions"]["year"] == 202627
|
|
||||||
assert out[2]["admissions_history"] == [{**out[2]["admissions_history"][0]}]
|
|
||||||
|
|
||||||
# Empty tables degrade to the null block, not a crash
|
|
||||||
assert out[1]["census"] is None and out[1]["deprivation"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_single_wrapper_matches_batch(monkeypatch):
|
|
||||||
session = _FakeSession({"FactOfstedInspection": [_ofsted_row(5, "2022-01-01", 2)]})
|
|
||||||
single = data_loader.get_supplementary_data(session, 5)
|
|
||||||
assert single["ofsted"]["overall_effectiveness"] == 2
|
|
||||||
assert single["admissions_history"] == []
|
|
||||||
+26
-11
@@ -19,6 +19,27 @@ function schoolLinks(page: Page) {
|
|||||||
return page.locator('a[href^="/school/"]');
|
return page.locator('a[href^="/school/"]');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two URNs guaranteed to be pure-primary (same phase). The compare page's
|
||||||
|
* phase tabs split all-through schools (which carry KS4 data) onto the
|
||||||
|
* secondary tab, so picking two arbitrary "primary" search hits can land
|
||||||
|
* them on different tabs where only the active one renders. Selecting via
|
||||||
|
* the API by exact phase keeps both on the same tab. Data-invariant: uses
|
||||||
|
* whatever primaries the environment holds.
|
||||||
|
*/
|
||||||
|
async function twoPrimaryUrns(page: Page): Promise<[string, string]> {
|
||||||
|
const res = await page.request.get('/api/schools?search=primary&per_page=50');
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const body = await res.json();
|
||||||
|
const urns: string[] = (body.schools ?? [])
|
||||||
|
.filter((s: { phase?: string; rwm_expected_pct?: number | null }) =>
|
||||||
|
s.phase === 'Primary' && s.rwm_expected_pct != null,
|
||||||
|
)
|
||||||
|
.map((s: { urn: number }) => String(s.urn));
|
||||||
|
expect(urns.length).toBeGreaterThanOrEqual(2);
|
||||||
|
return [urns[0], urns[1]];
|
||||||
|
}
|
||||||
|
|
||||||
test('home page loads with hero search', async ({ page }) => {
|
test('home page loads with hero search', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.locator('h1').first()).toBeVisible();
|
await expect(page.locator('h1').first()).toBeVisible();
|
||||||
@@ -139,19 +160,13 @@ test('results map fullscreen falls back to an overlay on iOS', async ({ page })
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('comparing two schools shows the parent-first sections side by side', async ({ page }) => {
|
test('comparing two schools shows the parent-first sections side by side', async ({ page }) => {
|
||||||
// Collect two school URNs from search results, then load the share URL
|
// Two same-phase (pure primary) schools so both stay on one tab.
|
||||||
await searchByName(page, 'primary');
|
const [urn0, urn1] = await twoPrimaryUrns(page);
|
||||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
|
||||||
const hrefs = await schoolLinks(page).evaluateAll((links) =>
|
|
||||||
links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '')
|
|
||||||
);
|
|
||||||
const urns = [...new Set(hrefs.map((h) => h.match(/\/school\/(\d+)/)?.[1]).filter(Boolean))];
|
|
||||||
expect(urns.length).toBeGreaterThanOrEqual(2);
|
|
||||||
|
|
||||||
await page.goto(`/compare?urns=${urns[0]},${urns[1]}`);
|
await page.goto(`/compare?urns=${urn0},${urn1}`);
|
||||||
// Both schools' detail links should render in the comparison view
|
// Both schools' detail links should render in the comparison view
|
||||||
await expect(page.locator(`a[href*="${urns[0]}"]`).first()).toBeVisible({ timeout: 15_000 });
|
await expect(page.locator(`a[href*="${urn0}"]`).first()).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(page.locator(`a[href*="${urns[1]}"]`).first()).toBeVisible();
|
await expect(page.locator(`a[href*="${urn1}"]`).first()).toBeVisible();
|
||||||
|
|
||||||
// The parent-first sections render in order (data-invariant: headings only)
|
// The parent-first sections render in order (data-invariant: headings only)
|
||||||
for (const heading of [
|
for (const heading of [
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Regression: on refresh, the compare page must show the SSR-rendered data.
|
||||||
|
*
|
||||||
|
* The basket hydrates from the URL a beat after mount (selectedSchools is
|
||||||
|
* empty for the first render), so the fetch effect must not blank the
|
||||||
|
* SSR payload during that window — and must not refetch data the server
|
||||||
|
* already provided.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { ComparisonView } from '@/components/ComparisonView';
|
||||||
|
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||||
|
import type { ComparisonData, School } from '@/lib/types';
|
||||||
|
|
||||||
|
const fetchComparison = jest.fn();
|
||||||
|
jest.mock('@/lib/api', () => ({
|
||||||
|
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
|
||||||
|
}));
|
||||||
|
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
|
||||||
|
|
||||||
|
function school(urn: number, name: string): School {
|
||||||
|
return {
|
||||||
|
urn,
|
||||||
|
school_name: name,
|
||||||
|
local_authority: 'Testshire',
|
||||||
|
school_type: 'Community school',
|
||||||
|
rwm_expected_pct: 80,
|
||||||
|
phase: 'Primary',
|
||||||
|
} as School;
|
||||||
|
}
|
||||||
|
|
||||||
|
function data(urn: number, name: string): ComparisonData {
|
||||||
|
return {
|
||||||
|
school_info: school(urn, name),
|
||||||
|
yearly_data: [{ year: 202425, rwm_expected_pct: 80 }] as ComparisonData['yearly_data'],
|
||||||
|
ofsted: null,
|
||||||
|
census: null,
|
||||||
|
admissions: null,
|
||||||
|
admissions_history: [],
|
||||||
|
deprivation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_DATA = {
|
||||||
|
'100': data(100, 'Alpha Primary'),
|
||||||
|
'200': data(200, 'Beta Primary'),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchComparison.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders SSR data on refresh without wiping it or refetching', async () => {
|
||||||
|
render(
|
||||||
|
<ComparisonProvider>
|
||||||
|
<ComparisonView
|
||||||
|
initialData={INITIAL_DATA}
|
||||||
|
initialNationalAverages={{
|
||||||
|
year: 202425,
|
||||||
|
primary: { rwm_expected_pct: 62 },
|
||||||
|
secondary: {},
|
||||||
|
by_year: [],
|
||||||
|
}}
|
||||||
|
initialBenchmarks={undefined}
|
||||||
|
initialUrns={[100, 200]}
|
||||||
|
metrics={[]}
|
||||||
|
selectedMetric="rwm_expected_pct"
|
||||||
|
/>
|
||||||
|
</ComparisonProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both SSR-provided schools appear (data was not blanked during hydration)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByText('Alpha Primary').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
expect(screen.getAllByText('Beta Primary').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
// …and the client never refetched data the server already rendered.
|
||||||
|
expect(fetchComparison).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -107,24 +107,26 @@ export function ComparisonView({
|
|||||||
router.replace(newUrl, { scroll: false });
|
router.replace(newUrl, { scroll: false });
|
||||||
}, [urnKey, selectedMetric, pathname, searchParams, router]);
|
}, [urnKey, selectedMetric, pathname, searchParams, router]);
|
||||||
|
|
||||||
// Fetch only when the school set changes. The very first run is skipped
|
// Fetch when the school set changes, but only for schools we don't already
|
||||||
// when the SSR payload already covers the current set — no double-fetch
|
// have data for. This skips the refetch of SSR-rendered data on load AND
|
||||||
// of data the server just rendered.
|
// avoids a network call when a school is merely removed. A ref holds the
|
||||||
const firstFetchRef = useRef(true);
|
// latest data so the effect can read it without re-running on every fetch.
|
||||||
useEffect(() => {
|
//
|
||||||
if (!urnKey) {
|
// Correctness note: we must NOT null the data on a transient empty urnKey.
|
||||||
setComparisonData(null);
|
// On mount the basket is empty for a beat before it hydrates from the URL,
|
||||||
setNationalAverages(undefined);
|
// and blanking here (then skipping the refetch because SSR "covers" the set)
|
||||||
setBenchmarks(undefined);
|
// was leaving the page empty on refresh. The render already shows the empty
|
||||||
return;
|
// state whenever `selectedSchools` is empty, so stale data for deselected
|
||||||
}
|
// schools is harmless — it's simply unused.
|
||||||
|
const comparisonDataRef = useRef(comparisonData);
|
||||||
|
comparisonDataRef.current = comparisonData;
|
||||||
|
|
||||||
if (firstFetchRef.current) {
|
useEffect(() => {
|
||||||
firstFetchRef.current = false;
|
if (!isInitialized || !urnKey) return;
|
||||||
const ssrUrns = new Set(Object.keys(initialData ?? {}));
|
|
||||||
const covered = urnKey.split(',').every((urn) => ssrUrns.has(urn));
|
const have = comparisonDataRef.current ?? {};
|
||||||
if (covered && ssrUrns.size > 0) return;
|
const covered = urnKey.split(',').every((urn) => have[urn] != null);
|
||||||
}
|
if (covered) return;
|
||||||
|
|
||||||
fetchComparison(urnKey, { cache: 'no-store' })
|
fetchComparison(urnKey, { cache: 'no-store' })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -138,8 +140,7 @@ export function ComparisonView({
|
|||||||
// destroy a working comparison the user is looking at.
|
// destroy a working comparison the user is looking at.
|
||||||
console.error('Failed to fetch comparison:', err);
|
console.error('Failed to fetch comparison:', err);
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [urnKey, isInitialized]);
|
||||||
}, [urnKey]);
|
|
||||||
|
|
||||||
// Classify schools by phase using comparison data
|
// Classify schools by phase using comparison data
|
||||||
const classifySchool = (school: School): 'primary' | 'secondary' => {
|
const classifySchool = (school: School): 'primary' | 'secondary' => {
|
||||||
|
|||||||
@@ -1,50 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Custom hook for managing school comparison state
|
* Custom hook for managing school comparison state.
|
||||||
* Uses shared context for real-time updates across components
|
*
|
||||||
|
* This hook is mounted on every page via the global Navigation and
|
||||||
|
* ComparisonToast, so it must stay cheap — it exposes basket state only.
|
||||||
|
* The compare page fetches `/api/compare` itself (ComparisonView); nothing
|
||||||
|
* ever read the comparison payload from here, so the previous per-page SWR
|
||||||
|
* fetch (which fired on every page whenever the basket was non-empty) was
|
||||||
|
* dead weight and has been removed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import useSWR from 'swr';
|
|
||||||
import { fetcher } from '@/lib/api';
|
|
||||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||||
import type { ComparisonResponse } from '@/lib/types';
|
|
||||||
|
|
||||||
export function useComparison() {
|
export function useComparison() {
|
||||||
const {
|
return useComparisonContext();
|
||||||
selectedSchools,
|
|
||||||
addSchool,
|
|
||||||
removeSchool,
|
|
||||||
replaceSchools,
|
|
||||||
clearAll,
|
|
||||||
isSelected,
|
|
||||||
canAddMore,
|
|
||||||
isInitialized,
|
|
||||||
} = useComparisonContext();
|
|
||||||
|
|
||||||
// Fetch comparison data for selected schools
|
|
||||||
const urns = selectedSchools.map((s) => s.urn).join(',');
|
|
||||||
const { data, error, isLoading, mutate } = useSWR<ComparisonResponse>(
|
|
||||||
selectedSchools.length > 0 ? `/compare?urns=${urns}` : null,
|
|
||||||
fetcher,
|
|
||||||
{
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
dedupingInterval: 10000,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
selectedSchools,
|
|
||||||
comparisonData: data?.comparison,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
addSchool,
|
|
||||||
removeSchool,
|
|
||||||
replaceSchools,
|
|
||||||
clearAll,
|
|
||||||
isSelected,
|
|
||||||
canAddMore,
|
|
||||||
isInitialized,
|
|
||||||
mutate,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user