feat(places): ItemList and BreadcrumbList, and the e2e gate
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 36s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 4m33s
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 36s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 4m33s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
This commit is contained in:
1 parent
c981d89137
commit
6b871ce1e9
3 files changed
+140
No files matched your search
@@ -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.
|
// Admissions distance is the one fact the DfE service has no equivalent for.
|
||||||
expect(desc).toMatch(/close you had to live|distance/i);
|
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);
|
||||||
|
});
|
||||||
@@ -68,3 +68,26 @@ describe('PlaceView', () => {
|
|||||||
expect(screen.queryByTestId('local-vs-england')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('local-vs-england')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('PlaceView structured data', () => {
|
||||||
|
function jsonLd() {
|
||||||
|
const { container } = render(<PlaceView detail={detail} phase="primary"
|
||||||
|
englandAverage={61} neighbours={[]} />);
|
||||||
|
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\//);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import Link from 'next/link';
|
|||||||
import type { PlaceDetail, PlaceSummary } from '@/lib/places';
|
import type { PlaceDetail, PlaceSummary } from '@/lib/places';
|
||||||
import { placeUrl, authoritySlug } from '@/lib/places';
|
import { placeUrl, authoritySlug } from '@/lib/places';
|
||||||
import { schoolUrl } from '@/lib/utils';
|
import { schoolUrl } from '@/lib/utils';
|
||||||
|
import { absoluteUrl } from '@/lib/site';
|
||||||
import styles from './PlaceView.module.css';
|
import styles from './PlaceView.module.css';
|
||||||
|
|
||||||
interface Props {
|
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)
|
.map(([grade, label]) => [label, schools.filter((s) => s.ofsted_grade === grade).length] as const)
|
||||||
.filter(([, n]) => n > 0);
|
.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 (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<h1>{phaseWord} in {place.name}</h1>
|
<h1>{phaseWord} in {place.name}</h1>
|
||||||
<p className={styles.summary}>
|
<p className={styles.summary}>
|
||||||
|
|||||||
Reference in new issue
Block a user