Files
minstrel/web/src/lib/components/SearchInput.test.ts
T
bvandeusen 251d165e0b feat(web): add SearchInput header component (debounced URL sync)
Pre-fills from page.url.searchParams.get('q'); typing debounces 250ms
then goto('/search?q=…'); first nav from elsewhere pushes, subsequent
typing on /search replaces. Empty input on /search drops the param.
Escape clears the input. URL → value re-sync via \$effect uses
untrack() on the value comparison so it doesn't loop with typing.
2026-04-25 16:01:25 -04:00

95 lines
3.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
const state = vi.hoisted(() => ({
pageUrl: new URL('http://localhost/')
}));
vi.mock('$app/state', () => ({
page: { get url() { return state.pageUrl; } }
}));
vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
import SearchInput from './SearchInput.svelte';
import { goto } from '$app/navigation';
beforeEach(() => {
vi.useFakeTimers();
state.pageUrl = new URL('http://localhost/');
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
describe('SearchInput', () => {
test('mount pre-fills value from page.url.searchParams', () => {
state.pageUrl = new URL('http://localhost/search?q=hello');
render(SearchInput);
const input = screen.getByRole('searchbox') as HTMLInputElement;
expect(input.value).toBe('hello');
});
test('typing fires goto once after 250ms debounce', async () => {
render(SearchInput);
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'h' } });
expect(goto).not.toHaveBeenCalled();
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledTimes(1);
expect(goto).toHaveBeenCalledWith('/search?q=h', { replaceState: false });
});
test('rapid keystrokes debounce to a single goto', async () => {
render(SearchInput);
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'h' } });
vi.advanceTimersByTime(100);
await fireEvent.input(input, { target: { value: 'he' } });
vi.advanceTimersByTime(100);
await fireEvent.input(input, { target: { value: 'hel' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledTimes(1);
expect(goto).toHaveBeenCalledWith('/search?q=hel', { replaceState: false });
});
test('typing while on /search uses replaceState=true', async () => {
state.pageUrl = new URL('http://localhost/search?q=h');
render(SearchInput);
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'he' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledWith('/search?q=he', { replaceState: true });
});
test('clearing input on /search calls goto("/search") (drops ?q=)', async () => {
state.pageUrl = new URL('http://localhost/search?q=h');
render(SearchInput);
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: '' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledWith('/search', { replaceState: true });
});
test('Escape clears the input value', async () => {
render(SearchInput);
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'hello' } });
expect(input.value).toBe('hello');
await fireEvent.keyDown(input, { key: 'Escape' });
expect(input.value).toBe('');
});
test('encodeURIComponent applied to special chars', async () => {
render(SearchInput);
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'a b&c' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledWith('/search?q=a%20b%26c', { replaceState: false });
});
});