Files
movieloop-frontend/src/components/game/SearchAutocomplete.test.tsx
T
TehRiehlDeal 8d0b7f39ee feat(ui): give the app one visual identity as Movie Loop
Gold is the primary in both themes, Bricolage Grotesque carries the
wordmark, titles and the score, and Movie A / Movie B wear the blue and
magenta of three-strip Technicolor on their posters instead of colouring
whole rows. The loop closes on screen - connectors light, a gold line
draws back to Movie A, the row glows, then the result arrives - in place
of confetti, on every play mode, scaled to the chain's length. Every
page shares one header and hairline lists; copy is sentence case with
one verb per flow, and the Home hero shows a real finished loop.

Also clamps link counts at zero: an opponent who had not moved yet
showed "-1 links".
2026-09-12 22:42:55 -07:00

561 lines
19 KiB
TypeScript

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TmdbMovieResult, TmdbPersonResult } from '@/types';
/**
* The ARIA 1.2 combobox that drives every "who was in..." / "what movie was
* ... in" search step. Requests are intercepted at the axios client and
* routed by URL - the same seam game-store.test.ts uses - so nothing reaches
* the network; searchMovies/searchPersons run for real against the mock.
*
* Real timers throughout: combining `vi.useFakeTimers()` with
* `@testing-library/user-event` deadlocks every `user.type`/`user.click` call
* in this project's toolchain (vitest 5.0.0 + user-event 14.6.7 + jsdom 30) -
* reproduced even for a bare `<input>` with no debounce involved, regardless
* of `delay: null` or an `advanceTimers` callback. `waitFor`/`findBy*` (which
* poll on real timers) stand in for fake-timer control of the 300ms debounce.
*/
const searchPersonsApi = vi.fn();
const searchMoviesApi = vi.fn();
type Handler = (params: Record<string, unknown> | undefined) => Promise<unknown>;
function route(method: string, url: string): Handler {
if (method === 'get' && url === '/persons/search') {
return (params) => searchPersonsApi((params as { q: string }).q);
}
if (method === 'get' && url === '/movies/search') {
return (params) => searchMoviesApi((params as { q: string }).q);
}
throw new Error(`test reached an unrouted request: ${method} ${url}`);
}
vi.mock('@/api/client', () => {
const call =
(method: string) =>
async (
url: string,
config?: { params?: Record<string, unknown> },
): Promise<{ data: unknown }> => ({
data: await route(method, url)(config?.params),
});
return {
default: {
get: call('get'),
post: call('post'),
put: call('put'),
delete: call('delete'),
},
};
});
const { default: SearchAutocomplete } = await import('./SearchAutocomplete');
const person = (id: number, name = `Person ${id}`): TmdbPersonResult => ({
id,
name,
profile_path: null,
popularity: 5,
known_for_department: 'Acting',
known_for: [],
adult: false,
});
const tmdbMovie = (id: number, title = `Movie ${id}`): TmdbMovieResult => ({
id,
title,
original_title: title,
overview: '',
poster_path: null,
backdrop_path: null,
release_date: '2000-01-01',
popularity: 5,
vote_average: 7,
vote_count: 10,
genre_ids: [],
adult: false,
});
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/** Comfortably past the component's 300ms debounce. */
const pastDebounce = () => wait(400);
beforeEach(() => {
vi.clearAllMocks();
searchPersonsApi.mockResolvedValue({ page: 1, results: [], total_pages: 1, total_results: 0 });
searchMoviesApi.mockResolvedValue({ page: 1, results: [], total_pages: 1, total_results: 0 });
});
describe('SearchAutocomplete', () => {
it('does not search below the two-character threshold', async () => {
const user = userEvent.setup();
const onSelectActor = vi.fn();
render(
<SearchAutocomplete mode="actor" onSelectActor={onSelectActor} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'a');
await pastDebounce();
expect(searchPersonsApi).not.toHaveBeenCalled();
expect(screen.queryByRole('listbox')).toBeNull();
}, 10000);
it('debounces before searching: not called right after typing, called once settled', async () => {
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'ke');
expect(searchPersonsApi).not.toHaveBeenCalled();
await waitFor(() => expect(searchPersonsApi).toHaveBeenCalledWith('ke'));
}, 10000);
it('opens a labelled listbox of actor results, wired to the combobox', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'Keanu Reeves'), person(2, 'Carrie-Anne Moss')],
total_pages: 1,
total_results: 2,
});
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
const input = screen.getByRole('combobox', { name: 'Search for an actor' });
await user.type(input, 'ke');
const listbox = await screen.findByRole('listbox', { name: 'Actor results' });
expect(input).toHaveAttribute('aria-expanded', 'true');
expect(input).toHaveAttribute('aria-controls', listbox.id);
expect(screen.getByText('Keanu Reeves')).toBeInTheDocument();
expect(screen.getByText('Carrie-Anne Moss')).toBeInTheDocument();
}, 10000);
it('filters out results that are not in the Acting department', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [
person(1, 'An Actor'),
{ ...person(2, 'A Director'), known_for_department: 'Directing' },
],
total_pages: 1,
total_results: 2,
});
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'an');
await screen.findByText('An Actor');
expect(screen.queryByText('A Director')).toBeNull();
}, 10000);
it('searches movies in movie mode', async () => {
searchMoviesApi.mockResolvedValue({
page: 1,
results: [tmdbMovie(1, 'The Matrix')],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
render(
<SearchAutocomplete mode="movie" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox', { name: 'Search for a movie' }), 'ma');
await waitFor(() => expect(searchMoviesApi).toHaveBeenCalledWith('ma'));
expect(searchPersonsApi).not.toHaveBeenCalled();
expect(
screen.getByRole('listbox', { name: 'Movie results' }),
).toBeInTheDocument();
expect(screen.getByText('The Matrix')).toBeInTheDocument();
}, 10000);
it('shows a no-match message when a search comes back empty', async () => {
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'zz');
await screen.findByText('No matches. Try another spelling.');
}, 10000);
it('shows an error option when the search request fails', async () => {
searchPersonsApi.mockRejectedValue(new Error('network down'));
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'ke');
await screen.findByText('Search failed. Please try again.');
}, 10000);
it('shows a loading indicator while the request is in flight, then hides it', async () => {
let resolveSearch: (v: unknown) => void = () => undefined;
searchPersonsApi.mockImplementation(
() => new Promise((resolve) => (resolveSearch = resolve)),
);
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'ke');
await screen.findByLabelText('Searching…');
resolveSearch({ page: 1, results: [], total_pages: 1, total_results: 0 });
await waitFor(() => expect(screen.queryByLabelText('Searching…')).toBeNull());
}, 10000);
it('ignores a stale response that resolves after a newer search has started', async () => {
let resolveFirst: (v: unknown) => void = () => undefined;
let resolveSecond: (v: unknown) => void = () => undefined;
searchPersonsApi
.mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve)))
.mockImplementationOnce(() => new Promise((resolve) => (resolveSecond = resolve)));
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
const input = screen.getByRole('combobox');
await user.type(input, 'ab');
await waitFor(() => expect(searchPersonsApi).toHaveBeenCalledTimes(1));
await user.type(input, 'c');
await waitFor(() => expect(searchPersonsApi).toHaveBeenCalledTimes(2));
// The newer ("abc") request settles first.
resolveSecond({
page: 1,
results: [person(2, 'Current Result')],
total_pages: 1,
total_results: 1,
});
await screen.findByText('Current Result');
// The stale ("ab") request settles after: its cleanup already ran, so it
// must not clobber the current results.
resolveFirst({
page: 1,
results: [person(1, 'Stale Result')],
total_pages: 1,
total_results: 1,
});
await wait(50);
expect(screen.getByText('Current Result')).toBeInTheDocument();
expect(screen.queryByText('Stale Result')).toBeNull();
}, 10000);
it('navigates results with ArrowDown/ArrowUp, wrapping at each end', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'First'), person(2, 'Second')],
total_pages: 1,
total_results: 2,
});
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
const input = screen.getByRole('combobox');
await user.type(input, 'ke');
const listbox = await screen.findByRole('listbox');
expect(input).not.toHaveAttribute('aria-activedescendant');
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-activedescendant', `${listbox.id}-option-0`);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-activedescendant', `${listbox.id}-option-1`);
// Wraps back to the first option.
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-activedescendant', `${listbox.id}-option-0`);
// Wraps to the last option going up from the first.
await user.keyboard('{ArrowUp}');
expect(input).toHaveAttribute('aria-activedescendant', `${listbox.id}-option-1`);
}, 10000);
it('selects the active option on Enter, clears the query and closes the list', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(7, 'Seven')],
total_pages: 1,
total_results: 1,
});
const onSelectActor = vi.fn();
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={onSelectActor} onSelectMovie={vi.fn()} />,
);
const input = screen.getByRole('combobox');
await user.type(input, 'se');
await screen.findByText('Seven');
await user.keyboard('{ArrowDown}{Enter}');
expect(onSelectActor).toHaveBeenCalledWith(expect.objectContaining({ id: 7, name: 'Seven' }));
expect(input).toHaveValue('');
expect(input).toHaveAttribute('aria-expanded', 'false');
}, 10000);
it('does nothing on Enter when no option is active', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(7, 'Seven')],
total_pages: 1,
total_results: 1,
});
const onSelectActor = vi.fn();
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={onSelectActor} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'se');
await screen.findByText('Seven');
await user.keyboard('{Enter}');
expect(onSelectActor).not.toHaveBeenCalled();
}, 10000);
it('selects an actor result by clicking it', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(3, 'Clicked Actor')],
total_pages: 1,
total_results: 1,
});
const onSelectActor = vi.fn();
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={onSelectActor} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'cl');
await screen.findByText('Clicked Actor');
await user.click(screen.getByRole('button', { name: /Clicked Actor/ }));
expect(onSelectActor).toHaveBeenCalledWith(
expect.objectContaining({ id: 3, name: 'Clicked Actor' }),
);
}, 10000);
it("shows an actor's known-for titles when TMDB reports any", async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [
{
...person(4, 'Prolific Actor'),
known_for: [tmdbMovie(1, 'Movie One'), tmdbMovie(2, 'Movie Two')],
},
],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'pr');
await screen.findByText('Prolific Actor');
expect(screen.getByText('Known for: Movie One, Movie Two')).toBeInTheDocument();
}, 10000);
it('selects the active movie option on Enter in movie mode', async () => {
searchMoviesApi.mockResolvedValue({
page: 1,
results: [tmdbMovie(9, 'Speed')],
total_pages: 1,
total_results: 1,
});
const onSelectMovie = vi.fn();
const user = userEvent.setup();
render(
<SearchAutocomplete mode="movie" onSelectActor={vi.fn()} onSelectMovie={onSelectMovie} />,
);
const input = screen.getByRole('combobox');
await user.type(input, 'sp');
await screen.findByText('Speed');
await user.keyboard('{ArrowDown}{Enter}');
expect(onSelectMovie).toHaveBeenCalledWith(
expect.objectContaining({ id: 9, title: 'Speed' }),
);
expect(input).toHaveValue('');
}, 10000);
it('selects a movie result by clicking it', async () => {
searchMoviesApi.mockResolvedValue({
page: 1,
results: [tmdbMovie(9, 'Speed')],
total_pages: 1,
total_results: 1,
});
const onSelectMovie = vi.fn();
const user = userEvent.setup();
render(
<SearchAutocomplete mode="movie" onSelectActor={vi.fn()} onSelectMovie={onSelectMovie} />,
);
await user.type(screen.getByRole('combobox'), 'sp');
await screen.findByText('Speed');
await user.click(screen.getByRole('button', { name: /Speed/ }));
expect(onSelectMovie).toHaveBeenCalledWith(expect.objectContaining({ id: 9, title: 'Speed' }));
}, 10000);
it('closes and resets the active option on Escape', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'First')],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
const input = screen.getByRole('combobox');
await user.type(input, 'fi');
await screen.findByRole('listbox');
await user.keyboard('{ArrowDown}');
await user.keyboard('{Escape}');
expect(screen.queryByRole('listbox')).toBeNull();
expect(input).not.toHaveAttribute('aria-activedescendant');
}, 10000);
it('closes the dropdown when clicking outside the component', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'First')],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
render(
<div>
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />
<button type="button">outside</button>
</div>,
);
await user.type(screen.getByRole('combobox'), 'fi');
await screen.findByRole('listbox');
await user.click(screen.getByRole('button', { name: 'outside' }));
expect(screen.queryByRole('listbox')).toBeNull();
}, 10000);
it('reopens the dropdown on focus when there are already results', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'First')],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
render(
<div>
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />
<button type="button">outside</button>
</div>,
);
const input = screen.getByRole('combobox');
await user.type(input, 'fi');
await screen.findByRole('listbox');
await user.click(screen.getByRole('button', { name: 'outside' }));
expect(screen.queryByRole('listbox')).toBeNull();
await user.click(input);
expect(screen.getByRole('listbox')).toBeInTheDocument();
}, 10000);
it('disables the input and forwards a custom placeholder', () => {
render(
<SearchAutocomplete
mode="actor"
onSelectActor={vi.fn()}
onSelectMovie={vi.fn()}
disabled
placeholder="Custom prompt"
/>,
);
const input = screen.getByRole('combobox');
expect(input).toBeDisabled();
expect(input).toHaveAttribute('placeholder', 'Custom prompt');
});
it('resets its query and results when the mode prop switches, and refocuses', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'First')],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
const { rerender } = render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
const actorInput = screen.getByRole('combobox');
await user.type(actorInput, 'fi');
await screen.findByRole('listbox');
expect(actorInput).toHaveValue('fi');
rerender(
<SearchAutocomplete mode="movie" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
const movieInput = screen.getByRole('combobox', { name: 'Search for a movie' });
expect(movieInput).toHaveValue('');
expect(screen.queryByRole('listbox')).toBeNull();
expect(movieInput).toHaveFocus();
}, 10000);
it('does not search the new mode with the previous query after a selection flips the mode', async () => {
// Seen live in WP-19's smoke run: pick an actor, the mode flips to movie,
// and 300ms of debounce still hold the actor's name - so a movie search
// for "Daniel Craig" ran and opened a listbox under an empty input.
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'Daniel Craig')],
total_pages: 1,
total_results: 1,
});
searchMoviesApi.mockResolvedValue({
page: 1,
results: [tmdbMovie(9, 'Daniel Craig - Permis de jouer')],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
const onSelectActor = vi.fn();
const { rerender } = render(
<SearchAutocomplete mode="actor" onSelectActor={onSelectActor} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'Daniel Craig');
await screen.findByText('Daniel Craig');
await user.click(screen.getByRole('button', { name: /Daniel Craig/ }));
expect(onSelectActor).toHaveBeenCalledTimes(1);
// The parent reacts to the selection by switching the mode, as the game does.
rerender(
<SearchAutocomplete mode="movie" onSelectActor={onSelectActor} onSelectMovie={vi.fn()} />,
);
await pastDebounce();
expect(searchMoviesApi).not.toHaveBeenCalled();
expect(screen.queryByRole('listbox')).toBeNull();
expect(screen.getByRole('combobox', { name: 'Search for a movie' })).toHaveValue('');
}, 10000);
});