Files
school_compare/nextjs-app/__tests__/components/ResultsMapView.test.tsx
T
TudorandClaude Opus 5.5 8ebe461435
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 18s
fix(search): set the toolbar's line count by width, not by results
The results toolbar wrapped wherever it ran out of room, and the List/Map
switch only appears when there are results, so the same search took two
lines with results and one without.

From 1340px the controls never wrap away from the search, which takes
what they leave (at least 12rem); phase and type chips cap at 11rem to
fit. Between 641px and 1339px the controls always take a full line of
their own. The switch now sits in FilterBar's row via a viewSwitch slot,
so that line runs the full width instead of stopping short of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:27:14 +01:00

185 lines
8.2 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.0 miles of SW196AR' })).toBeInTheDocument();
});