PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 22s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m17s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 20s
A parent choosing a primary and a secondary school at once hit the old cap of five total. The basket now holds up to five primary and five secondary schools (ten in all), matching the compare page's phase tabs. Schools that could land in either tab (all-through, special schools with phase "Not applicable", unknown phase) count against both groups, so no tab ever exceeds the five-slot chart palette and point styles. - lib/compareLogic: compareGroups, fullGroupFor, fitToGroupLimits - search modal disables only the full group and says which one - rankings rows carry the phase of the tab they are ranked under - shared ?urns= links are trimmed per group - copy: compare metadata, homepage value prop, How it works card now name primary and secondary schools (also better for search intent) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
170 lines
7.2 KiB
TypeScript
170 lines
7.2 KiB
TypeScript
import { metadata as homeMetadata } from '@/app/(frontend)/page';
|
|
import { metadata as rankingsMetadata } from '@/app/(frontend)/rankings/page';
|
|
import { metadata as admissionsMetadata } from '@/app/(frontend)/admissions/page';
|
|
import { generateMetadata as compareMetadata } from '@/app/(frontend)/compare/page';
|
|
import { metadata as rootMetadata } from '@/app/(frontend)/layout';
|
|
|
|
describe('canonical URLs', () => {
|
|
it('the homepage canonicalises to the bare root', () => {
|
|
// page.tsx reads eleven search params. Without this, every filter
|
|
// combination is a crawlable near-duplicate of the one page we want to
|
|
// rank for "compare schools".
|
|
expect(homeMetadata.alternates?.canonical)
|
|
.toBe('https://www.schoolcompare.co.uk/');
|
|
});
|
|
|
|
it('rankings canonicalises to the bare path', () => {
|
|
expect(rankingsMetadata.alternates?.canonical)
|
|
.toBe('https://www.schoolcompare.co.uk/rankings');
|
|
});
|
|
|
|
it('admissions canonicalises to the bare path', () => {
|
|
expect(admissionsMetadata.alternates?.canonical)
|
|
.toBe('https://www.schoolcompare.co.uk/admissions');
|
|
});
|
|
});
|
|
|
|
describe('/compare indexability', () => {
|
|
it('the bare compare page is indexable and canonical to itself', async () => {
|
|
// This is the landing page for the "compare schools" head term.
|
|
const meta = await compareMetadata({ searchParams: Promise.resolve({}) });
|
|
expect(meta.alternates?.canonical)
|
|
.toBe('https://www.schoolcompare.co.uk/compare');
|
|
expect(meta.robots).toBeUndefined();
|
|
});
|
|
|
|
it('a comparison of specific schools is noindex, follow', async () => {
|
|
// ~317 million pairs before triples. Indexing the parameter space would
|
|
// swamp everything else in the corpus.
|
|
const meta = await compareMetadata({
|
|
searchParams: Promise.resolve({ urns: '100001,100002' }),
|
|
});
|
|
expect(meta.robots).toEqual({ index: false, follow: true });
|
|
});
|
|
|
|
it('a parameterised comparison still canonicalises to the bare path', async () => {
|
|
// follow:true plus a canonical means the outbound links to each school
|
|
// page still pass value even though this URL is not indexed.
|
|
const meta = await compareMetadata({
|
|
searchParams: Promise.resolve({ urns: '100001,100002' }),
|
|
});
|
|
expect(meta.alternates?.canonical)
|
|
.toBe('https://www.schoolcompare.co.uk/compare');
|
|
});
|
|
});
|
|
|
|
/*
|
|
* W8 — snippet copy for the C1 cluster.
|
|
*
|
|
* The baseline (GSC, 16 months to 2026-08-20) showed these pages ranking on
|
|
* page one and converting at a tenth of the normal rate: "compare school
|
|
* performance" at position 6.1 with 0.43% CTR, against 9.16% for the brand
|
|
* query from the same neighbourhood. The SERP is dominated by the DfE's own
|
|
* "Compare school performance" service, so the job of this copy is to say
|
|
* what that service does not offer, without losing intent match on the title.
|
|
*
|
|
* These tests guard the mechanics that make a snippet work — length, intent
|
|
* keyword, differentiator, no brand-first — not the exact wording, which
|
|
* should stay free to iterate.
|
|
*/
|
|
|
|
// Google truncates titles near 60 characters and descriptions near 155.
|
|
const TITLE_MAX = 60;
|
|
const DESC_MIN = 110;
|
|
const DESC_MAX = 155;
|
|
|
|
type Meta = { title?: unknown; description?: unknown };
|
|
const titleOf = (m: Meta): string => {
|
|
const t = m.title as string | { absolute?: string } | undefined;
|
|
return typeof t === 'string' ? t : (t?.absolute ?? '');
|
|
};
|
|
|
|
describe('C1 snippet copy', () => {
|
|
const pages: Array<[string, Meta, RegExp]> = [
|
|
['home', homeMetadata as Meta, /compare schools/i],
|
|
['rankings', rankingsMetadata as Meta, /league table/i],
|
|
['admissions', admissionsMetadata as Meta, /admission/i],
|
|
];
|
|
|
|
for (const [name, meta, intent] of pages) {
|
|
it(`${name}: title carries the search intent and fits the SERP`, () => {
|
|
const t = titleOf(meta);
|
|
expect(t).toMatch(intent);
|
|
expect(t.length).toBeLessThanOrEqual(TITLE_MAX);
|
|
});
|
|
|
|
it(`${name}: title does not open with the brand`, () => {
|
|
// The measured 0.43% CTR came from a brand-first title. The most
|
|
// valuable pixels go to the thing the searcher typed.
|
|
expect(titleOf(meta).toLowerCase().startsWith('schoolcompare')).toBe(false);
|
|
});
|
|
|
|
it(`${name}: description is long enough to be worth reading, short enough to survive`, () => {
|
|
const d = meta.description as string;
|
|
expect(d.length).toBeGreaterThanOrEqual(DESC_MIN);
|
|
expect(d.length).toBeLessThanOrEqual(DESC_MAX);
|
|
});
|
|
}
|
|
|
|
it('the homepage description names what gov.uk does not publish', () => {
|
|
// Admissions distance is the one fact the DfE service has no equivalent
|
|
// for. If it ever leaves this description, the snippet is competing with
|
|
// gov.uk on gov.uk's own ground.
|
|
expect(homeMetadata.description).toMatch(/close you had to live|distance/i);
|
|
});
|
|
|
|
it('/compare targets the tool phrasing rather than repeating the homepage', () => {
|
|
// Two pages chasing one phrase is how a site competes with itself.
|
|
return compareMetadata({ searchParams: Promise.resolve({}) }).then((m) => {
|
|
expect(m.title).toMatch(/comparison tool/i);
|
|
expect(m.title).not.toBe(titleOf(homeMetadata as Meta));
|
|
});
|
|
});
|
|
|
|
it('no C1 page claims a school count that will drift', () => {
|
|
// The corpus moves with every data refresh; this repo has already shipped
|
|
// one copy bug of that kind ("three schools" against a limit of five).
|
|
for (const [, meta] of pages) {
|
|
expect(meta.description as string).not.toMatch(/\b\d{2},\d{3}\b|\b\d{2},000\b/);
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The share card must be declared, not inherited.
|
|
*
|
|
* `app/opengraph-image.tsx` is a metadata file convention, and it does attach
|
|
* to routes in the app root segment — `_not-found` gets an og:image from it.
|
|
* It does NOT attach to the site's pages, which live in the `(frontend)`
|
|
* route group whose own layout is a root layout. Staging served og:title,
|
|
* og:description, og:url, og:site_name and og:type and no og:image at all,
|
|
* so every link pasted into a chat rendered bare.
|
|
*
|
|
* The file stays at the app root, because /robots.txt and /icon.png depend on
|
|
* it being there. The site's root layout points at the route it generates.
|
|
*/
|
|
describe('the share card', () => {
|
|
it('declares an opengraph image on the site root layout', () => {
|
|
// No og:image means every link pasted into a chat renders bare.
|
|
const images = rootMetadata.openGraph?.images;
|
|
expect(images).toBeTruthy();
|
|
expect(JSON.stringify(images)).toContain('/opengraph-image');
|
|
});
|
|
|
|
it('declares a twitter image too', () => {
|
|
// twitter.card is summary_large_image. Claiming a large-image card and
|
|
// supplying no image is worse than claiming a summary card.
|
|
// Metadata['twitter'] is a union and `card` is not on every member, so
|
|
// this reads the serialised shape rather than narrowing the type.
|
|
const twitter = JSON.stringify(rootMetadata.twitter);
|
|
expect(twitter).toContain('summary_large_image');
|
|
expect(twitter).toContain('/opengraph-image');
|
|
});
|
|
|
|
it('resolves the card to an absolute url via metadataBase', () => {
|
|
// The e2e journey does `new URL(ogUrl)`, which throws on a relative path.
|
|
expect(rootMetadata.metadataBase?.toString())
|
|
.toBe('https://www.schoolcompare.co.uk/');
|
|
});
|
|
});
|