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, parent_authority: 'Essex', phases: ['primary'] }, schools: [ { urn: 1, school_name: 'Alpha Primary', rwm_expected_pct: 82, ofsted_grade: 1, phase: 'Primary' } as never, { urn: 2, school_name: 'Beta Primary', rwm_expected_pct: 44, ofsted_grade: 3, phase: 'Primary' } as never, ], averages: { rwm_expected_pct: 63, attainment_8_score: null }, }; describe('PlaceView', () => { it('leads with an H1 that matches how the place is searched', () => { render(); 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(); expect(screen.getByText(/29 schools/i)).toBeInTheDocument(); }); it('compares the local average against England, which a list cannot', () => { render(); 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(); expect(screen.getAllByRole('link', { name: /Primary$/ })).toHaveLength(2); }); it('links to the parent authority so the place sits in a hierarchy', () => { render(); expect(screen.getByRole('link', { name: /Essex/i })) .toHaveAttribute('href', '/schools/authority/essex'); }); it('shows the Ofsted distribution, not just a count of Outstanding', () => { render(); expect(screen.getByTestId('ofsted-distribution')).toBeInTheDocument(); }); it('links to neighbouring places so the page is not a dead end', () => { render(); expect(screen.getByRole('link', { name: /Romford/ })) .toHaveAttribute('href', '/schools/romford'); }); it('says nothing about an average it does not have', () => { render(); 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\//); } }); }); describe('PlaceView phase variants', () => { it('links the phase variants that exist', () => { render(); 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(); expect(screen.queryByRole('link', { name: /Secondary schools in Brentwood/i })) .not.toBeInTheDocument(); }); it('does not link sideways from a variant page to itself', () => { render(); expect(screen.queryByRole('link', { name: /Primary schools in Brentwood/i })) .not.toBeInTheDocument(); }); }); 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(); 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(); 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(); expect(screen.getByText('Not published')).toBeInTheDocument(); }); it('styles school links to the site convention rather than browser default', () => { const { container } = render(); const link = container.querySelector('a[href^="/school/"]'); expect(link?.className).toBeTruthy(); }); it('a phased page shows one table and no phase headings', () => { render(); expect(screen.queryByRole('heading', { name: /^Secondary schools/ })) .not.toBeInTheDocument(); }); }); 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(); 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(); expect(container.querySelectorAll('th')[0].className).toBe(''); }); }); 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(); 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(); 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(); expect(screen.getByRole('link', { name: 'Merton' })).toBeInTheDocument(); }); }); describe('PlaceView list ordering', () => { const detail3: 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: 40, attainment_8_score: null } as never, { urn: 2, school_name: 'Beta Primary', phase: 'Primary', rwm_expected_pct: 90, attainment_8_score: null } as never, ], averages: { rwm_expected_pct: 65, attainment_8_score: null }, }; it('renders schools in the order the API sent them, not by score', () => { // The API sorts alphabetically now; the component must not re-sort. render(); const links = screen.getAllByRole('link', { name: /Primary$/ }); expect(links.map((l) => l.textContent)) .toEqual(['Alpha Primary', 'Beta Primary']); }); it('declares the list as ascending rather than implying a ranking', () => { // An ItemList carrying `position` reads as a ranking unless it says // otherwise, and the table is A-Z. const { container } = render(); const ld = JSON.parse( container.querySelector('script[type="application/ld+json"]')!.textContent!); const list = ld['@graph'].find((n: { '@type': string }) => n['@type'] === 'ItemList'); expect(list.itemListOrder).toBe('https://schema.org/ItemListOrderAscending'); }); }); describe('PlaceView phase links', () => { const authority: PlaceDetail = { place: { kind: 'authority', slug: 'barnet', name: 'Barnet', count: 156, parent_authority: null, phases: ['primary', 'secondary'] }, 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('keeps an authority phase link in the authority namespace', () => { // The link was built as `/schools/${slug}/${phase}` for every kind, so an // authority page pointed into the town namespace. For 87 of 151 // authorities that 404'd; for the other 64 it silently landed on the town // page of the same name — a different set of schools, and exactly the // duplicate the two namespaces exist to prevent. Barnet is one of the 64. render(); expect(screen.getByRole('link', { name: /^Primary schools in Barnet$/ })) .toHaveAttribute('href', '/schools/authority/barnet/primary'); expect(screen.getByRole('link', { name: /^Secondary schools in Barnet$/ })) .toHaveAttribute('href', '/schools/authority/barnet/secondary'); }); it('still uses the bare namespace for a town', () => { render(); expect(screen.getByRole('link', { name: /^Primary schools in Brentwood$/ })) .toHaveAttribute('href', '/schools/brentwood/primary'); }); it('offers no phase link when the place publishes none', () => { // Outcodes are the case: no phase route exists for them, so the registry // reports no phases and the nav does not render. const outcode = { ...detail, place: { ...detail.place, kind: 'outcode', slug: 'cm13', name: 'CM13', phases: [] } }; render(); expect(screen.queryByRole('navigation', { name: 'By phase' })) .not.toBeInTheDocument(); }); }); describe('PlaceView unlinkable authorities', () => { const withUnpublished: PlaceDetail = { place: { kind: 'outcode', slug: 'tr21', name: 'TR21', count: 8, parent_authority: 'Cornwall', phases: [], authorities: [ { name: 'Cornwall', slug: 'cornwall', count: 6 }, { name: 'Isles Of Scilly', slug: null, count: 2 }, ] }, 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 an authority with no page without linking it', () => { // City of London and the Isles of Scilly hold fewer schools than a page // needs. Saying where the place is stays right; linking there would 404. const { container } = render(); expect(screen.getByRole('link', { name: 'Cornwall' })).toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Isles Of Scilly' })) .not.toBeInTheDocument(); expect(container.querySelector('header p')?.textContent) .toContain('Isles Of Scilly'); }); }); describe('PlaceView school attributes', () => { /* * The table shipped with one column of scores, which answers "how did they * do" and nothing about whether the school is one a family could use. Age * range, faith, nursery and constituency are the four facts a parent * filters on before they look at a number at all. */ const withAttributes: PlaceDetail = { place: { kind: 'town', slug: 'chelmsford', name: 'Chelmsford', count: 3, parent_authority: 'Essex', phases: ['primary', 'secondary'] }, schools: [ { urn: 1, school_name: 'Alpha Primary', phase: 'Primary', rwm_expected_pct: 82, attainment_8_score: null, age_range: '4-11', religious_denomination: 'Church of England', nursery_provision: true, parliamentary_constituency: 'Chelmsford' } as never, { urn: 2, school_name: 'Beta High', phase: 'Secondary', rwm_expected_pct: null, attainment_8_score: 47, age_range: '11-16', religious_denomination: 'Does not apply', nursery_provision: false, parliamentary_constituency: 'Witham' } as never, ], averages: { rwm_expected_pct: 63, attainment_8_score: 45 }, }; function headings(container: HTMLElement, table = 0): string[] { return Array.from(container.querySelectorAll('table')[table] .querySelectorAll('thead th')).map((th) => th.textContent ?? ''); } it('heads a primary table with all four attributes', () => { const { container } = render(); expect(headings(container)).toEqual([ 'School', 'Reading, writing & maths', 'Ages', 'Religious character', 'Nursery', 'Constituency', ]); }); it('omits nursery from a secondary table, where it does not apply', () => { const { container } = render(); expect(headings(container, 1)).toEqual([ 'School', 'Attainment 8', 'Ages', 'Religious character', 'Constituency', ]); }); it('keeps the measure beside the school name, where a phone can see it', () => { // Six columns overflow a phone and .tableWrap turns that into a swipe. // With the measure last, the one number the page exists for is the one // scrolled off the screen. const { container } = render(); expect(headings(container)[1]).toBe('Reading, writing & maths'); }); it('shows the age range without repeating the column heading', () => { render(); expect(screen.getByText('4–11')).toBeInTheDocument(); expect(screen.queryByText('Ages 4–11')).not.toBeInTheDocument(); }); it('names the faith of a faith school', () => { render(); expect(screen.getByText('Church of England')).toBeInTheDocument(); }); it('reads "Does not apply" as no religious character, not as a value', () => { // GIAS spells the absence of a faith as "Does not apply", which is a // database answer rather than an English one. The school page already // suppresses it; the two must not disagree about the same school. const { container } = render(); const secondary = container.querySelectorAll('table')[1] .querySelectorAll('tbody td'); expect(secondary[3].textContent).toBe('—'); expect(screen.queryByText(/Does not apply/)).not.toBeInTheDocument(); }); it('marks a nursery as such and a school without one as not', () => { const { container } = render(); const cells = container.querySelectorAll('table')[0] .querySelectorAll('tbody td'); expect(cells[4].textContent).toBe('Yes'); }); it('names the constituency of each school', () => { render(); expect(screen.getByText('Chelmsford', { selector: 'td' })).toBeInTheDocument(); expect(screen.getByText('Witham', { selector: 'td' })).toBeInTheDocument(); }); it('dashes an attribute the data does not carry', () => { // nursery_provision and parliamentary_constituency are absent from marts // the pipeline has not rebuilt, and the API degrades them to null rather // than failing. A row must survive that. const bare: PlaceDetail = { ...withAttributes, schools: [{ urn: 3, school_name: 'Gamma Primary', phase: 'Primary', rwm_expected_pct: 70 } as never], }; const { container } = render(); const cells = Array.from(container.querySelectorAll('tbody td')) .map((td) => td.textContent); expect(cells.slice(2)).toEqual(['—', '—', '—', '—']); }); it('gives an all-through school its nursery under primary only', () => { // All-through schools render in both groups. Nursery belongs to the // primary reading of the same school, not the secondary one. const allThrough: PlaceDetail = { ...withAttributes, schools: [{ urn: 4, school_name: 'Delta Academy', phase: 'All-through', rwm_expected_pct: 66, attainment_8_score: 51, age_range: '4-18', religious_denomination: 'None', nursery_provision: true, parliamentary_constituency: 'Chelmsford' } as never], }; const { container } = render(); const tables = container.querySelectorAll('table'); expect(tables[0].textContent).toContain('Yes'); expect(tables[1].textContent).not.toContain('Yes'); }); });