/** * 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: no verdict without a published figure, and no verdict at all * when the margin is inside the error of a postcode centroid. */ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { CutoffMapPanel } from '@/components/school/CutoffMapPanel'; import { cutoffYearRows, 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. // The panel's logic is independent of it, so the map is stubbed out. jest.mock('@/components/LeafletCutoffMapInner', () => ({ __esModule: true, default: () =>
, })); 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 d = (year: number, distance_m: number): 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! }; } function renderPanel(history: SchoolAdmissionDistance[], admissions: { year: number; oversubscribed?: boolean }[] = []) { return render( , ); } 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 published figure to draw', () => { const { container } = renderPanel([]); expect(container).toBeEmptyDOMElement(); }); it('renders nothing when the school has no coordinates', () => { const { container } = render( , ); expect(container).toBeEmptyDOMElement(); }); it('rejects a malformed postcode without calling the geocoder', async () => { renderPanel([d(2024, 800)]); await check('not a postcode'); expect(await screen.findByRole('alert')).toHaveTextContent(/does not look like a UK postcode/); expect(mockGeocode).not.toHaveBeenCalled(); }); it('reports a home clearly inside every published year', async () => { mockGeocode.mockResolvedValue(northOf(200)); renderPanel([d(2022, 1000), d(2024, 800)]); await check('SE23 3NA'); expect(await screen.findByRole('status')).toHaveTextContent(/inside the cut-off in all 2 years/); }); it('reports a home clearly outside every published year', async () => { mockGeocode.mockResolvedValue(northOf(5000)); renderPanel([d(2022, 1000), d(2024, 800)]); await check('SE23 3NA'); expect(await screen.findByRole('status')).toHaveTextContent(/outside the cut-off in every year/); }); it('declines to call a result that sits inside the measurement error', async () => { // The home is nominally inside 2024's 800 m cut-off, but only by half the // uncertainty band — which a postcode centroid cannot resolve. mockGeocode.mockResolvedValue(northOf(800 - CUTOFF_UNCERTAINTY_M / 2)); renderPanel([d(2024, 800)]); await check('SE23 3NA'); const result = await screen.findByRole('status'); expect(result).toHaveTextContent(/too close to call/); expect(result).not.toHaveTextContent(/inside the cut-off in all/); }); it('counts an unpublished year as unknown rather than as a pass', async () => { mockGeocode.mockResolvedValue(northOf(200)); renderPanel([d(2022, 1000), d(2024, 800)], [{ year: 2023, oversubscribed: false }]); await check('SE23 3NA'); const result = await screen.findByRole('status'); expect(result).toHaveTextContent(/inside the cut-off in all 2 years/); expect(result).toHaveTextContent(/no published figure/); }); it('surfaces a postcode the geocoder cannot find', async () => { mockGeocode.mockResolvedValue(null); renderPanel([d(2024, 800)]); 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([d(2024, 800)]); 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('does not carry the caveat itself', () => { // It moved to CutoffDistanceDetail so it renders once per section, and so // it still appears for a school with no coordinates (no map, no check). // Asserting its absence here is what stops the old three-paragraph stutter // creeping back. renderPanel([d(2024, 800)]); expect(screen.queryByText(/Distance is the last criterion/)).not.toBeInTheDocument(); }); it('keeps the map behind a request until there is a reason to show it', async () => { renderPanel([d(2024, 800)]); expect(screen.queryByTestId('cutoff-map')).not.toBeInTheDocument(); // A successful check is that reason: the rings only answer a question once // there is a home to sit beside them. 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([d(2024, 800)]); fireEvent.click(screen.getByRole('button', { name: /Show these distances on a map/ })); expect(screen.getByTestId('cutoff-map')).toBeInTheDocument(); }); });