Files
school_compare/nextjs-app/__tests__/components/SimilarSchoolsSection.test.tsx
T
TudorandClaude Opus 5 5c0ccc693d fix: order nearby schools by distance, not by how alike they are
Reported from staging: a Catholic primary showed six Catholic primaries, none
of them close enough to be a real option, and omitted the community school
down the road.

Three causes, compounding. Ranking put tier before distance, so a faith match
at 2.9 miles outranked a community school at 0.3. The ENOUGH=3 stopping rule —
added so a cap of six would not drag in weak distant matches — filled the row
from the best tier before it ever widened, which is what made every card
Catholic. And a 3-mile tier-1 radius is sane for a secondary and most of a city
for a primary, whose catchments are routinely under a mile.

The premise was backwards. For a parent, distance is a constraint and intake is
a preference; a school beyond a primary catchment is not a weaker option, it is
not an option. So distance now decides the order and nothing else does. The
hard filters are untouched — they were always where the defensibility lived.
Similarity survives as chips on the card: reported, so a reader applies their
own weighting, rather than ranked, so we apply ours for them.

Reach is capped per phase (primary 2, secondary 6, post-16 10) as a sanity
bound, not a target: ordering already handles density, so the cap only decides
what happens where an area is sparse. A primary with nothing inside two miles
now renders no section, which is the honest answer.

Deleted: the tier system, the stopping rule, the tier-dependent lede, the
`tier` field, the tier-3 fallback chip and its style. select_similar also stops
taking is_secondary — it reads the phase from the subject's own row, so no
caller can hand it one that disagrees with the data.

The heading is now "Other schools nearby". The hard filters still guarantee a
comparable set, but nothing ranks on likeness, so the heading no longer says it
does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 13:06:32 +01:00

193 lines
7.0 KiB
TypeScript

