diff --git a/backend/app.py b/backend/app.py
index 9e60c1d..a513aea 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -1337,9 +1337,22 @@ async def get_place(request: Request, kind: str, slug: str,
for m in ("rwm_expected_pct", "attainment_8_score")
}
- cols = [c for c in SCHOOL_COLUMNS + ["latitude", "longitude", "phase",
- "rwm_expected_pct", "attainment_8_score",
- "total_pupils"]
+ # dict.fromkeys, not a list: SCHOOL_COLUMNS already ends with latitude and
+ # longitude, so concatenating them again selected each twice and pandas
+ # dropped one of every duplicated pair with a "columns are not unique"
+ # warning. Ordered de-duplication keeps the column order and the warning
+ # cannot come back.
+ #
+ # nursery_provision and parliamentary_constituency are not in
+ # SCHOOL_COLUMNS and the place table shows both. The `in rows.columns`
+ # guard is what keeps a mart the pipeline has not rebuilt working: those
+ # two are the optional GIAS columns data_loader degrades to NULL.
+ cols = [c for c in dict.fromkeys(
+ SCHOOL_COLUMNS + ["latitude", "longitude", "phase",
+ "nursery_provision",
+ "parliamentary_constituency",
+ "rwm_expected_pct", "attainment_8_score",
+ "total_pupils"])
if c in rows.columns]
return {
diff --git a/backend/tests/test_places_api.py b/backend/tests/test_places_api.py
index 1d8f701..716bc3a 100644
--- a/backend/tests/test_places_api.py
+++ b/backend/tests/test_places_api.py
@@ -137,3 +137,49 @@ def test_an_authority_without_a_page_is_named_but_carries_no_slug(straddling_cli
by_name = {a["name"]: a for a in body["place"]["authorities"]}
assert by_name["Essex"]["slug"] == "essex"
assert by_name["Isles Of Scilly"]["slug"] is None
+
+
+def _attributed_df() -> pd.DataFrame:
+ """The same town, with the four attributes the place table now shows."""
+ df = _schools_df()
+ df["age_range"] = "4-11"
+ df["religious_denomination"] = "Church of England"
+ df["nursery_provision"] = True
+ df["parliamentary_constituency"] = "Brentwood and Ongar"
+ return df
+
+
+@pytest.fixture()
+def attributed_client(monkeypatch):
+ from backend import app as app_module
+
+ monkeypatch.setattr(app_module, "load_school_data", _attributed_df)
+ monkeypatch.setattr(app_module, "load_latest_school_data", _attributed_df)
+ monkeypatch.setattr(app_module, "_place_registry", None)
+ return TestClient(app_module.app, raise_server_exceptions=False)
+
+
+def test_place_detail_carries_the_attributes_the_table_shows(attributed_client):
+ """age_range and religious_denomination ride in on SCHOOL_COLUMNS.
+
+ nursery_provision and parliamentary_constituency do not, and the place
+ table needs all four — a column the response cannot fill is a column of
+ dashes on ~3,900 pages.
+ """
+ body = attributed_client.get("/api/places/town/brentwood").json()
+ school = body["schools"][0]
+ assert school["age_range"] == "4-11"
+ assert school["religious_denomination"] == "Church of England"
+ assert school["nursery_provision"] is True
+ assert school["parliamentary_constituency"] == "Brentwood and Ongar"
+
+
+def test_place_detail_survives_a_mart_without_the_optional_columns(client):
+ """The base fixture has neither column, as an unrebuilt mart does not.
+
+ data_loader degrades those to NULL rather than failing the load, so the
+ endpoint must not assume they are present.
+ """
+ res = client.get("/api/places/town/brentwood")
+ assert res.status_code == 200
+ assert "nursery_provision" not in res.json()["schools"][0]
diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts
index 1a55540..966f2ab 100644
--- a/e2e/tests/journeys.spec.ts
+++ b/e2e/tests/journeys.spec.ts
@@ -1978,6 +1978,59 @@ test('a place page links its phase variants, and they resolve', async ({ page })
await expect(page.locator('h1')).toContainText(new RegExp(`${phase} schools in`, 'i'));
});
+/*
+ * The table shipped with one column of scores. A parent shortlisting from a
+ * town page needs to know whether a school takes their child's age, whether
+ * it is a faith school, and — for a primary — whether it has a nursery,
+ * before a percentage means anything.
+ *
+ * These assert the column headings rather than the values: nursery_provision
+ * and parliamentary_constituency are optional mart columns, and on an
+ * environment whose pipeline has not rebuilt them the API degrades them to
+ * absent. A value assertion would then fail for a data reason, not a code one.
+ */
+async function phasedPlace(page: Page, phase: 'primary' | 'secondary') {
+ const place = await firstPlaceOfKind(page, 'town');
+ const detail = await (await page.request.get(`/api/places/town/${place.slug}`)).json();
+ test.skip(!(detail.place.phases ?? []).includes(phase),
+ `no ${phase} page clears the threshold here`);
+ return place;
+}
+
+test('a primary place page names each school as well as scoring it', async ({ page }) => {
+ const place = await phasedPlace(page, 'primary');
+ await page.goto(`/schools/${place.slug}/primary`);
+ for (const heading of ['Ages', 'Religious character', 'Nursery', 'Constituency']) {
+ await expect(page.getByRole('columnheader', { name: heading, exact: true }))
+ .toBeVisible();
+ }
+ // age_range rides in on SCHOOL_COLUMNS and predates the optional columns,
+ // so it is the one attribute safe to assert a value for anywhere.
+ await expect(page.locator('table tbody td').filter({ hasText: /^\d+–\d+$/ }).first())
+ .toBeVisible();
+});
+
+test('a secondary place page does not ask about nurseries', async ({ page }) => {
+ const place = await phasedPlace(page, 'secondary');
+ await page.goto(`/schools/${place.slug}/secondary`);
+ await expect(page.getByRole('columnheader', { name: 'Ages', exact: true }))
+ .toBeVisible();
+ await expect(page.getByRole('columnheader', { name: 'Nursery', exact: true }))
+ .toHaveCount(0);
+});
+
+test('the measure stays beside the school name, not behind a swipe', async ({ page }) => {
+ // Six columns overflow a phone; .tableWrap turns that into a horizontal
+ // scroll. With the measure last, the number the page exists for is the one
+ // off the screen.
+ const place = await phasedPlace(page, 'primary');
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto(`/schools/${place.slug}/primary`);
+ const second = page.locator('table thead th').nth(1);
+ await expect(second).toContainText(/reading, writing/i);
+ await expect(second).toBeInViewport();
+});
+
test('phase variants are submitted in the places sitemap', async ({ page }) => {
const xml = await (await page.request.get('/sitemaps/places-1.xml')).text();
expect(xml).toMatch(/\/schools\/[a-z0-9-]+\/primary);
diff --git a/nextjs-app/__tests__/components/PlaceView.test.tsx b/nextjs-app/__tests__/components/PlaceView.test.tsx
index 96bed7d..2ef75dd 100644
--- a/nextjs-app/__tests__/components/PlaceView.test.tsx
+++ b/nextjs-app/__tests__/components/PlaceView.test.tsx
@@ -346,3 +346,134 @@ describe('PlaceView unlinkable authorities', () => {
.toContain('Isles Of Scilly');
});
});
+
+describe('PlaceView school attributes', () => {
+ /*
+ * The table shipped with one column of scores, which answers "how did they
+ * do" and nothing about whether the school is one a family could use. Age
+ * range, faith, nursery and constituency are the four facts a parent
+ * filters on before they look at a number at all.
+ */
+ const withAttributes: PlaceDetail = {
+ place: { kind: 'town', slug: 'chelmsford', name: 'Chelmsford', count: 3,
+ parent_authority: 'Essex', phases: ['primary', 'secondary'] },
+ schools: [
+ { urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
+ rwm_expected_pct: 82, attainment_8_score: null,
+ age_range: '4-11', religious_denomination: 'Church of England',
+ nursery_provision: true,
+ parliamentary_constituency: 'Chelmsford' } as never,
+ { urn: 2, school_name: 'Beta High', phase: 'Secondary',
+ rwm_expected_pct: null, attainment_8_score: 47,
+ age_range: '11-16', religious_denomination: 'Does not apply',
+ nursery_provision: false,
+ parliamentary_constituency: 'Witham' } as never,
+ ],
+ averages: { rwm_expected_pct: 63, attainment_8_score: 45 },
+ };
+
+ function headings(container: HTMLElement, table = 0): string[] {
+ return Array.from(container.querySelectorAll('table')[table]
+ .querySelectorAll('thead th')).map((th) => th.textContent ?? '');
+ }
+
+ it('heads a primary table with all four attributes', () => {
+ const { container } = render(
| Ages | +Religious character | + {showNursery &&Nursery | } +Constituency | @@ -96,6 +126,19 @@ function SchoolTable({ schools, phase }: { schools: School[]; phase: PhaseKey }) ? Not published : `${Math.round(Number(value))}${metric.unit}`} +{formatAgeSpan(s.age_range) || NO_VALUE} | +{faithOf(s)} | + {showNursery && ( ++ {/* Undefined is a mart the pipeline has not rebuilt, and + false is a school without one. Neither is a "Yes", and + neither is worth two different words. */} + {s.nursery_provision ? 'Yes' : NO_VALUE} + | + )} ++ {s.parliamentary_constituency || NO_VALUE} + | ); })} diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts index 2d7e931..42864ef 100644 --- a/nextjs-app/lib/utils.ts +++ b/nextjs-app/lib/utils.ts @@ -82,11 +82,21 @@ export function shortName(name: string, maxLength = 32): string { * Display-only — leaves the raw `age_range` field (used for sixth-form * detection) untouched. Falls back to the raw value if it's not a plain range. */ -export function formatAgeRange(ageRange: string | null | undefined): string { +export function formatAgeSpan(ageRange: string | null | undefined): string { if (!ageRange) return ''; const match = ageRange.match(/^\s*(\d+)\s*[-–]\s*(\d+)\s*$/); if (!match) return ageRange; - return `Ages ${match[1]}–${match[2]}`; + return `${match[1]}–${match[2]}`; +} + +/** + * The same span, labelled — for the places that show it with no column + * heading to carry the word "Ages". Delegates so the en-dash normalisation + * lives in one place. + */ +export function formatAgeRange(ageRange: string | null | undefined): string { + const span = formatAgeSpan(ageRange); + return /^\d+–\d+$/.test(span) ? `Ages ${span}` : span; } // ============================================================================
|---|