From 6b871ce1e9c38ab5183328023158a94ff44f5c01 Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 21 Aug 2026 18:19:08 +0100 Subject: [PATCH] feat(places): ItemList and BreadcrumbList, and the e2e gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ItemList tells Google the page is a ranked set rather than prose; BreadcrumbList puts the place in a hierarchy. School URLs in the markup are absolute on the canonical host, since a relative URL in JSON-LD is ambiguous. Eight journeys covering all four families, the two-namespace guarantee, the threshold, the canonical, the sitemap and the local-versus-England line — the last because that comparison is the reason these pages are not a name dropped into a template. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj --- e2e/tests/journeys.spec.ts | 84 +++++++++++++++++++ .../__tests__/components/PlaceView.test.tsx | 23 +++++ nextjs-app/components/places/PlaceView.tsx | 33 ++++++++ 3 files changed, 140 insertions(+) diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index fbe6546..f6b1538 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -1758,3 +1758,87 @@ test('the homepage snippet names what gov.uk does not publish', async ({ page }) // Admissions distance is the one fact the DfE service has no equivalent for. expect(desc).toMatch(/close you had to live|distance/i); }); + +/* + * The location layer (spec 2026-08-21, W2). + * + * Location intent sat at position 49.5 with one click across the whole 16-month + * baseline — the site published no page about a place. These assert the four + * families render, stay in their own namespaces, and reach the sitemap. + */ +async function firstPlaceOfKind(page: Page, kind: string) { + const res = await page.request.get('/api/places'); + expect(res.ok()).toBeTruthy(); + const { places } = await res.json(); + const hit = places.find((p: { kind: string }) => p.kind === kind); + expect(hit, `no ${kind} in the registry`).toBeTruthy(); + return hit as { kind: string; slug: string; name: string; count: number }; +} + +for (const [kind, prefix, article] of [ + ['town', '/schools/', 'a'], + ['authority', '/schools/authority/', 'an'], + ['outcode', '/schools/near/', 'an'], +] as const) { + test(`${article} ${kind} page renders with its school count`, async ({ page }) => { + const place = await firstPlaceOfKind(page, kind); + await page.goto(`${prefix}${place.slug}`); + await expect(page.locator('h1')).toContainText(place.name, { ignoreCase: true }); + await expect(page.locator('a[href^="/school/"]').first()).toBeVisible(); + }); +} + +test('a town and an authority sharing a name are different pages', async ({ page }) => { + // 67 real collisions, and the authority is the larger set in only 43 — so + // one namespace would have published near-duplicates. + const { places } = await (await page.request.get('/api/places')).json(); + const townSlugs = new Set( + places.filter((p: { kind: string }) => p.kind === 'town') + .map((p: { slug: string }) => p.slug)); + const clash = places.find((p: { kind: string; slug: string }) => + p.kind === 'authority' && townSlugs.has(p.slug)); + test.skip(!clash, 'no town/authority name collision in this environment'); + + const townRes = await page.request.get(`/api/places/town/${clash.slug}`); + const laRes = await page.request.get(`/api/places/authority/${clash.slug}`); + expect(townRes.ok() && laRes.ok()).toBeTruthy(); + const townUrns = (await townRes.json()).schools.map((s: { urn: number }) => s.urn).sort(); + const laUrns = (await laRes.json()).schools.map((s: { urn: number }) => s.urn).sort(); + expect(townUrns).not.toEqual(laUrns); +}); + +test('a place below the threshold has no page', async ({ page }) => { + // Crosby holds one school; publishing it would be a page with nothing to say. + const res = await page.request.get('/api/places/town/crosby'); + expect(res.status()).toBe(404); +}); + +test('place pages declare a canonical and reach the sitemap', async ({ page }) => { + const place = await firstPlaceOfKind(page, 'town'); + await page.goto(`/schools/${place.slug}`); + const canonical = await page.locator('link[rel="canonical"]').first() + .getAttribute('href'); + expect(canonical).toBe(`https://www.schoolcompare.co.uk/schools/${place.slug}`); + + const xml = await (await page.request.get('/sitemaps/places-1.xml')).text(); + expect(xml).toContain(`/schools/${place.slug}`); +}); + +test('the place page ships ItemList structured data that parses', async ({ page }) => { + const place = await firstPlaceOfKind(page, 'town'); + await page.goto(`/schools/${place.slug}`); + const raw = await page.locator('script[type="application/ld+json"]').first() + .textContent(); + const parsed = JSON.parse(raw!); + const types = (parsed['@graph'] ?? []).map((n: { '@type': string }) => n['@type']); + expect(types).toContain('ItemList'); + expect(types).toContain('BreadcrumbList'); +}); + +test('a place page states the local average against England', async ({ page }) => { + // The one number a list cannot give, and the reason these pages are not + // a name dropped into a template. + const place = await firstPlaceOfKind(page, 'town'); + await page.goto(`/schools/${place.slug}`); + await expect(page.getByTestId('local-vs-england')).toContainText(/across England/i); +}); diff --git a/nextjs-app/__tests__/components/PlaceView.test.tsx b/nextjs-app/__tests__/components/PlaceView.test.tsx index c1685c1..9d3c5b0 100644 --- a/nextjs-app/__tests__/components/PlaceView.test.tsx +++ b/nextjs-app/__tests__/components/PlaceView.test.tsx @@ -68,3 +68,26 @@ describe('PlaceView', () => { expect(screen.queryByTestId('local-vs-england')).not.toBeInTheDocument(); }); }); + +describe('PlaceView structured data', () => { + function jsonLd() { + const { container } = render(); + const el = container.querySelector('script[type="application/ld+json"]'); + return JSON.parse(el!.textContent!); + } + + it('declares the page as a ranked list, not prose', () => { + const types = jsonLd()['@graph'].map((n: { '@type': string }) => n['@type']); + expect(types).toContain('ItemList'); + expect(types).toContain('BreadcrumbList'); + }); + + it('gives every listed school an absolute URL on the canonical host', () => { + const list = jsonLd()['@graph'].find((n: { '@type': string }) => n['@type'] === 'ItemList'); + expect(list.itemListElement).toHaveLength(2); + for (const item of list.itemListElement) { + expect(item.url).toMatch(/^https:\/\/www\.schoolcompare\.co\.uk\/school\//); + } + }); +}); diff --git a/nextjs-app/components/places/PlaceView.tsx b/nextjs-app/components/places/PlaceView.tsx index 56fdf97..0e063ac 100644 --- a/nextjs-app/components/places/PlaceView.tsx +++ b/nextjs-app/components/places/PlaceView.tsx @@ -13,6 +13,7 @@ import Link from 'next/link'; import type { PlaceDetail, PlaceSummary } from '@/lib/places'; import { placeUrl, authoritySlug } from '@/lib/places'; import { schoolUrl } from '@/lib/utils'; +import { absoluteUrl } from '@/lib/site'; import styles from './PlaceView.module.css'; interface Props { @@ -39,8 +40,40 @@ export function PlaceView({ detail, phase, englandAverage, neighbours }: Props) .map(([grade, label]) => [label, schools.filter((s) => s.ofsted_grade === grade).length] as const) .filter(([, n]) => n > 0); + // ItemList tells Google this page is a ranked set rather than prose; + // BreadcrumbList puts the place in a hierarchy. Capped at 20 because that + // is what the page shows above the fold and what the markup should mirror. + const jsonLd = { + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'ItemList', + name: `${phaseWord} in ${place.name}`, + numberOfItems: schools.length, + itemListElement: schools.slice(0, 20).map((s, i) => ({ + '@type': 'ListItem', + position: i + 1, + url: absoluteUrl(schoolUrl(s.urn, s.school_name)), + name: s.school_name, + })), + }, + { + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Schools', + item: absoluteUrl('/') }, + { '@type': 'ListItem', position: 2, name: place.name }, + ], + }, + ], + }; + return (
+