Files
school_compare/nextjs-app/__tests__/components/ResultsToolbar.test.tsx
T
2026-10-02 11:54:04 +01:00

169 lines
7.8 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: [],
school_type_groups: [{ value: 'council', label: 'State school: council-run' }],
};
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=council&local_authority=Wandsworth');
render(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: /More filters \(1\)/ })).toBeInTheDocument();
});
});
describe('the phone chips line', () => {
it('drops its "more this way" fade when nothing is left to scroll', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=primary');
render(<FilterBar filters={filters} />);
// jsdom lays nothing out, so the line reads as not overflowing at all.
const line = screen.getByRole('group', { name: 'Applied filters' }).parentElement!;
expect(line.className).toMatch(/controlsAtEnd/);
});
});
describe('the phone filter sheet', () => {
it('offers the results total', () => {
render(<HomeView initialSchools={results()} filters={filters} />);
fireEvent.click(screen.getByRole('button', { name: 'Filters' }));
expect(screen.getByRole('button', { name: 'Show 1 school' })).toBeInTheDocument();
});
});
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();
});
});