docs(plan): school type groups and a faith filter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
TudorandClaude Opus 5.5 committed 2026-10-02 11:44:57 +01:00
1 parent 50546ecf22
commit 3e2fa4a419
1 file changed
+911
@@ -0,0 +1,911 @@
# School Type Groups and a Faith Filter Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the School type filter's 34 GIAS types with six parent-facing groups, and add a Faith filter with eight options.
**Architecture:** A new backend module `backend/school_groups.py` holds both groupings as GIAS code sets and exposes lookups by the translated *name* (the DataFrame the API filters only carries names). `/api/schools` accepts a group key for `school_type` and a new `faith` key; `/api/filters` gains `school_type_groups` and `faiths` as `{value, label}` lists. The frontend's FilterBar reads those lists for its School type select and a new Faith select.
**Tech Stack:** FastAPI + pandas (backend, pytest via uv), Next.js 15 / React (frontend, Jest + Testing Library), Playwright (E2E against staging).
**Spec:** `docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md`
## Global Constraints
- Type group keys, labels and order, verbatim: `academy` "State school: academy or free school", `council` "State school: council-run", `independent` "Independent (fee-paying)", `special` "Special school (SEND)", `post16` "Sixth form or college", `alternative` "Alternative provision".
- Faith keys, labels and order, verbatim: `none` "No religious character", `church_of_england` "Church of England", `roman_catholic` "Roman Catholic", `other_christian` "Other Christian", `jewish` "Jewish", `muslim` "Muslim", `other_faith` "Other faith".
- Select defaults, verbatim: "Any school type", "Any faith or none". Faith select `aria-label="Faith"`.
- `/api/filters` keeps `school_types` (raw list) unchanged; the new keys are additions.
- An old raw-label `school_type` value (e.g. `Community school`) still filters by that label exactly.
- An unknown `faith` key returns no schools.
- Do not change `backend/gias_codes.py` or `pipeline/scripts/gias_codes.py` (their parity is tested).
- No copy may use an em dash (`__tests__/components/noEmDashCopy.test.ts`).
- Backend tests run from the repo root with: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q`
- Frontend checks run from `nextjs-app/`: `npx jest` and `npx tsc --noEmit`.
- Do not start a local server. E2E journeys are verified against staging after merge.
## Review Focus
- A school whose `religious_denomination` is `None`/NaN or `""` (GIAS code 99) must count as "No religious character", not drop out of every faith option. Pinned in Task 1.
- A type or religion label the dictionaries do not know (e.g. `"Unknown (9999)"`, or a test fixture's `"Academy"`) must map to no group without raising. Pinned in Task 1.
- `/api/filters` returning no `school_type_groups`/`faiths` (API failed, or an older backend) must leave the selects out rather than render "Any…" alone. Pinned in Task 3.
- An old bookmarked URL with `school_type=Community+school` must still return community schools and show a chip with its raw label. Pinned in Tasks 2 and 3.
- Mixed case in a key (`?faith=Roman_Catholic`, `?school_type=Special`) must still match. Pinned in Task 2.
---
## File Structure
| File | Responsibility |
|---|---|
| `backend/school_groups.py` (new) | The two groupings as code sets, and name-based lookups. Nothing else. |
| `backend/tests/test_school_groups.py` (new) | Coverage of every GIAS code, joint faiths, unknown/blank names. |
| `backend/app.py` (modify) | `faith` param; group-key branch for `school_type`; two new `/api/filters` keys. |
| `backend/tests/test_type_and_faith_filters.py` (new) | API behaviour of both filters and the new `/api/filters` keys. |
| `nextjs-app/lib/types.ts` (modify) | `FilterOption`; optional `school_type_groups`/`faiths` on `Filters`; `faith` on `SchoolSearchParams`. |
| `nextjs-app/app/(frontend)/page.tsx` (modify) | Read and forward `faith`. |
| `nextjs-app/components/FilterBar.tsx` (modify) | Grouped School type options; Faith select; chips, counts, analytics. |
| `nextjs-app/__tests__/components/FilterBarTypeFaith.test.tsx` (new) | Frontend behaviour of both selects. |
| Existing FilterBar tests (modify) | Fixtures move from raw types to groups. |
| `e2e/tests/journeys.spec.ts` (modify) | A journey for both filters. |
| Spec (modify) | Architecture section: grouping is by name at filter time. |
---
### Task 1: The groupings module
**Files:**
- Create: `backend/school_groups.py`
- Test: `backend/tests/test_school_groups.py`
- Modify: `docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md` (Architecture)
**Interfaces:**
- Consumes: `backend.gias_codes.SCHOOL_TYPE: dict[int, str]`, `backend.gias_codes.RELIGIOUS_CHARACTER: dict[int, str]` (code 99 maps to `""`).
- Produces:
- `TYPE_GROUPS: tuple[tuple[str, str, frozenset[int]], ...]` (key, label, codes), in display order
- `UNOFFERED_TYPE_CODES: frozenset[int]`
- `FAITH_GROUPS: tuple[tuple[str, str, frozenset[int]], ...]`, in display order
- `TYPE_GROUP_KEYS: frozenset[str]`, `FAITH_KEYS: frozenset[str]`
- `type_group_for(name: object) -> str | None`
- `faith_groups_for(name: object) -> tuple[str, ...]`
- [ ] **Step 1: Write the failing tests**
Create `backend/tests/test_school_groups.py`:
```python
"""Parent-facing groups over GIAS establishment types and religious characters.
Every GIAS code must be accounted for, so a new DfE code fails here instead of
silently vanishing from the filter.
"""
from pathlib import Path
import numpy as np
import pytest
import yaml
from backend.gias_codes import RELIGIOUS_CHARACTER, SCHOOL_TYPE
from backend.school_groups import (
FAITH_GROUPS,
TYPE_GROUPS,
UNOFFERED_TYPE_CODES,
faith_groups_for,
type_group_for,
)
DBT_PROJECT = Path(__file__).resolve().parents[2] / "pipeline" / "transform" / "dbt_project.yml"
def _non_england_codes() -> set[int]:
return set(yaml.safe_load(DBT_PROJECT.read_text())["vars"]["non_england_school_type_codes"])
def test_every_type_code_is_in_exactly_one_place():
places = [codes for _, _, codes in TYPE_GROUPS] + [UNOFFERED_TYPE_CODES, _non_england_codes()]
for code in SCHOOL_TYPE:
homes = sum(code in p for p in places)
assert homes == 1, f"type code {code} ({SCHOOL_TYPE[code]}) is in {homes} places"
def test_every_religion_code_has_a_faith():
for code, name in RELIGIOUS_CHARACTER.items():
assert any(code in codes for _, _, codes in FAITH_GROUPS), f"{code} {name!r}"
def test_type_groups_in_display_order():
assert [k for k, _, _ in TYPE_GROUPS] == [
"academy", "council", "independent", "special", "post16", "alternative"]
def test_faiths_in_display_order():
assert [k for k, _, _ in FAITH_GROUPS] == [
"none", "church_of_england", "roman_catholic", "other_christian",
"jewish", "muslim", "other_faith"]
@pytest.mark.parametrize("name, group", [
("Academy converter", "academy"),
("University technical college", "academy"),
("Voluntary aided school", "council"),
("Local authority nursery school", "council"),
("Other independent school", "independent"),
("Other independent special school", "special"),
("Special post 16 institution", "special"),
("Further education", "post16"),
("Pupil referral unit", "alternative"),
("academy CONVERTER", "academy"),
])
def test_type_group_by_name(name, group):
assert type_group_for(name) == group
@pytest.mark.parametrize("name", [
"Higher education institutions", "Miscellaneous", "Unknown (9999)", "Academy", "", None, np.nan,
])
def test_unoffered_or_unknown_types_have_no_group(name):
assert type_group_for(name) is None
@pytest.mark.parametrize("name, faiths", [
("Roman Catholic/Church of England", ("church_of_england", "roman_catholic")),
("Roman Catholic/Anglican", ("church_of_england", "roman_catholic")),
("Church of England/Methodist", ("church_of_england", "other_christian")),
("Church of England/Christian", ("church_of_england",)),
("Catholic", ("roman_catholic",)),
("Inter- / non- denominational", ("other_christian",)),
("Orthodox Jewish", ("jewish",)),
("Sunni Deobandi", ("muslim",)),
("Hindu", ("other_faith",)),
("Does not apply", ("none",)),
("None", ("none",)),
])
def test_faiths_by_name(name, faiths):
assert faith_groups_for(name) == faiths
@pytest.mark.parametrize("missing", [None, np.nan, "", " "])
def test_a_missing_religion_is_no_religious_character(missing):
assert faith_groups_for(missing) == ("none",)
def test_an_unknown_religion_has_no_faith():
assert faith_groups_for("Unknown (77)") == ()
```
- [ ] **Step 2: Run them to see them fail**
Run: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_school_groups.py -q`
Expected: collection error, `ModuleNotFoundError: No module named 'backend.school_groups'`.
- [ ] **Step 3: Write the module**
Create `backend/school_groups.py`:
```python
"""Parent-facing groups over GIAS establishment types and religious characters.
The 34 GIAS establishment types describe governance and funding, which for a
mainstream state school barely changes what a parent experiences. The search
filter offers six groups a parent recognises instead, and a faith filter in
place of the faith signal that "Voluntary aided" and "Voluntary controlled"
only half carry. See
docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md.
Groups are defined over GIAS codes and looked up by the translated name,
because the DataFrame the API filters carries names only: the codes are
replaced at load (data_loader.translate_gias_code_columns), the legacy-name
mart fallback never had them, and test fixtures are written in names.
"""
from typing import Optional
import pandas as pd
from .gias_codes import RELIGIOUS_CHARACTER, SCHOOL_TYPE
# (key, label, GIAS TypeOfEstablishment codes), in the order shown.
TYPE_GROUPS: tuple[tuple[str, str, frozenset[int]], ...] = (
# Academy sponsor led, Academy converter, Free schools, University technical
# college, Studio schools, City technology college. UTCs and studio schools
# are legally academies, and a family considering one searches by name.
("academy", "State school: academy or free school", frozenset({28, 34, 35, 40, 41, 6})),
# Community, Voluntary aided, Voluntary controlled, Foundation, LA nursery.
("council", "State school: council-run", frozenset({1, 2, 3, 5, 15})),
("independent", "Independent (fee-paying)", frozenset({11})),
# Every special type, independent ones included (usually funded by the
# council through an EHCP, so SEND provision to a parent, not private
# school), and Special post 16 institutions.
("special", "Special school (SEND)", frozenset({7, 8, 10, 12, 32, 33, 36, 44})),
# Further education, Sixth form centres, the 16-19 academies and free schools.
("post16", "Sixth form or college", frozenset({18, 31, 39, 45, 46})),
# Pupil referral units and AP academies and free schools. Last: parents do
# not apply to these; the local authority places children there.
("alternative", "Alternative provision", frozenset({14, 38, 42, 43})),
)
# In no group, so reachable only under "Any school type": Secure units,
# Miscellaneous, Higher education institutions, Online provider, Institution
# funded by other government department, Academy secure 16 to 19.
UNOFFERED_TYPE_CODES: frozenset[int] = frozenset({24, 27, 29, 49, 56, 57})
# (key, label, GIAS ReligiousCharacter codes), in the order shown. A joint
# school is in every faith its label names; a generic "Christian" beside a
# named church adds nothing.
FAITH_GROUPS: tuple[tuple[str, str, frozenset[int]], ...] = (
# Does not apply, None, and 99 (a blank label).
("none", "No religious character", frozenset({0, 6, 99})),
("church_of_england", "Church of England",
frozenset({2, 9, 10, 11, 12, 13, 19, 20, 30, 31, 32, 33, 34, 41, 48})),
("roman_catholic", "Roman Catholic", frozenset({3, 11, 13, 35, 48})),
# 28 "Inter- / non- denominational" is how GIAS files Christian schools
# tied to no one church.
("other_christian", "Other Christian",
frozenset({4, 8, 9, 10, 12, 14, 15, 16, 17, 18, 19, 22, 26, 28, 30, 33,
37, 38, 39, 40, 41, 44, 45, 46, 47})),
("jewish", "Jewish", frozenset({5, 36, 43})),
("muslim", "Muslim", frozenset({7, 42, 49})),
("other_faith", "Other faith", frozenset({21, 24, 25, 29})),
)
TYPE_GROUP_KEYS: frozenset[str] = frozenset(k for k, _, _ in TYPE_GROUPS)
FAITH_KEYS: frozenset[str] = frozenset(k for k, _, _ in FAITH_GROUPS)
def _key(name: str) -> str:
return name.strip().lower()
_TYPE_GROUP_BY_NAME: dict[str, str] = {
_key(SCHOOL_TYPE[code]): key
for key, _, codes in TYPE_GROUPS
for code in codes
if code in SCHOOL_TYPE
}
_FAITHS_BY_NAME: dict[str, tuple[str, ...]] = {}
for _faith, _, _codes in FAITH_GROUPS:
for _code in sorted(_codes):
if _code in RELIGIOUS_CHARACTER:
_name = _key(RELIGIOUS_CHARACTER[_code])
_FAITHS_BY_NAME[_name] = _FAITHS_BY_NAME.get(_name, ()) + (_faith,)
def type_group_for(name: object) -> Optional[str]:
"""The type group of a GIAS establishment type name, or None."""
if not isinstance(name, str):
return None
return _TYPE_GROUP_BY_NAME.get(_key(name))
def faith_groups_for(name: object) -> tuple[str, ...]:
"""The faith groups of a GIAS religious character name.
A missing or blank name is "No religious character". A name the
dictionary does not know has no faith, so it matches no faith option.
"""
if not isinstance(name, str):
return ("none",) if name is None or pd.isna(name) else ()
return _FAITHS_BY_NAME.get(_key(name), ())
```
- [ ] **Step 4: Run the tests to see them pass**
Run: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_school_groups.py -q`
Expected: all pass.
- [ ] **Step 5: Correct the spec's architecture**
In the spec, replace the `### backend/data_loader.py` subsection with:
```markdown
### Grouping by name, at filter time
The groups are defined over codes but looked up by the translated name
(`type_group_for(name)`, `faith_groups_for(name)`), when `/api/schools` and
`/api/filters` filter. The DataFrame those endpoints read carries names only:
`translate_gias_code_columns` replaces the codes at load, the legacy-name
mart fallback never had codes, and the API test fixtures are written in
names. The names come from the same dictionaries, so the lookup is exact.
`data_loader.py` is unchanged.
```
and in `### backend/school_groups.py (new)` replace the `type_group_for(code)` / `faith_groups_for(code)` bullet with `type_group_for(name) -> str | None` and `faith_groups_for(name) -> tuple[str, ...]` (a missing or blank name gives `("none",)`).
- [ ] **Step 6: Commit**
```bash
git add backend/school_groups.py backend/tests/test_school_groups.py docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md
git commit -m "feat(api): group GIAS school types and religions for parents"
```
---
### Task 2: The API filters
**Files:**
- Modify: `backend/app.py` (imports near line 43; `get_schools` params ~738-747, sanitising ~755-759, secondary filters ~782-786, `school_type` filter ~886-889; `get_filter_options` ~1155-1180)
- Test: `backend/tests/test_type_and_faith_filters.py`
**Interfaces:**
- Consumes: from Task 1, `TYPE_GROUPS`, `FAITH_GROUPS`, `TYPE_GROUP_KEYS`, `FAITH_KEYS`, `type_group_for`, `faith_groups_for`.
- Produces:
- `GET /api/schools?school_type=<group key | raw label>&faith=<faith key>`
- `GET /api/filters` adds `"school_type_groups": [{"value": str, "label": str}]` and `"faiths": [{"value": str, "label": str}]`, in group order, groups with no school left out.
- [ ] **Step 1: Write the failing tests**
Create `backend/tests/test_type_and_faith_filters.py`:
```python
"""/api/schools school-type groups and faith filter, and their /api/filters lists."""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
# urn -> (GIAS school type, GIAS religious character)
SCHOOLS = {
100001: ("Community school", "Does not apply"),
100002: ("Voluntary aided school", "Roman Catholic"),
100003: ("Academy converter", "Roman Catholic/Church of England"),
100004: ("Community special school", None),
100005: ("Academy special converter", "Church of England"),
100006: ("Other independent school", "Jewish"),
100007: ("Miscellaneous", ""),
}
def _schools_df() -> pd.DataFrame:
base = {
"local_authority": "Testshire", "address": "1 Test Street", "town": "Testtown",
"postcode": "TS1 1AA", "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, "phase": "Primary",
}
return pd.DataFrame([
{**base, "urn": urn, "school_name": f"School {urn}",
"school_type": t, "religious_denomination": r}
for urn, (t, r) in SCHOOLS.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, **params):
resp = client.get("/api/schools", params={"page_size": 50, **params})
assert resp.status_code == 200, resp.text
return sorted(s["urn"] for s in resp.json()["schools"])
@pytest.mark.parametrize("key, urns", [
("council", [100001, 100002]),
("academy", [100003]),
("special", [100004, 100005]),
("independent", [100006]),
("Special", [100004, 100005]),
])
def test_a_type_group_key_filters_to_its_group(client, key, urns):
assert _urns(client, school_type=key) == urns
def test_a_raw_type_label_still_filters_exactly(client):
assert _urns(client, school_type="Community school") == [100001]
@pytest.mark.parametrize("key, urns", [
("roman_catholic", [100002, 100003]),
("church_of_england", [100003, 100005]),
("none", [100001, 100004, 100007]),
("jewish", [100006]),
("Roman_Catholic", [100002, 100003]),
])
def test_faith_filters_to_its_group_joint_schools_included(client, key, urns):
assert _urns(client, faith=key) == urns
def test_an_unknown_faith_returns_nothing(client):
assert _urns(client, faith="nonsense") == []
def test_type_and_faith_combine(client):
assert _urns(client, school_type="special", faith="church_of_england") == [100005]
def test_filters_lists_only_groups_present_in_order(client):
body = client.get("/api/filters").json()
assert body["school_type_groups"] == [
{"value": "academy", "label": "State school: academy or free school"},
{"value": "council", "label": "State school: council-run"},
{"value": "independent", "label": "Independent (fee-paying)"},
{"value": "special", "label": "Special school (SEND)"},
]
assert body["faiths"] == [
{"value": "none", "label": "No religious character"},
{"value": "church_of_england", "label": "Church of England"},
{"value": "roman_catholic", "label": "Roman Catholic"},
{"value": "jewish", "label": "Jewish"},
]
# The raw list is still there for anything that reads it.
assert "Community school" in body["school_types"]
```
- [ ] **Step 2: Run them to see them fail**
Run: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_type_and_faith_filters.py -q`
Expected: the group-key, faith and `/api/filters` tests fail (`KeyError: 'school_type_groups'`, empty URN lists); `test_a_raw_type_label_still_filters_exactly` passes.
- [ ] **Step 3: Implement**
In `backend/app.py`, add beside the other local imports (after the `from .schemas import ...` line):
```python
from .school_groups import (
FAITH_GROUPS,
FAITH_KEYS,
TYPE_GROUP_KEYS,
TYPE_GROUPS,
faith_groups_for,
type_group_for,
)
```
Add a helper just above `def sanitize_search_input`:
```python
def _names_in_group(names: pd.Series, in_group) -> set:
"""The distinct names in a column that a group predicate accepts.
Evaluated once per distinct name rather than per row, so a filter over
every school costs a few dozen lookups.
"""
return {n for n in names.dropna().unique() if in_group(n)}
```
In `get_schools`, add the parameter after `has_sixth_form`:
```python
faith: Optional[str] = Query(None, description="Filter by faith group key", max_length=40),
```
and after `phase = sanitize_search_input(phase)`:
```python
faith = sanitize_search_input(faith)
```
After the `has_sixth_form` filter block (the one ending `df_latest = df_latest[flag if has_sixth_form == "yes" else ~flag]`), add:
```python
# Faith group (backend/school_groups.py). A joint school is in every faith
# its label names; a missing religious character is "none". An unknown key
# matches nothing rather than being ignored, so a typo cannot show all.
if faith:
faith_key = faith.lower()
if faith_key in FAITH_KEYS and "religious_denomination" in df_latest.columns:
column = df_latest["religious_denomination"]
matches = column.isin(_names_in_group(column, lambda n: faith_key in faith_groups_for(n)))
# _names_in_group skips missing names; a missing religious
# character is "No religious character".
if faith_key == "none":
matches = matches | column.isna()
df_latest = df_latest[matches]
else:
df_latest = df_latest.iloc[0:0]
```
Replace the existing `school_type` filter:
```python
if school_type:
schools_df = schools_df[
schools_df["school_type"].str.lower() == school_type.lower()
]
```
with:
```python
# A type group key (backend/school_groups.py), or for an old link a raw
# GIAS type label, matched exactly as before.
if school_type:
type_key = school_type.lower()
if type_key in TYPE_GROUP_KEYS:
column = schools_df["school_type"]
schools_df = schools_df[
column.isin(_names_in_group(column, lambda n: type_group_for(n) == type_key))
]
else:
schools_df = schools_df[schools_df["school_type"].str.lower() == type_key]
```
In `get_filter_options`, add to the early `if df.empty:` return dict:
```python
"school_type_groups": [],
"faiths": [],
```
and before the final `return {`:
```python
def offered(groups, present):
return [{"value": key, "label": label} for key, label, _ in groups if key in present]
type_groups_present = (
{type_group_for(n) for n in df["school_type"].dropna().unique()} - {None}
if "school_type" in df.columns else set()
)
faiths_present = (
{f for n in df["religious_denomination"].unique() for f in faith_groups_for(n)}
if "religious_denomination" in df.columns else set()
)
```
and add to the returned dict:
```python
"school_type_groups": offered(TYPE_GROUPS, type_groups_present),
"faiths": offered(FAITH_GROUPS, faiths_present),
```
- [ ] **Step 4: Run the new tests, then the whole backend suite**
Run: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_type_and_faith_filters.py -q`
Expected: all pass.
Run: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests pipeline/tests scripts/ci/tests -q`
Expected: all pass (the CI command; `pyyaml` comes from requirements or add `--with pyyaml` if collection fails on `import yaml`).
- [ ] **Step 5: Commit**
```bash
git add backend/app.py backend/tests/test_type_and_faith_filters.py
git commit -m "feat(api): filter schools by type group and by faith"
```
---
### Task 3: The frontend filters
**Files:**
- Modify: `nextjs-app/lib/types.ts` (`Filters` ~487-494, `SchoolSearchParams` ~558-570)
- Modify: `nextjs-app/app/(frontend)/page.tsx` (props type ~15-29, `hasSearchParams` ~79-88, `fetchSchools` call ~96-109)
- Modify: `nextjs-app/components/FilterBar.tsx`
- Test: `nextjs-app/__tests__/components/FilterBarTypeFaith.test.tsx` (new)
- Modify tests: `__tests__/components/ResultsToolbar.test.tsx`, `FilterSheet.test.tsx`, `FilterSheetPending.test.tsx`, `FilterBarOptions.test.tsx`
**Interfaces:**
- Consumes: `/api/filters` keys `school_type_groups`, `faiths` (Task 2); URL params `school_type` (group key) and `faith`.
- Produces: `export interface FilterOption { value: string; label: string }`; `Filters.school_type_groups?: FilterOption[]`; `Filters.faiths?: FilterOption[]`; `SchoolSearchParams.faith?: string`. FilterBar selects named "School type" and "Faith".
- [ ] **Step 1: Write the failing tests**
Create `nextjs-app/__tests__/components/FilterBarTypeFaith.test.tsx`:
```tsx
import { fireEvent, render, screen, within } from '@testing-library/react';
import { FilterBar } from '@/components/FilterBar';
import { track } from '@/lib/analytics';
/*
* School type offers six groups a parent recognises, not GIAS's 34 types, and
* a Faith filter sits beside it (spec 2026-10-02-school-type-groups-and-faith-
* filter-design.md). Both lists come from /api/filters.
*/
let params = new URLSearchParams('postcode=SW196AR&radius=1');
const push = jest.fn();
jest.mock('next/navigation', () => ({
useSearchParams: () => params,
usePathname: () => '/',
useRouter: () => ({ push, replace: jest.fn(), prefetch: jest.fn() }),
}));
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
const filters = {
local_authorities: ['Wandsworth'], school_types: ['Community school'], years: [],
phases: ['Primary', 'Secondary'], genders: [], admissions_policies: [],
school_type_groups: [
{ value: 'academy', label: 'State school: academy or free school' },
{ value: 'council', label: 'State school: council-run' },
{ value: 'special', label: 'Special school (SEND)' },
],
faiths: [
{ value: 'none', label: 'No religious character' },
{ value: 'roman_catholic', label: 'Roman Catholic' },
],
};
const pushedParams = () => new URLSearchParams(push.mock.calls.at(-1)![0].split('?')[1]);
const openSheet = () => {
fireEvent.click(screen.getByRole('button', { name: /^Filters/ }));
return screen.getByRole('dialog', { name: 'Filters' });
};
const optionsOf = (scope: HTMLElement, name: string) =>
within(within(scope).getByRole('combobox', { name })).getAllByRole('option').map((o) => o.textContent);
beforeEach(() => {
params = new URLSearchParams('postcode=SW196AR&radius=1');
push.mockClear();
jest.mocked(track).mockClear();
});
describe('School type', () => {
it('offers the groups, not the GIAS types, on desktop and in the sheet', () => {
render(<FilterBar filters={filters} />);
const groups = ['Any school type', 'State school: academy or free school',
'State school: council-run', 'Special school (SEND)'];
expect(optionsOf(screen.getByRole('group', { name: 'Filters' }), 'School type')).toEqual(groups);
expect(optionsOf(openSheet(), 'School type')).toEqual(groups);
});
it('puts the group key in the URL and names the chip by its label', () => {
const view = render(<FilterBar filters={filters} />);
fireEvent.change(within(openSheet()).getByRole('combobox', { name: 'School type' }),
{ target: { value: 'special' } });
expect(pushedParams().get('school_type')).toBe('special');
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=special');
view.rerender(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: 'Remove filter: Special school (SEND)' })).toBeInTheDocument();
});
it('names an old raw-label link\'s chip by that label', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=Community+school');
render(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: 'Remove filter: Community school' })).toBeInTheDocument();
});
it('is left out when the API sends no groups', () => {
render(<FilterBar filters={{ ...filters, school_type_groups: undefined }} />);
expect(screen.queryByRole('combobox', { name: 'School type' })).not.toBeInTheDocument();
});
});
describe('Faith', () => {
it('offers its options in the More filters panel and in the sheet', () => {
render(<FilterBar filters={filters} />);
fireEvent.click(screen.getByRole('button', { name: /More filters/ }));
const faiths = ['Any faith or none', 'No religious character', 'Roman Catholic'];
expect(optionsOf(document.body, 'Faith')).toEqual(faiths);
expect(optionsOf(openSheet(), 'Faith')).toEqual(faiths);
});
it('shows as a chip, counts on both buttons, and clears with Clear all', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&faith=roman_catholic');
render(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: 'Remove filter: Roman Catholic' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Filters, 1 applied' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /More filters \(1\)/ })).toBeInTheDocument();
fireEvent.click(within(screen.getByRole('group', { name: 'Applied filters' }))
.getByRole('button', { name: 'Clear all' }));
expect(pushedParams().get('faith')).toBeNull();
expect(pushedParams().get('postcode')).toBe('SW196AR');
});
it('goes into the search analytics event', () => {
params = new URLSearchParams('faith=roman_catholic');
render(<FilterBar filters={filters} />);
const input = screen.getByRole('searchbox', { name: 'School name or postcode' });
fireEvent.change(input, { target: { value: 'st marys' } });
fireEvent.submit(input.closest('form')!);
expect(track).toHaveBeenCalledWith('search_submitted',
expect.objectContaining({ filters_active: 'faith=roman_catholic', filters_count: 1 }));
});
it('is left out when the API sends no faiths', () => {
render(<FilterBar filters={{ ...filters, faiths: [] }} />);
expect(within(openSheet()).queryByRole('combobox', { name: 'Faith' })).not.toBeInTheDocument();
});
});
```
- [ ] **Step 2: Run them to see them fail**
Run (from `nextjs-app/`): `npx jest __tests__/components/FilterBarTypeFaith.test.tsx`
Expected: failures; School type still lists `Community school`, no `Faith` combobox. (`tsc` also flags `school_type_groups` on `Filters`, which Step 3 fixes.)
- [ ] **Step 3: Types and page plumbing**
In `nextjs-app/lib/types.ts`, above `export interface Filters`:
```ts
/** A filter option whose URL value differs from what a parent reads. */
export interface FilterOption {
value: string;
label: string;
}
```
and add to `Filters` (after `admissions_policies`):
```ts
/** Parent-facing school type groups; the URL's school_type takes their value. */
school_type_groups?: FilterOption[];
faiths?: FilterOption[];
```
and to `SchoolSearchParams` (after `has_sixth_form`):
```ts
faith?: string;
```
In `nextjs-app/app/(frontend)/page.tsx`: add `faith?: string;` to the `searchParams` type after `has_sixth_form?: string;`; add `params.faith ||` to `hasSearchParams` after `params.has_sixth_form`; add `faith: params.faith,` to the `fetchSchools({...})` call after `has_sixth_form: params.has_sixth_form,`.
- [ ] **Step 4: FilterBar**
In `nextjs-app/components/FilterBar.tsx`:
1. Add `"faith"` to `FILTER_KEYS` directly after `"school_type"`.
2. After `const currentHasSixthForm = ...` add:
```tsx
const currentFaith = searchParams.get("faith") || "";
```
3. Add `currentFaith,` to the `activeDropdownFilters` array (after `currentLA,`). Faith lives behind More filters, so it counts there.
4. In `handleSearchSubmit`'s `filters_active` list, after the `type=` line add:
```tsx
currentFaith && `faith=${currentFaith}`,
```
5. Add `currentFaith ||` to `hasActiveFilters` after `currentType ||`.
6. Replace `const typeOptions = filters.school_types;` with:
```tsx
// Six groups a parent recognises, not GIAS's 34 establishment types.
const typeOptions = filters.school_type_groups ?? [];
const faithOptions = filters.faiths ?? [];
```
7. Add `faith: currentFaith,` to the `values` record after `school_type: currentType,`.
8. In `labelFor`, before the `// Phase, gender and admissions values…` comment, add:
```tsx
// An old link may carry a raw GIAS type, which reads as itself.
const labelled = { school_type: typeOptions, faith: faithOptions }[
key as "school_type" | "faith"
];
if (labelled) return labelled.find((o) => o.value === value)?.label ?? value;
```
9. In `typeSelect`, replace the options map with:
```tsx
{typeOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
```
10. After `laSelect`, add:
```tsx
const faithSelect = (look: Look) => (
<SelectShell wide>
<select
value={currentFaith}
onChange={(e) => handleFilterChange("faith", e.target.value)}
className={selectClass(look, currentFaith)}
aria-label="Faith"
disabled={isPending && look !== "sheet"}
>
<option value="">Any faith or none</option>
{faithOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</SelectShell>
);
```
11. In the desktop row, change `{typeSelect("pill")}` to `{typeOptions.length > 0 && typeSelect("pill")}`.
12. In the More filters panel, after `{laSelect("panel")}` add `{faithOptions.length > 0 && faithSelect("panel")}`.
13. In the sheet, replace `<SheetField label="School type">{typeSelect("sheet")}</SheetField>` with:
```tsx
{typeOptions.length > 0 && (
<SheetField label="School type">{typeSelect("sheet")}</SheetField>
)}
{faithOptions.length > 0 && (
<SheetField label="Faith">{faithSelect("sheet")}</SheetField>
)}
```
- [ ] **Step 5: Move the existing tests' fixtures to groups**
These tests used raw types as values; give each fixture the groups and use keys:
- `ResultsToolbar.test.tsx`: add to `filters` `school_type_groups: [{ value: 'council', label: 'State school: council-run' }],`; in 'counts only what More filters hides' change `school_type=Community+school` to `school_type=council`.
- `FilterSheet.test.tsx`: add the same `school_type_groups` to `filters`; change both `school_type=Community+school` to `school_type=council`.
- `FilterSheetPending.test.tsx`: add the same `school_type_groups`; change the `{ target: { value: 'Community school' } }` to `{ target: { value: 'council' } }` and `toBe('Community school')` to `toBe('council')`.
- `FilterBarOptions.test.tsx`: add to `filters` `school_type_groups: [{ value: 'academy', label: 'State school: academy or free school' }, { value: 'council', label: 'State school: council-run' }],`; change `school_type=Community+school` (two places) to `school_type=council`; change the expected School type options to `['Any school type', 'State school: academy or free school', 'State school: council-run']`; change `toBe('Community school')` to `toBe('council')`; rename the first test to 'offer every school type group, gender and admissions policy, whatever the results hold'; and in the header comment replace "School type, gender and admissions offer the full lists" with "School type groups, gender and admissions offer the full lists".
- [ ] **Step 6: Run the frontend checks**
Run (from `nextjs-app/`): `npx jest` then `npx tsc --noEmit`
Expected: all suites pass; no type errors.
- [ ] **Step 7: Commit**
```bash
git add nextjs-app/lib/types.ts "nextjs-app/app/(frontend)/page.tsx" nextjs-app/components/FilterBar.tsx nextjs-app/__tests__/components/FilterBarTypeFaith.test.tsx nextjs-app/__tests__/components/ResultsToolbar.test.tsx nextjs-app/__tests__/components/FilterSheet.test.tsx nextjs-app/__tests__/components/FilterSheetPending.test.tsx nextjs-app/__tests__/components/FilterBarOptions.test.tsx
git commit -m "feat(search): school type groups and a faith filter"
```
---
### Task 4: E2E journey, build, PR
**Files:**
- Modify: `e2e/tests/journeys.spec.ts` (insert before `test('a phase outside primary/secondary filters to that phase, not to everything'`)
**Interfaces:**
- Consumes: the "School type" and "Faith" selects (Task 3); `/api/schools?school_type=&faith=` (Task 2).
- [ ] **Step 1: Add the journey**
```ts
/*
* School type offers six groups a parent recognises, and Faith sits beside it.
* Data-invariant: asserts what every returned school is, never how many.
*/
test('school type groups and the faith filter narrow to what they name', async ({ page }) => {
await page.goto('/?search=school');
const type = page.getByRole('combobox', { name: 'School type', exact: true });
await expect(type).toBeVisible({ timeout: 15_000 });
await type.selectOption({ label: 'Special school (SEND)' });
await expect(page).toHaveURL(/[?&]school_type=special(&|$)/);
await page.getByRole('button', { name: /^More filters/ }).click();
await page.getByRole('combobox', { name: 'Faith', exact: true }).selectOption({ label: 'Roman Catholic' });
await expect(page).toHaveURL(/[?&]faith=roman_catholic(&|$)/);
const res = await page.request.get(
'/api/schools?search=school&school_type=special&faith=roman_catholic&page_size=100');
expect(res.ok()).toBeTruthy();
for (const s of (await res.json()).schools as { school_type: string; religious_denomination: string }[]) {
expect(s.school_type).toMatch(/special/i);
expect(s.religious_denomination).toMatch(/catholic/i);
}
});
```
- [ ] **Step 2: Confirm it parses, and fails on today's staging**
Run (from `e2e/`): `npx playwright test --list | grep "school type groups"`
Expected: the test is listed.
Run: `BASE_URL=https://stx.schoolcompare.co.uk npx playwright test -g "school type groups" --retries=0 --reporter=line`
Expected: FAIL at `selectOption({ label: 'Special school (SEND)' })`, because staging still offers the raw types. It can only pass after merge (the staging E2E gate runs post-merge).
- [ ] **Step 3: Build without a database**
Run (from `nextjs-app/`): `env -u DATABASE_URL npm run build`
Expected: build completes.
- [ ] **Step 4: Commit, push, open the PR**
```bash
git add e2e/tests/journeys.spec.ts
git commit -m "test(e2e): school type groups and the faith filter"
git push -u origin feat/school-type-groups-faith
```
Open the PR on Gitea against `main` (credential-helper basic auth, as for PRs #168/#169), listing: the six groups and seven faiths, the old-link compatibility, the backend-by-name decision, test counts, and that the new journey can only pass post-merge.