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 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m20s
Rewrites every visitor-facing string that used an em dash, choosing the punctuation for what the dash was doing: a colon before a list or explanation, a comma for an aside, a full stop between two thoughts, parentheses for an aside mid-sentence. Covers page titles and meta descriptions, the home and admissions guide copy, school page headings and notes, the compare page, metric labels and tooltips. Two rewrites also fix the sentence around them: the closure banner no longer repeats "proposed for closure", and the cut-off caveat's list of priorities now parses. A lone dash marking a missing value in a table cell stays: it is a data convention, not prose. A Jest guard walks the source with the TypeScript parser and fails on any other em dash in a string or JSX text node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
153 lines
5.8 KiB
TypeScript
153 lines
5.8 KiB
TypeScript
/**
|
|
* The postcode check.
|
|
*
|
|
* This is the one place on the site that answers a question about a specific
|
|
* family rather than about a school, so the tests here are mostly about what it
|
|
* refuses to say — and that matters more now than it did, because there is only
|
|
* one year to answer with. A run of years used to soften a single close call;
|
|
* nothing does now, so the "too close to call" band is the whole safety margin.
|
|
*/
|
|
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import { CutoffMapPanel } from '@/components/school/CutoffMapPanel';
|
|
import { CUTOFF_UNCERTAINTY_M } from '@/components/school/lastDistanceOffered';
|
|
import type { School, SchoolAdmissionDistance } from '@/lib/types';
|
|
|
|
// Leaflet needs a real layout box and network tiles; neither exists in jsdom.
|
|
jest.mock('@/components/LeafletCutoffMapInner', () => ({
|
|
__esModule: true,
|
|
default: () => <div data-testid="cutoff-map" />,
|
|
}));
|
|
|
|
const mockGeocode = jest.fn();
|
|
jest.mock('@/lib/api', () => ({
|
|
...jest.requireActual('@/lib/api'),
|
|
geocodePostcode: (pc: string) => mockGeocode(pc),
|
|
}));
|
|
|
|
const SCHOOL = { urn: 100010, school_name: 'Test Primary', latitude: 51.5, longitude: -0.12 } as School;
|
|
|
|
const cutoff = (distance_m: number | null, year = 2026): SchoolAdmissionDistance => ({
|
|
year, distance_m, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles',
|
|
});
|
|
|
|
/** A point due north of the school, `metres` away. 1° latitude ≈ 111,320 m. */
|
|
function northOf(metres: number) {
|
|
return { latitude: SCHOOL.latitude! + metres / 111_320, longitude: SCHOOL.longitude! };
|
|
}
|
|
|
|
const renderPanel = (c = cutoff(800)) =>
|
|
render(<CutoffMapPanel schoolInfo={SCHOOL} cutoff={c} />);
|
|
|
|
async function check(postcode: string) {
|
|
fireEvent.change(screen.getByLabelText('Your postcode'), { target: { value: postcode } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'Check' }));
|
|
}
|
|
|
|
beforeEach(() => mockGeocode.mockReset());
|
|
|
|
describe('CutoffMapPanel', () => {
|
|
it('renders nothing without a figure to compare against', () => {
|
|
const { container } = renderPanel(cutoff(null));
|
|
expect(container).toBeEmptyDOMElement();
|
|
});
|
|
|
|
it('renders nothing when the school has no coordinates', () => {
|
|
const { container } = render(
|
|
<CutoffMapPanel
|
|
schoolInfo={{ ...SCHOOL, latitude: null, longitude: null } as School}
|
|
cutoff={cutoff(800)}
|
|
/>,
|
|
);
|
|
expect(container).toBeEmptyDOMElement();
|
|
});
|
|
|
|
it('rejects a malformed postcode without calling the geocoder', async () => {
|
|
renderPanel();
|
|
await check('not a postcode');
|
|
|
|
expect(await screen.findByRole('alert')).toHaveTextContent(/does not look like a UK postcode/);
|
|
expect(mockGeocode).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('names the year in the verdict, so the figure is never free-floating', async () => {
|
|
mockGeocode.mockResolvedValue(northOf(200));
|
|
renderPanel(cutoff(800, 2026));
|
|
await check('SE23 3NA');
|
|
|
|
const result = await screen.findByRole('status');
|
|
expect(result).toHaveTextContent(/inside the/);
|
|
expect(result).toHaveTextContent(/September 2026/);
|
|
});
|
|
|
|
it('reports a home clearly beyond the cut-off', async () => {
|
|
mockGeocode.mockResolvedValue(northOf(5000));
|
|
renderPanel(cutoff(800));
|
|
await check('SE23 3NA');
|
|
|
|
expect(await screen.findByRole('status')).toHaveTextContent(/beyond the/);
|
|
});
|
|
|
|
it('declines to call a result that sits inside the measurement error', async () => {
|
|
// Nominally inside the 800 m cut-off, but by half the uncertainty band —
|
|
// which a postcode centroid cannot resolve. With only one year published
|
|
// there is nothing else to fall back on, so this must not read as a pass.
|
|
mockGeocode.mockResolvedValue(northOf(800 - CUTOFF_UNCERTAINTY_M / 2));
|
|
renderPanel(cutoff(800));
|
|
await check('SE23 3NA');
|
|
|
|
const result = await screen.findByRole('status');
|
|
expect(result).toHaveTextContent(/too close/);
|
|
expect(result).toHaveTextContent(/measurement error/);
|
|
// Explanation is supporting text, not part of the bold verdict line.
|
|
expect(result.querySelector('[class*="cutoffCheckHeadline"]')!.textContent)
|
|
.not.toMatch(/measurement error/);
|
|
expect(result).not.toHaveTextContent(/^\S+ away, inside/);
|
|
});
|
|
|
|
it('surfaces a postcode the geocoder cannot find', async () => {
|
|
mockGeocode.mockResolvedValue(null);
|
|
renderPanel();
|
|
await check('ZZ99 9ZZ');
|
|
|
|
expect(await screen.findByRole('alert')).toHaveTextContent(/could not find that postcode/);
|
|
});
|
|
|
|
it('recovers from a geocoder failure instead of leaving a stale verdict', async () => {
|
|
mockGeocode.mockResolvedValue(northOf(200));
|
|
renderPanel();
|
|
await check('SE23 3NA');
|
|
await screen.findByRole('status');
|
|
|
|
mockGeocode.mockRejectedValue(new Error('network'));
|
|
await check('SE23 3NB');
|
|
|
|
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
|
|
expect(screen.getByRole('alert')).toHaveTextContent(/Something went wrong/);
|
|
});
|
|
|
|
it('keeps the map behind a request until there is a reason to show it', async () => {
|
|
renderPanel();
|
|
expect(screen.queryByTestId('cutoff-map')).not.toBeInTheDocument();
|
|
|
|
mockGeocode.mockResolvedValue(northOf(200));
|
|
await check('SE23 3NA');
|
|
await screen.findByRole('status');
|
|
expect(screen.getByTestId('cutoff-map')).toBeInTheDocument();
|
|
});
|
|
|
|
it('can also show the map without a postcode, on request', () => {
|
|
renderPanel();
|
|
fireEvent.click(screen.getByRole('button', { name: /Show this distance on a map/ }));
|
|
expect(screen.getByTestId('cutoff-map')).toBeInTheDocument();
|
|
});
|
|
|
|
it('states its limits before it is used, not with the answer', () => {
|
|
renderPanel();
|
|
const caveat = screen.getByText(/Distance is the last criterion applied/);
|
|
expect(caveat).toBeInTheDocument();
|
|
expect(caveat).toHaveTextContent(/not a catchment boundary/);
|
|
expect(caveat).toHaveTextContent(/walking route/);
|
|
});
|
|
});
|