2026-08-21 18:15:10 +01:00
|
|
|
import { render, screen } from '@testing-library/react';
|
|
|
|
|
import { PlaceView } from '@/components/places/PlaceView';
|
|
|
|
|
import type { PlaceDetail } from '@/lib/places';
|
|
|
|
|
|
|
|
|
|
const detail: PlaceDetail = {
|
|
|
|
|
place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 29,
|
2026-08-21 20:43:07 +01:00
|
|
|
parent_authority: 'Essex', phases: ['primary'] },
|
2026-08-21 18:15:10 +01:00
|
|
|
schools: [
|
|
|
|
|
{ urn: 1, school_name: 'Alpha Primary', rwm_expected_pct: 82,
|
2026-08-21 21:49:39 +01:00
|
|
|
ofsted_grade: 1, phase: 'Primary' } as never,
|
2026-08-21 18:15:10 +01:00
|
|
|
{ urn: 2, school_name: 'Beta Primary', rwm_expected_pct: 44,
|
2026-08-21 21:49:39 +01:00
|
|
|
ofsted_grade: 3, phase: 'Primary' } as never,
|
2026-08-21 18:15:10 +01:00
|
|
|
],
|
|
|
|
|
averages: { rwm_expected_pct: 63, attainment_8_score: null },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
describe('PlaceView', () => {
|
|
|
|
|
it('leads with an H1 that matches how the place is searched', () => {
|
|
|
|
|
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
|
|
|
|
neighbours={[]} />);
|
|
|
|
|
expect(screen.getByRole('heading', { level: 1 }))
|
|
|
|
|
.toHaveTextContent(/primary schools in brentwood/i);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('states the count so the page says something before the table', () => {
|
|
|
|
|
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
|
|
|
|
neighbours={[]} />);
|
|
|
|
|
expect(screen.getByText(/29 schools/i)).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('compares the local average against England, which a list cannot', () => {
|
|
|
|
|
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
|
|
|
|
neighbours={[]} />);
|
|
|
|
|
expect(screen.getByTestId('local-vs-england')).toHaveTextContent('63');
|
|
|
|
|
expect(screen.getByTestId('local-vs-england')).toHaveTextContent('61');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('links every school in scope, which is what de-orphans them', () => {
|
|
|
|
|
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
|
|
|
|
neighbours={[]} />);
|
|
|
|
|
expect(screen.getAllByRole('link', { name: /Primary$/ })).toHaveLength(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('links to the parent authority so the place sits in a hierarchy', () => {
|
|
|
|
|
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
|
|
|
|
neighbours={[]} />);
|
|
|
|
|
expect(screen.getByRole('link', { name: /Essex/i }))
|
|
|
|
|
.toHaveAttribute('href', '/schools/authority/essex');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('shows the Ofsted distribution, not just a count of Outstanding', () => {
|
|
|
|
|
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
|
|
|
|
neighbours={[]} />);
|
|
|
|
|
expect(screen.getByTestId('ofsted-distribution')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('links to neighbouring places so the page is not a dead end', () => {
|
|
|
|
|
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
|
|
|
|
neighbours={[{ kind: 'town', slug: 'romford', name: 'Romford', count: 40 }]} />);
|
|
|
|
|
expect(screen.getByRole('link', { name: /Romford/ }))
|
|
|
|
|
.toHaveAttribute('href', '/schools/romford');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('says nothing about an average it does not have', () => {
|
|
|
|
|
render(<PlaceView detail={{ ...detail, averages:
|
|
|
|
|
{ rwm_expected_pct: null, attainment_8_score: null } }}
|
|
|
|
|
phase="primary" englandAverage={61} neighbours={[]} />);
|
|
|
|
|
expect(screen.queryByTestId('local-vs-england')).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-21 18:19:08 +01:00
|
|
|
|
|
|
|
|
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\//);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-21 20:43:07 +01:00
|
|
|
|
|
|
|
|
describe('PlaceView phase variants', () => {
|
|
|
|
|
it('links the phase variants that exist', () => {
|
|
|
|
|
render(<PlaceView detail={detail} englandAverage={61} neighbours={[]} />);
|
|
|
|
|
expect(screen.getByRole('link', { name: /Primary schools in Brentwood/i }))
|
|
|
|
|
.toHaveAttribute('href', '/schools/brentwood/primary');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('links no variant for a phase below its own threshold', () => {
|
|
|
|
|
render(<PlaceView detail={detail} englandAverage={61} neighbours={[]} />);
|
|
|
|
|
expect(screen.queryByRole('link', { name: /Secondary schools in Brentwood/i }))
|
|
|
|
|
.not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does not link sideways from a variant page to itself', () => {
|
|
|
|
|
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
|
|
|
|
neighbours={[]} />);
|
|
|
|
|
expect(screen.queryByRole('link', { name: /Primary schools in Brentwood/i }))
|
|
|
|
|
.not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-21 21:49:39 +01:00
|
|
|
|
|
|
|
|
describe('PlaceView presentation', () => {
|
|
|
|
|
// /schools/brentwood shipped with 8 of 27 rows blank: an unphased page shows
|
|
|
|
|
// one primary-only measure for a list that also holds secondaries.
|
|
|
|
|
const mixed: PlaceDetail = {
|
|
|
|
|
place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 4,
|
|
|
|
|
parent_authority: 'Essex', phases: ['primary', 'secondary'] },
|
|
|
|
|
schools: [
|
|
|
|
|
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
|
|
|
|
|
rwm_expected_pct: 82, attainment_8_score: null } as never,
|
|
|
|
|
{ urn: 2, school_name: 'Beta High', phase: 'Secondary',
|
|
|
|
|
rwm_expected_pct: null, attainment_8_score: 47 } as never,
|
|
|
|
|
],
|
|
|
|
|
averages: { rwm_expected_pct: 63, attainment_8_score: 45 },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
it('gives each phase its own table rather than one column of blanks', () => {
|
|
|
|
|
render(<PlaceView detail={mixed} englandAverage={61} neighbours={[]} />);
|
|
|
|
|
expect(screen.getByRole('heading', { name: /^Primary schools/ })).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByRole('heading', { name: /^Secondary schools/ })).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByText('82%')).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByText('47')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('names the measure in plain words, not jargon', () => {
|
|
|
|
|
// The first cut said "RWM expected", which appears nowhere else on the site.
|
|
|
|
|
render(<PlaceView detail={mixed} englandAverage={61} neighbours={[]} />);
|
|
|
|
|
expect(screen.getByText('Reading, writing & maths')).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByText('Attainment 8')).toBeInTheDocument();
|
|
|
|
|
expect(screen.queryByText(/RWM expected/i)).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('says a missing result is unpublished rather than showing a bare dash', () => {
|
|
|
|
|
const noResult: PlaceDetail = {
|
|
|
|
|
...mixed,
|
|
|
|
|
schools: [{ urn: 3, school_name: 'New Primary', phase: 'Primary',
|
|
|
|
|
rwm_expected_pct: null, attainment_8_score: null } as never],
|
|
|
|
|
};
|
|
|
|
|
render(<PlaceView detail={noResult} englandAverage={61} neighbours={[]} />);
|
|
|
|
|
expect(screen.getByText('Not published')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('styles school links to the site convention rather than browser default', () => {
|
|
|
|
|
const { container } = render(<PlaceView detail={mixed} englandAverage={61}
|
|
|
|
|
neighbours={[]} />);
|
|
|
|
|
const link = container.querySelector('a[href^="/school/"]');
|
|
|
|
|
expect(link?.className).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a phased page shows one table and no phase headings', () => {
|
|
|
|
|
render(<PlaceView detail={mixed} phase="primary" englandAverage={61}
|
|
|
|
|
neighbours={[]} />);
|
|
|
|
|
expect(screen.queryByRole('heading', { name: /^Secondary schools/ }))
|
|
|
|
|
.not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-21 22:15:02 +01:00
|
|
|
|
|
|
|
|
describe('PlaceView table alignment', () => {
|
|
|
|
|
const aligned: PlaceDetail = {
|
|
|
|
|
place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 2,
|
|
|
|
|
parent_authority: 'Essex', phases: ['primary'] },
|
|
|
|
|
schools: [
|
|
|
|
|
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
|
|
|
|
|
rwm_expected_pct: 82, attainment_8_score: null } as never,
|
|
|
|
|
],
|
|
|
|
|
averages: { rwm_expected_pct: 63, attainment_8_score: null },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
it('aligns the measure heading and its values with the same class', () => {
|
|
|
|
|
// They were aligned by two different selectors whose specificity did not
|
|
|
|
|
// match: `.table th:last-child` (0,2,1) won and went right, while `.num`
|
|
|
|
|
// (0,1,0) lost to `.table td` (0,1,1) and stayed left. Sharing one class
|
|
|
|
|
// is what makes them impossible to drift apart.
|
|
|
|
|
const { container } = render(<PlaceView detail={aligned} englandAverage={61}
|
|
|
|
|
neighbours={[]} />);
|
|
|
|
|
const th = container.querySelectorAll('th')[1];
|
|
|
|
|
const td = container.querySelectorAll('tbody td')[1];
|
|
|
|
|
expect(th.className).toBeTruthy();
|
|
|
|
|
expect(td.className).toBe(th.className);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('leaves the school-name column unclassed so it takes the spare width', () => {
|
|
|
|
|
const { container } = render(<PlaceView detail={aligned} englandAverage={61}
|
|
|
|
|
neighbours={[]} />);
|
|
|
|
|
expect(container.querySelectorAll('th')[0].className).toBe('');
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-21 22:19:23 +01:00
|
|
|
|
|
|
|
|
describe('PlaceView authorities', () => {
|
|
|
|
|
const straddling: PlaceDetail = {
|
|
|
|
|
place: { kind: 'outcode', slug: 'sw19', name: 'SW19', count: 33,
|
|
|
|
|
parent_authority: 'Merton', phases: ['primary'],
|
|
|
|
|
authorities: [
|
|
|
|
|
{ name: 'Merton', slug: 'merton', count: 26 },
|
|
|
|
|
{ name: 'Wandsworth', slug: 'wandsworth', count: 7 },
|
|
|
|
|
] },
|
|
|
|
|
schools: [
|
|
|
|
|
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
|
|
|
|
|
rwm_expected_pct: 82, attainment_8_score: null } as never,
|
|
|
|
|
],
|
|
|
|
|
averages: { rwm_expected_pct: 63, attainment_8_score: null },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
it('names every authority the place straddles, not just the largest', () => {
|
|
|
|
|
// SW19 is mostly Merton but partly Wandsworth. Naming one asserts
|
|
|
|
|
// something false about a quarter of outcodes.
|
|
|
|
|
render(<PlaceView detail={straddling} englandAverage={61} neighbours={[]} />);
|
|
|
|
|
expect(screen.getByRole('link', { name: 'Merton' }))
|
|
|
|
|
.toHaveAttribute('href', '/schools/authority/merton');
|
|
|
|
|
expect(screen.getByRole('link', { name: 'Wandsworth' }))
|
|
|
|
|
.toHaveAttribute('href', '/schools/authority/wandsworth');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('joins them readably rather than as a bare list', () => {
|
|
|
|
|
// Asserted on the summary line's whole text: a loose /and/ matcher also
|
|
|
|
|
// hits "Wandsworth".
|
|
|
|
|
const { container } = render(<PlaceView detail={straddling}
|
|
|
|
|
englandAverage={61} neighbours={[]} />);
|
|
|
|
|
const summary = container.querySelector('header p');
|
|
|
|
|
expect(summary?.textContent).toContain('Merton and Wandsworth');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('falls back to the single parent when the field is absent', () => {
|
|
|
|
|
// A cached API response predating the authorities field must not blank
|
|
|
|
|
// the line entirely.
|
|
|
|
|
const legacy = { ...straddling,
|
|
|
|
|
place: { ...straddling.place, authorities: undefined } };
|
|
|
|
|
render(<PlaceView detail={legacy} englandAverage={61} neighbours={[]} />);
|
|
|
|
|
expect(screen.getByRole('link', { name: 'Merton' })).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|