Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd7c8593d9 | ||
|
|
0c414680fd | ||
|
|
e211e1376d | ||
|
|
74418ca6b9 |
No files matched your search
+6
-4
@@ -736,7 +736,7 @@ async def get_schools(
|
||||
None, description="Filter by local authority", max_length=100
|
||||
),
|
||||
school_type: Optional[str] = Query(None, description="Filter by school type", max_length=100),
|
||||
phase: Optional[str] = Query(None, description="Filter by phase: primary, secondary, all-through", max_length=50),
|
||||
phase: Optional[str] = Query(None, description="Filter by phase: primary or secondary (grouped), or any GIAS phase name (exact)", max_length=50),
|
||||
postcode: Optional[str] = Query(None, description="Search near postcode", max_length=10),
|
||||
radius: float = Query(5.0, ge=0.1, le=5, description="Search radius in miles"),
|
||||
page: int = Query(1, ge=1, le=1000, description="Page number"),
|
||||
@@ -771,11 +771,13 @@ async def get_schools(
|
||||
|
||||
# Phase filter — uses PHASE_GROUPS so all-through/middle schools appear
|
||||
# in the correct phase(s) rather than being invisible to both filters.
|
||||
# Any other GIAS phase (nursery, 16 plus, middle deemed ...) is an exact
|
||||
# match. It must never fall through to no filter: the search page offers
|
||||
# every phase, and "Nursery" used to return the whole result set.
|
||||
if phase:
|
||||
phase_lower = phase.lower().replace("_", "-")
|
||||
allowed = PHASE_GROUPS.get(phase_lower)
|
||||
if allowed:
|
||||
df_latest = df_latest[df_latest["phase"].str.lower().isin(allowed)]
|
||||
allowed = PHASE_GROUPS.get(phase_lower, {phase_lower})
|
||||
df_latest = df_latest[df_latest["phase"].fillna("").str.lower().isin(allowed)]
|
||||
|
||||
# Secondary-specific filters (after phase filter)
|
||||
if gender:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""The /api/schools phase filter.
|
||||
|
||||
The search page offers every GIAS phase, but the filter only knew the three
|
||||
grouped ones (primary, secondary, all-through). Anything else — nursery,
|
||||
16 plus, the middle-deemed phases — fell through to no filter at all, so
|
||||
"Nursery" returned the whole result set, mostly primaries.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
PHASES = {
|
||||
100001: "Nursery",
|
||||
100002: "Primary",
|
||||
100003: "Middle deemed primary",
|
||||
100004: "Secondary",
|
||||
100005: "Middle deemed secondary",
|
||||
100006: "16 plus",
|
||||
100007: "All-through",
|
||||
}
|
||||
|
||||
|
||||
def _schools_df() -> pd.DataFrame:
|
||||
base = {
|
||||
"local_authority": "Testshire",
|
||||
"school_type": "Academy",
|
||||
"address": "1 Test Street",
|
||||
"town": "Testtown",
|
||||
"postcode": "TS1 1AA",
|
||||
"religious_denomination": None,
|
||||
"age_range": "4-11",
|
||||
"has_sixth_form": None,
|
||||
"gender": "Mixed",
|
||||
"admissions_policy": None,
|
||||
"ofsted_grade": np.nan,
|
||||
"ofsted_date": None,
|
||||
"ofsted_framework": None,
|
||||
"latitude": 51.5,
|
||||
"longitude": -0.1,
|
||||
"year": 202425,
|
||||
"total_pupils": 300,
|
||||
"rwm_expected_pct": np.nan,
|
||||
"attainment_8_score": np.nan,
|
||||
}
|
||||
return pd.DataFrame([
|
||||
{**base, "urn": urn, "school_name": f"{phase} School", "phase": phase}
|
||||
for urn, phase in PHASES.items()
|
||||
])
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
|
||||
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
|
||||
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def _urns(client, phase):
|
||||
resp = client.get("/api/schools", params={"phase": phase})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return sorted(s["urn"] for s in resp.json()["schools"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phase, urn", [
|
||||
("nursery", 100001),
|
||||
("16 plus", 100006),
|
||||
("middle deemed primary", 100003),
|
||||
("middle deemed secondary", 100005),
|
||||
])
|
||||
def test_an_ungrouped_phase_matches_exactly(client, phase, urn):
|
||||
assert _urns(client, phase) == [urn]
|
||||
|
||||
|
||||
def test_grouped_phases_still_take_in_their_related_phases(client):
|
||||
assert _urns(client, "primary") == [100002, 100003, 100007]
|
||||
assert _urns(client, "secondary") == [100004, 100005, 100006, 100007]
|
||||
|
||||
|
||||
def test_an_unknown_phase_returns_nothing_rather_than_everything(client):
|
||||
assert _urns(client, "kindergarten") == []
|
||||
@@ -272,6 +272,31 @@ test('searching by postcode returns nearby schools', async ({ page }) => {
|
||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('the phase filter switches straight from secondary to primary', async ({ page }) => {
|
||||
// The phase options once came from the result set, which the phase filter
|
||||
// had already narrowed — so with secondary chosen, primary was not offered.
|
||||
await page.goto('/?search=school&phase=secondary');
|
||||
const phase = page.getByRole('combobox', { name: 'Phase' });
|
||||
await expect(phase).toHaveValue('secondary', { timeout: 15_000 });
|
||||
await phase.selectOption('primary');
|
||||
await expect(page).toHaveURL(/[?&]phase=primary(&|$)/);
|
||||
await expect(phase).toHaveValue('primary');
|
||||
});
|
||||
|
||||
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
|
||||
// ones and silently dropped the rest — so "Nursery" returned primaries.
|
||||
for (const phase of ['Nursery', '16 plus']) {
|
||||
const res = await page.request.get(
|
||||
`/api/schools?phase=${encodeURIComponent(phase.toLowerCase())}&page_size=50`);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const phases = new Set(
|
||||
((await res.json()).schools ?? []).map((s: { phase?: string }) => s.phase));
|
||||
expect([...phases], `phase=${phase} returned other phases`)
|
||||
.toEqual(phases.size ? [phase] : []);
|
||||
}
|
||||
});
|
||||
|
||||
test('a report-card school shows a Report Card badge in search results, not its old grade', async ({ page }) => {
|
||||
// List/map badges keyed off ofsted_grade (the carried-forward legacy grade)
|
||||
// and never reached the report-card branch, so report-card schools were
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { FilterBar } from '@/components/FilterBar';
|
||||
|
||||
let searchParams = new URLSearchParams();
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
|
||||
usePathname: () => '/',
|
||||
useSearchParams: () => searchParams,
|
||||
}));
|
||||
|
||||
const FILTERS = {
|
||||
local_authorities: [], school_types: [], years: [],
|
||||
phases: ['Primary', 'Secondary', 'All-through'],
|
||||
genders: [], admissions_policies: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* The phase options must not come from the result set. The backend scopes its
|
||||
* result filters to the schools it returns, and it applies the phase filter
|
||||
* first — so with "secondary" chosen the scoped list holds only secondary-ish
|
||||
* phases, and switching to primary meant going back to "Any phase" first.
|
||||
*/
|
||||
describe('FilterBar phase options', () => {
|
||||
it('offers every phase while a phase filter narrows the results', () => {
|
||||
searchParams = new URLSearchParams('search=hampton&phase=secondary');
|
||||
render(
|
||||
<FilterBar
|
||||
filters={FILTERS}
|
||||
resultFilters={{
|
||||
local_authorities: [], school_types: [],
|
||||
phases: ['Secondary', 'All-through'],
|
||||
genders: [], admissions_policies: [],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const phase = screen.getByRole('combobox', { name: 'Phase' });
|
||||
expect(within(phase).getByRole('option', { name: 'Primary' })).toBeInTheDocument();
|
||||
expect(phase).toHaveValue('secondary');
|
||||
});
|
||||
});
|
||||
@@ -295,7 +295,10 @@ export function FilterBar({
|
||||
const laOptions =
|
||||
resultFilters?.local_authorities ?? filters.local_authorities;
|
||||
const typeOptions = resultFilters?.school_types ?? filters.school_types;
|
||||
const phaseOptions = resultFilters?.phases ?? filters.phases ?? [];
|
||||
// Phase is the exception: always the full list. The result set has already
|
||||
// been narrowed by the phase filter, so scoping to it would leave only the
|
||||
// chosen phase on offer and switching phase would need "Any phase" first.
|
||||
const phaseOptions = filters.phases ?? [];
|
||||
const genderOptions = resultFilters?.genders ?? filters.genders ?? [];
|
||||
const admissionsPolicyOptions =
|
||||
resultFilters?.admissions_policies ?? filters.admissions_policies ?? [];
|
||||
|
||||
Reference in new issue
Block a user