/**
* The section's job is to be honest about what it is showing. These tests pin
* the ways it could mislead: rendering below the minimum, claiming a likeness
* it does not rank on, showing a missing figure as a number, or hiding a card
* behind an arrow where a crawler cannot reach it.
*/
import { render, screen } from '@testing-library/react';
import {
nearbyNoun,
SimilarSchoolsSection,
shouldRenderSimilar,
} from '@/components/school/SimilarSchoolsSection';
import type { SimilarSchool } from '@/lib/types';
jest.mock('@/components/school/AddToCompareButton', () => ({
AddToCompareButton: ({ school }: { school: SimilarSchool }) => (
<button type="button">Add {school.school_name} to compare</button>
),
}));
jest.mock('@/components/school/SimilarSchoolsCompareBar', () => ({
SimilarSchoolsCompareBar: ({ thisUrn }: { thisUrn: number }) => (
<div data-testid="compare-bar">bar for {thisUrn}</div>
),
}));
function school(overrides: Partial<SimilarSchool> = {}): SimilarSchool {
return {
urn: 100002,
school_name: 'Willow Lane Primary School',
distance_miles: 0.6,
school_type: 'Community school',
age_range: '4-11',
shared: ['Mixed', 'No religious character'],
metric_value: 74,
metric_key: 'rwm_expected_pct',
metric_year: 202425,
...overrides,
};
}
function renderSection(similar: SimilarSchool[]) {
return render(
<SimilarSchoolsSection
urn={100001}
schoolName="Meadowbrook Primary School"
phase="Primary"
thisMetricValue={72}
similar={similar}
/>,
);
}
describe('render gates', () => {
it.each([
['undefined', undefined],
['null', null],
['empty', []],
['a single school', [school()]],
])('renders nothing for %s', (_label, value) => {
expect(shouldRenderSimilar(value as SimilarSchool[] | null | undefined)).toBe(false);
});
it('renders for two or more schools', () => {
expect(shouldRenderSimilar([school(), school({ urn: 100003 })])).toBe(true);
});
it('returns null rather than an empty shell below the minimum', () => {
const { container } = renderSection([school()]);
expect(container).toBeEmptyDOMElement();
});
});
describe('what the section claims', () => {
it('never claims a similar intake, because it does not rank on one', () => {
renderSection([school(), school({ urn: 100003, shared: [] })]);
expect(screen.queryByText(/similar intake/i)).not.toBeInTheDocument();
});
it('is headed "Other schools nearby", not "similar"', () => {
renderSection([school(), school({ urn: 100003 })]);
expect(screen.getByRole('heading', { name: 'Other schools nearby' })).toBeInTheDocument();
});
it('shows chips for what is shared', () => {
renderSection([school({ shared: ['Mixed', 'Roman Catholic'] }), school({ urn: 100003 })]);
expect(screen.getAllByText('Roman Catholic').length).toBe(1);
});
it('shows no chips at all when nothing is shared, rather than inventing one', () => {
const { container } = render(
<SimilarSchoolsSection
urn={100001}
schoolName="Meadowbrook Primary School"
phase="Primary"
thisMetricValue={72}
similar={[school({ shared: [] }), school({ urn: 100003, shared: [] })]}
/>,
);
// The card still carries its distance, name, type and figure — just no
// claim of likeness.
expect(container.querySelectorAll('li ul').length).toBe(0);
expect(screen.getAllByText(/miles away/).length).toBe(2);
});
});
describe('what the lede calls the set', () => {
it.each([
['Primary', 'primary schools'],
['Middle deemed primary', 'primary schools'],
['Secondary', 'secondary schools'],
['Middle deemed secondary', 'secondary schools'],
['All-through', 'all-through schools'],
// GIAS phase 6. Its candidates span the whole secondary group, so no
// single noun fits and it takes the honest general one.
['16 plus', 'schools and colleges'],
['', 'schools'],
[null, 'schools'],
])('calls a %s school\'s neighbours "%s"', (phase, expected) => {
expect(nearbyNoun(phase)).toBe(expected);
});
it('never calls a sixth form college\'s neighbours primary schools', () => {
render(
<SimilarSchoolsSection
urn={100001}
schoolName="Barnet Sixth Form College"
phase="16 plus"
thisMetricValue={null}
similar={[school(), school({ urn: 100003 })]}
/>,
);
expect(screen.getByText(/Other schools and colleges near Barnet Sixth Form College/)).toBeInTheDocument();
expect(screen.queryByText(/primary schools/)).not.toBeInTheDocument();
});
});
describe('cards', () => {
it('links each school to its canonical slug', () => {
renderSection([school(), school({ urn: 100003, school_name: 'Oakfield Primary School' })]);
const link = screen.getByRole('link', { name: /Willow Lane Primary School/ });
expect(link).toHaveAttribute('href', '/school/100002-willow-lane-primary-school');
});
it('shows the distance and the shared characteristics', () => {
renderSection([school(), school({ urn: 100003 })]);
expect(screen.getAllByText('0.6 miles away').length).toBeGreaterThan(0);
expect(screen.getAllByText('Mixed').length).toBeGreaterThan(0);
});
it('renders a missing figure as "Not published", never as a number', () => {
renderSection([school({ metric_value: null }), school({ urn: 100003 })]);
expect(screen.getByText('Not published')).toBeInTheDocument();
});
it('anchors each figure against this school', () => {
renderSection([school(), school({ urn: 100003 })]);
expect(screen.getAllByText('72% at this school').length).toBe(2);
});
it('offers the compare bar once, for this school', () => {
renderSection([school(), school({ urn: 100003 })]);
expect(screen.getByTestId('compare-bar')).toHaveTextContent('bar for 100001');
});
it('keeps every card in the DOM, including the ones scrolled out of view', () => {
const six = Array.from({ length: 6 }, (_, n) =>
school({ urn: 100002 + n, school_name: `Peer ${n} School` }),
);
renderSection(six);
expect(screen.getAllByRole('link', { name: /Peer \d School/ })).toHaveLength(6);
});
it('offers no arrows when three cards fit the row', () => {
renderSection([school(), school({ urn: 100003 }), school({ urn: 100004 })]);
expect(screen.queryByRole('button', { name: /More schools/ })).not.toBeInTheDocument();
});
it('offers arrows once there is a fourth school', () => {
renderSection(Array.from({ length: 4 }, (_, n) => school({ urn: 100002 + n })));
expect(screen.getByRole('button', { name: /More schools/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Previous schools/ })).toBeInTheDocument();
});
it('says distances are straight-line, and offers no method panel', () => {
const { container } = renderSection([school(), school({ urn: 100003 })]);
expect(screen.getByText(/straight-line from this school/i)).toBeInTheDocument();
expect(container.querySelector('details')).toBeNull();
});
});