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.
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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 { 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 (
|
||||
<div className={styles.container}>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<header className={styles.header}>
|
||||
<h1>{phaseWord} in {place.name}</h1>
|
||||
<p className={styles.summary}>
|
||||
|
||||
Reference in new issue
Block a user