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>
158 lines
7.3 KiB
TypeScript
158 lines
7.3 KiB
TypeScript
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
import { HomeView } from '@/components/HomeView';
|
|
import { FilterBar } from '@/components/FilterBar';
|
|
import { fetchSchools } from '@/lib/api';
|
|
import { track } from '@/lib/analytics';
|
|
import { primaryFixture } from '../support/schoolFixtures';
|
|
import type { SchoolsResponse } from '@/lib/types';
|
|
|
|
/*
|
|
* The results toolbar (option B of the 2026-09-30 results-controls mockups):
|
|
* search, filters and the List/Map switch pinned under the header, with a
|
|
* floating List/Map button standing in for the switch on phones. Layout is CSS
|
|
* and not visible to jsdom; these pin the behaviour and the accessible names
|
|
* the E2E journeys rely on.
|
|
*/
|
|
|
|
let params = new URLSearchParams('postcode=SW196AR&radius=1');
|
|
const push = jest.fn();
|
|
jest.mock('next/navigation', () => ({
|
|
useSearchParams: () => params,
|
|
usePathname: () => '/',
|
|
useRouter: () => ({ push, 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(async () => ({})),
|
|
fetchLAaverages: jest.fn(async () => ({ secondary: { attainment_8_by_la: {} } })),
|
|
}));
|
|
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
|
|
jest.mock('@/components/SchoolMap', () => ({ SchoolMap: () => <div data-testid="map" /> }));
|
|
|
|
const filters = {
|
|
local_authorities: ['Wandsworth'], school_types: ['Community school'], years: [],
|
|
phases: ['Primary', 'Secondary'], genders: [], admissions_policies: [],
|
|
};
|
|
|
|
function results(): SchoolsResponse {
|
|
return { schools: [{ ...primaryFixture.schoolInfo, school_name: 'Southmead Primary School' }],
|
|
total: 1, page: 1, page_size: 25, total_pages: 1 };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
params = new URLSearchParams('postcode=SW196AR&radius=1');
|
|
push.mockClear();
|
|
jest.mocked(track).mockClear();
|
|
jest.mocked(fetchSchools).mockReset().mockResolvedValue(results());
|
|
});
|
|
|
|
describe('the List/Map switch', () => {
|
|
it('lives in the toolbar with the filters and says which view is on', () => {
|
|
render(<HomeView initialSchools={results()} filters={filters} />);
|
|
const view = screen.getByRole('group', { name: 'Results view' });
|
|
expect(view.closest('div[class*="resultsToolbar"]')).not.toBeNull();
|
|
// A postcode search opens on the map.
|
|
expect(screen.getByRole('button', { name: 'Map' })).toHaveAttribute('aria-pressed', 'true');
|
|
expect(screen.getByRole('button', { name: 'List' })).toHaveAttribute('aria-pressed', 'false');
|
|
});
|
|
|
|
it('has a floating twin that flips between map and list', async () => {
|
|
render(<HomeView initialSchools={results()} filters={filters} />);
|
|
await act(async () => {});
|
|
fireEvent.click(screen.getByRole('button', { name: 'Show list' }));
|
|
expect(screen.queryByTestId('map')).not.toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'List' })).toHaveAttribute('aria-pressed', 'true');
|
|
expect(track).toHaveBeenCalledWith('results_view_changed', { view: 'list', via: 'floating' });
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Show map' })));
|
|
expect(screen.getByTestId('map')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Show list' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not track a click on the view already showing', async () => {
|
|
render(<HomeView initialSchools={results()} filters={filters} />);
|
|
await act(async () => {});
|
|
fireEvent.click(screen.getByRole('button', { name: 'Map' }));
|
|
expect(track).not.toHaveBeenCalledWith('results_view_changed', expect.anything());
|
|
});
|
|
|
|
it('is absent from a name search, which has no map', () => {
|
|
params = new URLSearchParams('search=southmead');
|
|
render(<HomeView initialSchools={results()} filters={filters} />);
|
|
expect(screen.queryByRole('group', { name: 'Results view' })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: 'Show map' })).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('the toolbar filters', () => {
|
|
it('keeps distance, phase and school type in the row, not behind More filters', () => {
|
|
render(<FilterBar filters={filters} />);
|
|
const row = screen.getByRole('group', { name: 'Filters' });
|
|
for (const name of ['Distance', 'Phase', 'School type']) {
|
|
expect(row).toContainElement(screen.getByRole('combobox', { name }));
|
|
}
|
|
expect(screen.getByRole('combobox', { name: 'Distance' })).toHaveDisplayValue('Within 1 mile');
|
|
expect(screen.queryByRole('combobox', { name: 'Local authority' })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('counts only what More filters hides', () => {
|
|
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=Community+school&local_authority=Wandsworth');
|
|
render(<FilterBar filters={filters} />);
|
|
expect(screen.getByRole('button', { name: /More filters \(1\)/ })).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('the phone filter row', () => {
|
|
it('drops its "more this way" fade when nothing is left to scroll', () => {
|
|
render(<FilterBar filters={filters} />);
|
|
// jsdom lays nothing out, so the row reads as not overflowing at all.
|
|
expect(screen.getByRole('group', { name: 'Filters' }).className).toMatch(/controlsAtEnd/);
|
|
});
|
|
});
|
|
|
|
describe('the folded search', () => {
|
|
it('summarises the search and unfolds on tap', () => {
|
|
render(<FilterBar filters={filters} />);
|
|
const summary = screen.getByRole('button', { name: 'Edit search: SW196AR, within 1 mile' });
|
|
fireEvent.click(summary);
|
|
expect(screen.queryByRole('button', { name: /Edit search/ })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('folds again once the edited search is submitted', () => {
|
|
render(<FilterBar filters={filters} />);
|
|
fireEvent.click(screen.getByRole('button', { name: /Edit search/ }));
|
|
const input = screen.getByRole('searchbox', { name: 'School name or postcode' });
|
|
fireEvent.change(input, { target: { value: 'SW19 1AA' } });
|
|
fireEvent.submit(input.closest('form')!);
|
|
expect(screen.getByRole('button', { name: /Edit search/ })).toBeInTheDocument();
|
|
});
|
|
|
|
it('refolds and shows the new text when the search changes some other way', () => {
|
|
const view = render(<FilterBar filters={filters} />);
|
|
fireEvent.click(screen.getByRole('button', { name: /Edit search/ }));
|
|
fireEvent.change(screen.getByRole('searchbox', { name: 'School name or postcode' }),
|
|
{ target: { value: 'half-typed' } });
|
|
|
|
// Back button: the URL changes under the component, nothing is submitted.
|
|
params = new URLSearchParams('postcode=SW170AA&radius=3');
|
|
view.rerender(<FilterBar filters={filters} />);
|
|
|
|
expect(screen.getByRole('button', { name: 'Edit search: SW170AA, within 3 miles' }))
|
|
.toBeInTheDocument();
|
|
expect(screen.getByRole('searchbox', { name: 'School name or postcode' }))
|
|
.toHaveValue('SW170AA');
|
|
});
|
|
|
|
it('never appears in the hero, or before anything has been searched', () => {
|
|
const { unmount } = render(<FilterBar filters={filters} isHero />);
|
|
expect(screen.queryByRole('button', { name: /Edit search/ })).not.toBeInTheDocument();
|
|
unmount();
|
|
params = new URLSearchParams('local_authority=Wandsworth');
|
|
render(<FilterBar filters={filters} />);
|
|
expect(screen.queryByRole('button', { name: /Edit search/ })).not.toBeInTheDocument();
|
|
});
|
|
});
|