2026-08-16 13:42:06 +01:00
|
|
|
/**
|
|
|
|
|
* 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
|
2026-08-20 18:44:57 +01:00
|
|
|
* 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.
|
2026-08-16 13:42:06 +01:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
|
|
|
import { CutoffMapPanel } from '@/components/school/CutoffMapPanel';
|
2026-08-20 18:44:57 +01:00
|
|
|
import { CUTOFF_UNCERTAINTY_M } from '@/components/school/lastDistanceOffered';
|
2026-08-16 13:42:06 +01:00
|
|
|
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;
|
|
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
const cutoff = (distance_m: number | null, year = 2026): SchoolAdmissionDistance => ({
|
2026-08-16 13:42:06 +01:00
|
|
|
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! };
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
const renderPanel = (c = cutoff(800)) =>
|
|
|
|
|
render(<CutoffMapPanel schoolInfo={SCHOOL} cutoff={c} />);
|
2026-08-16 13:42:06 +01:00
|
|
|
|
|
|
|
|
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', () => {
|
2026-08-20 18:44:57 +01:00
|
|
|
it('renders nothing without a figure to compare against', () => {
|
|
|
|
|
const { container } = renderPanel(cutoff(null));
|
2026-08-16 13:42:06 +01:00
|
|
|
expect(container).toBeEmptyDOMElement();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('renders nothing when the school has no coordinates', () => {
|
|
|
|
|
const { container } = render(
|
|
|
|
|
<CutoffMapPanel
|
|
|
|
|
schoolInfo={{ ...SCHOOL, latitude: null, longitude: null } as School}
|
2026-08-20 18:44:57 +01:00
|
|
|
cutoff={cutoff(800)}
|
2026-08-16 13:42:06 +01:00
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
expect(container).toBeEmptyDOMElement();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects a malformed postcode without calling the geocoder', async () => {
|
2026-08-20 18:44:57 +01:00
|
|
|
renderPanel();
|
2026-08-16 13:42:06 +01:00
|
|
|
await check('not a postcode');
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByRole('alert')).toHaveTextContent(/does not look like a UK postcode/);
|
|
|
|
|
expect(mockGeocode).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
it('names the year in the verdict, so the figure is never free-floating', async () => {
|
2026-08-16 13:42:06 +01:00
|
|
|
mockGeocode.mockResolvedValue(northOf(200));
|
2026-08-20 18:44:57 +01:00
|
|
|
renderPanel(cutoff(800, 2026));
|
2026-08-16 13:42:06 +01:00
|
|
|
await check('SE23 3NA');
|
|
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
const result = await screen.findByRole('status');
|
|
|
|
|
expect(result).toHaveTextContent(/inside the/);
|
|
|
|
|
expect(result).toHaveTextContent(/September 2026/);
|
2026-08-16 13:42:06 +01:00
|
|
|
});
|
|
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
it('reports a home clearly beyond the cut-off', async () => {
|
2026-08-16 13:42:06 +01:00
|
|
|
mockGeocode.mockResolvedValue(northOf(5000));
|
2026-08-20 18:44:57 +01:00
|
|
|
renderPanel(cutoff(800));
|
2026-08-16 13:42:06 +01:00
|
|
|
await check('SE23 3NA');
|
|
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
expect(await screen.findByRole('status')).toHaveTextContent(/beyond the/);
|
2026-08-16 13:42:06 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('declines to call a result that sits inside the measurement error', async () => {
|
2026-08-20 18:44:57 +01:00
|
|
|
// 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.
|
2026-08-16 13:42:06 +01:00
|
|
|
mockGeocode.mockResolvedValue(northOf(800 - CUTOFF_UNCERTAINTY_M / 2));
|
2026-08-20 18:44:57 +01:00
|
|
|
renderPanel(cutoff(800));
|
2026-08-16 13:42:06 +01:00
|
|
|
await check('SE23 3NA');
|
|
|
|
|
|
|
|
|
|
const result = await screen.findByRole('status');
|
2026-08-20 18:44:57 +01:00
|
|
|
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/);
|
2026-08-16 13:42:06 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('surfaces a postcode the geocoder cannot find', async () => {
|
|
|
|
|
mockGeocode.mockResolvedValue(null);
|
2026-08-20 18:44:57 +01:00
|
|
|
renderPanel();
|
2026-08-16 13:42:06 +01:00
|
|
|
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));
|
2026-08-20 18:44:57 +01:00
|
|
|
renderPanel();
|
2026-08-16 13:42:06 +01:00
|
|
|
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/);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-20 14:54:02 +01:00
|
|
|
it('keeps the map behind a request until there is a reason to show it', async () => {
|
2026-08-20 18:44:57 +01:00
|
|
|
renderPanel();
|
2026-08-20 14:54:02 +01:00
|
|
|
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', () => {
|
2026-08-20 18:44:57 +01:00
|
|
|
renderPanel();
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Show this distance on a map/ }));
|
2026-08-20 14:54:02 +01:00
|
|
|
expect(screen.getByTestId('cutoff-map')).toBeInTheDocument();
|
2026-08-16 13:42:06 +01:00
|
|
|
});
|
2026-08-20 18:44:57 +01:00
|
|
|
|
|
|
|
|
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/);
|
|
|
|
|
});
|
2026-08-16 13:42:06 +01:00
|
|
|
});
|