/** * 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: () =>
, })); 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(); 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( , ); 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/); }); });