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 21s
Trial the map as the default for every postcode search, not only on desktop (DEFAULT_LOCATION_VIEW in HomeView; results_view_changed shows how often readers switch away). The view is now derived: the reader's choice once made, else the default for the kind of search. That follows a hero search through client-side navigation without syncing state. With nothing left varying by device, the user-agent default goes (lib/device.ts, the headers() read in page.tsx). That settles two review points: the HTML no longer needs Vary: User-Agent, and there is no narrow-window fallback left to flash the map before the list. Review fix in LeafletMapInner: the card reopened only when the selection or the school list changed, so a rebuild caused by the centre, reference point or radius closed it for good while the page still had the school selected. The selection effect now follows a pins-rebuilt counter. SchoolMap memoises its fallback centre, which was a fresh array on every render and would have rebuilt every pin each time. Phones hide the map view's list pane, so it no longer builds up to 500 hidden cards there (decided after mount, so hydration still matches). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
155 lines
6.8 KiB
TypeScript
155 lines
6.8 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: {} } })),
|
|
}));
|
|
jest.mock('@/components/FilterBar', () => ({ FilterBar: () => 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();
|
|
expect(screen.getAllByRole('heading', { name: /3 schools within 1.0 miles of SW196AR/ })).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(/compactItemSelected/);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'pin' }));
|
|
expect(card(2).className).toMatch(/compactItemSelected/);
|
|
expect(card(1).className).not.toMatch(/compactItemSelected/);
|
|
});
|
|
|
|
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% RWM -10 pts · 269 pupils');
|
|
expect(card(3)).toHaveTextContent('62 pupils');
|
|
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();
|
|
});
|