Files
school_compare/nextjs-app/__tests__/components/CutoffMapPanel.test.tsx
T

161 lines
6.3 KiB
TypeScript
Raw Normal View History

/**
* 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: () => <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 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(
<CutoffMapPanel schoolInfo={SCHOOL} rows={cutoffYearRows(history, admissions)} />,
);
}
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(
<CutoffMapPanel
schoolInfo={{ ...SCHOOL, latitude: null, longitude: null } as School}
rows={cutoffYearRows([d(2024, 800)])}
/>,
);
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();
});
});