diff --git a/backend/app.py b/backend/app.py index f451a9a..a054a6b 100644 --- a/backend/app.py +++ b/backend/app.py @@ -40,7 +40,7 @@ from .data_loader import ( from .data_loader import get_data_info as get_db_info from . import flags from .places import build_place_index, build_place_registry, places_for_urn -from .schemas import METRIC_DEFINITIONS, PHASE_GROUPS, RANKING_COLUMNS, SCHOOL_COLUMNS +from .schemas import METRIC_DEFINITIONS, PHASE_GROUPS, PHASE_ORDER, RANKING_COLUMNS, SCHOOL_COLUMNS from .school_groups import ( FAITH_GROUPS, FAITH_KEYS, @@ -381,6 +381,12 @@ def clean_filter_values(series: pd.Series) -> list[str]: ) +def order_phases(phases: list[str]) -> list[str]: + """Phases in the order a child meets them; any GIAS adds later follow, A-Z.""" + rank = {p: i for i, p in enumerate(PHASE_ORDER)} + return sorted(phases, key=lambda p: (rank.get(p.lower(), len(rank)), p)) + + # ============================================================================= # SECURITY MIDDLEWARE & HELPERS # ============================================================================= @@ -937,7 +943,7 @@ async def get_schools( result_filters = { "local_authorities": clean_filter_values(schools_df["local_authority"]) if "local_authority" in schools_df.columns else [], "school_types": clean_filter_values(schools_df["school_type"]) if "school_type" in schools_df.columns else [], - "phases": clean_filter_values(schools_df["phase"]) if "phase" in schools_df.columns else [], + "phases": order_phases(clean_filter_values(schools_df["phase"])) if "phase" in schools_df.columns else [], "genders": clean_filter_values(schools_df.loc[_sec_mask, "gender"]) if "gender" in schools_df.columns and _sec_mask.any() else [], "admissions_policies": clean_filter_values(schools_df.loc[_sec_mask, "admissions_policy"]) if "admissions_policy" in schools_df.columns and _sec_mask.any() else [], } @@ -1207,8 +1213,8 @@ async def get_filter_options(request: Request): "faiths": [], } - # Phases: return values from data, ordered sensibly - phases = clean_filter_values(df["phase"]) if "phase" in df.columns else [] + # Phases: the values in the data, in the order a child meets them + phases = order_phases(clean_filter_values(df["phase"])) if "phase" in df.columns else [] secondary_df = df[df["attainment_8_score"].notna()] if "attainment_8_score" in df.columns else df.iloc[0:0] genders = clean_filter_values(secondary_df["gender"]) if "gender" in secondary_df.columns else [] diff --git a/backend/schemas.py b/backend/schemas.py index 5dbec05..005a815 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -544,6 +544,19 @@ PHASE_GROUPS: dict[str, set[str]] = { "all-through": {"all-through"}, } +# GIAS phases in the order a child meets them, for the phase filter's options. +# All-through spans the whole path, so it follows the stages. Lowercased, as +# PHASE_GROUPS is, so a change of case in the GIAS label keeps its place. +PHASE_ORDER: list[str] = [ + "nursery", + "primary", + "middle deemed primary", + "middle deemed secondary", + "secondary", + "16 plus", + "all-through", +] + # School listing columns SCHOOL_COLUMNS = [ "urn", diff --git a/backend/tests/test_phase_filter.py b/backend/tests/test_phase_filter.py index f93bb26..b455f56 100644 --- a/backend/tests/test_phase_filter.py +++ b/backend/tests/test_phase_filter.py @@ -82,3 +82,25 @@ def test_grouped_phases_still_take_in_their_related_phases(client): def test_an_unknown_phase_returns_nothing_rather_than_everything(client): assert _urns(client, "kindergarten") == [] + + +def test_filters_lists_phases_in_the_order_a_child_meets_them(client): + # Alphabetical put "16 plus" and "All-through" first and Nursery fifth. + # All-through spans the whole path, so it comes after the stages. + assert client.get("/api/filters").json()["phases"] == [ + "Nursery", + "Primary", + "Middle deemed primary", + "Middle deemed secondary", + "Secondary", + "16 plus", + "All-through", + ] + + +def test_an_unknown_phase_follows_the_known_ones(): + from backend.app import order_phases + + assert order_phases(["Secondary", "Zeta", "Alpha", "Nursery"]) == [ + "Nursery", "Secondary", "Alpha", "Zeta", + ] diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index ae585ee..13ebecf 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -286,6 +286,19 @@ test('the phase filter switches straight from secondary to primary', async ({ pa await expect(phase).toHaveValue('primary'); }); +test('the phase filter lists phases in the order a child meets them', async ({ page }) => { + // They were alphabetical, so "16 plus" and "All-through" came before Nursery. + const childPath = ['Nursery', 'Primary', 'Middle deemed primary', + 'Middle deemed secondary', 'Secondary', '16 plus', 'All-through']; + await page.goto('/?search=school'); + const phase = page.getByRole('combobox', { name: 'Phase' }); + await expect(phase).toBeVisible({ timeout: 15_000 }); + const offered = (await phase.locator('option').allTextContents()) + .filter((o) => childPath.includes(o)); + expect(offered, 'no phase on offer').toContain('Primary'); + expect(offered).toEqual(childPath.filter((p) => offered.includes(p))); +}); + test('school type and gender switch straight to another value', async ({ page }) => { // Their options came from the result set, which the filter had already // narrowed, so with one value chosen it was the only one on offer.