Files
school_compare/nextjs-app/__tests__/components/ResultsMapView.test.tsx
T
TudorandClaude Opus 5.5 355a5a841c
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
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 17s
feat(search): offer a quarter-mile distance
Adds "Within 0.25 miles" to the Distance control; 0.5 stays the default.
The radius shown in the results heading and drawn on the map was rounded
to one decimal place, which would have turned 0.25 into 0.3. It now keeps
two, and the list heading says "1 mile" rather than "1.0 miles".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:52:59 +01:00

196 lines
8.9 KiB
TypeScript

import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { HomeView } from '@/components/HomeView';
import { fetchSchools, fetchNationalAverages } from '@/lib/api';
import { primaryFixture } from '../support/schoolFixtures';
import type { School, SchoolsResponse } from '@/lib/types';
/*
* The map view: the list beside the map (mockup B), where every postcode
* search opens. The map itself is Leaflet and mocked here; what is pinned is what
* HomeView hands it and the list it draws beside it.
*/
let params = new URLSearchParams('postcode=SW196AR&radius=1');
jest.mock('next/navigation', () => ({
useSearchParams: () => params,
usePathname: () => '/',
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
}));
jest.mock('@/context/ComparisonContext', () => ({
useComparisonContext: () => ({ addSchool: jest.fn(), removeSchool: jest.fn(), selectedSchools: [] }),
}));
jest.mock('@/lib/api', () => ({
fetchSchools: jest.fn(),
fetchNationalAverages: jest.fn(),
fetchLAaverages: jest.fn(async () => ({ secondary: { attainment_8_by_la: {} } })),
}));
// Renders only the List/Map switch HomeView hands it, which lives in its row.
jest.mock('@/components/FilterBar', () => ({
FilterBar: ({ viewSwitch }: { viewSwitch?: unknown }) => viewSwitch || null,
}));
jest.mock('@/components/SchoolMap', () => ({
SchoolMap: ({ selectedUrn, radiusMiles, onMarkerClick, schools }: {
selectedUrn: number | null; radiusMiles?: number;
onMarkerClick: (s: School) => void; schools: School[];
}) => (
<div data-testid="map" data-selected={selectedUrn ?? ''} data-radius={radiusMiles}>
<button type="button" onClick={() => onMarkerClick(schools[1])}>pin</button>
</div>
),
}));
const base = primaryFixture.schoolInfo;
const southmead: School = {
...base, urn: 2, school_name: 'Southmead Primary School', distance: 0.2,
school_type: 'Community school', rwm_expected_pct: 52, total_pupils: 269,
};
const greenmead: School = {
...base, urn: 3, school_name: 'Greenmead School', distance: 0.2,
school_type: 'Community special school', rwm_expected_pct: 0,
reading_expected_pct: 0, writing_expected_pct: 0, maths_expected_pct: 0, total_pupils: 62,
};
const ourLady: School = {
...base, urn: 1, school_name: 'Our Lady Queen of Heaven RC School', distance: 0,
school_type: 'Voluntary aided school', rwm_expected_pct: 70, total_pupils: 224,
};
function results(): SchoolsResponse {
return {
schools: [ourLady, southmead, greenmead], total: 3, page: 1, page_size: 25, total_pages: 1,
location_info: { postcode: 'SW196AR', radius: 1.60934, coordinates: [51.42, -0.21] },
} as SchoolsResponse;
}
const filters = { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
beforeEach(() => {
params = new URLSearchParams('postcode=SW196AR&radius=1');
jest.mocked(fetchSchools).mockReset().mockResolvedValue(results());
jest.mocked(fetchNationalAverages).mockResolvedValue({ primary: { rwm_expected_pct: 62 } } as never);
setWide(true);
});
/** Desktop unless a test says otherwise: the list pane is shown from 769px. */
function setWide(wide: boolean) {
window.matchMedia = ((q: string) => ({
matches: wide, media: q, addEventListener() {}, removeEventListener() {},
})) as unknown as typeof window.matchMedia;
}
async function renderMap() {
const view = render(<HomeView initialSchools={results()} filters={filters} />);
await act(async () => {});
return view;
}
it('opens a postcode search on the map', async () => {
await renderMap();
expect(screen.getByTestId('map')).toHaveAttribute('data-radius', '1');
expect(screen.getByRole('button', { name: 'Map' })).toHaveAttribute('aria-pressed', 'true');
});
it('lists a name search, which has no map', async () => {
params = new URLSearchParams('search=southmead');
render(<HomeView initialSchools={results()} filters={filters} />);
await act(async () => {});
expect(screen.queryByTestId('map')).not.toBeInTheDocument();
});
it('puts the count and the sort in the list beside the map, once', async () => {
await renderMap();
// Short beside the map, so it shares one line with the sort.
expect(screen.getAllByRole('heading', { name: '3 schools within 1 mile' })).toHaveLength(1);
expect(screen.getAllByRole('combobox')).toHaveLength(1);
});
it('selects the pin from the card, and the card from the pin', async () => {
const { container } = await renderMap();
const card = (urn: number) => container.querySelector(`[data-urn="${urn}"]`) as HTMLElement;
fireEvent.click(within(card(1)).getByText(/pupils/));
expect(screen.getByTestId('map')).toHaveAttribute('data-selected', '1');
expect(card(1).className).toMatch(/mapRowSelected/);
fireEvent.click(screen.getByRole('button', { name: 'pin' }));
expect(card(2).className).toMatch(/mapRowSelected/);
expect(card(1).className).not.toMatch(/mapRowSelected/);
});
it('clicking a card\'s link or button does not also select it', async () => {
const { container } = await renderMap();
const card = container.querySelector('[data-urn="1"]') as HTMLElement;
fireEvent.click(within(card).getByRole('button', { name: '+ Compare' }));
expect(screen.getByTestId('map')).toHaveAttribute('data-selected', '');
});
it('shows the England comparison for mainstream schools only, and never a placeholder 0%', async () => {
const { container } = await renderMap();
const card = (urn: number) => container.querySelector(`[data-urn="${urn}"]`) as HTMLElement;
expect(card(2)).toHaveTextContent('52%Reading, Writing & Maths-10 pts vs national');
expect(card(2)).toHaveTextContent('269pupils');
expect(card(3)).toHaveTextContent('62pupils');
expect(card(3)).not.toHaveTextContent(/%|pts/);
});
it('opens the map after a hero search, and keeps the reader\'s choice after that', async () => {
// Landing page, then a hero search: the same instance gets new props.
params = new URLSearchParams('');
const empty = { schools: [], total: 0, page: 1, page_size: 25, total_pages: 0 } as SchoolsResponse;
const view = render(<HomeView initialSchools={empty} filters={filters} />);
params = new URLSearchParams('postcode=SW196AR&radius=1');
view.rerender(<HomeView initialSchools={results()} filters={filters} />);
await act(async () => {});
expect(screen.getByTestId('map')).toBeInTheDocument();
// Chosen: a later search keeps the list.
fireEvent.click(screen.getByRole('button', { name: 'List' }));
params = new URLSearchParams('postcode=SW170AA&radius=1');
view.rerender(<HomeView initialSchools={results()} filters={filters} />);
await act(async () => {});
expect(screen.queryByTestId('map')).not.toBeInTheDocument();
});
it('builds no list cards on a phone, where the pane is hidden', async () => {
setWide(false);
const { container } = await renderMap();
expect(screen.getByTestId('map')).toBeInTheDocument();
expect(container.querySelectorAll('[data-urn]')).toHaveLength(0);
// The count stays: it is the pane's heading, shown above the map.
expect(screen.getByRole('heading', { name: /3 schools within/ })).toBeInTheDocument();
});
it('draws the list view\'s own row beside the map, with the same content', async () => {
const { container } = await renderMap();
const beside = container.querySelector('[data-urn="2"] > [class~="row"]')!.textContent;
fireEvent.click(screen.getByRole('button', { name: 'List' }));
const row = screen.getByRole('link', { name: 'Southmead Primary School' }).closest('[class~="row"]')!;
expect(row.parentElement?.className).toMatch(/schoolList/);
expect(row.textContent).toBe(beside);
});
it('lets a keyboard pick a pin from the list, with a real button', async () => {
await renderMap();
const show = screen.getByRole('button', { name: 'Show Southmead Primary School on the map' });
expect(show).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(show);
expect(screen.getByTestId('map')).toHaveAttribute('data-selected', '2');
expect(show).toHaveAttribute('aria-pressed', 'true');
});
it('keeps the postcode in the heading in list view, where there is room', async () => {
await renderMap();
fireEvent.click(screen.getByRole('button', { name: 'List' }));
expect(screen.getByRole('heading', { name: '3 schools within 1 mile of SW196AR' })).toBeInTheDocument();
});
it('draws and names a quarter-mile search as 0.25, not rounded to 0.3', async () => {
params = new URLSearchParams('postcode=SW196AR&radius=0.25');
const quarter = { ...results(), location_info: { postcode: 'SW196AR', radius: 0.25 * 1.60934, coordinates: [51.42, -0.21] } } as SchoolsResponse;
render(<HomeView initialSchools={quarter} filters={filters} />);
await act(async () => {});
expect(screen.getByTestId('map')).toHaveAttribute('data-radius', '0.25');
expect(screen.getByRole('heading', { name: '3 schools within 0.25 miles' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'List' }));
expect(screen.getByRole('heading', { name: '3 schools within 0.25 miles of SW196AR' })).toBeInTheDocument();
});