The three `getLibraryBrowseItems` pickers (RerunCollectionsScreen, PlaylistsScreen, FillerPresetsScreen) populated a native <select> from a 100-row window over media-library tables that can hold tens of thousands of rows. #644 made that truncation visible; it did not make the picker usable, and paging to completeness would have been worse than the bug (~200 serial requests, each more expensive than the last). They now resolve by SEARCH through the shared `SearchPicker` over a new `searchLibraryPickerOptions` helper: zero requests on mount or on a type switch, at most ONE bounded request (25 rows) per settled query, nothing below 2 characters. Typed text is compiled via the now-shared `titleContainsQuery` (`title:*<escaped>*`) rather than forwarded raw, since the index's default field does not match bare title words. The current selection renders from the owning record — `selectedName` for rerun collections and playlist items, and for filler presets (which store only an id) a single by-id detail read — so editing an existing record can never lose or fail to name its selection. Class A stays put: bounded-by-construction admin lists still page to completeness via `loadAllPages`, and the collection-family filler-preset types keep their bounded single page (their `query` is a SQL LIKE, which a compiled Lucene query would not match). No server-side cap is raised; this is a web-only change. Folded in from #578: the rule-builder facet typeahead arms on focus rather than on mount (an N-rule tree fired N unrequested lookups), both typeaheads pair their `seqRef` guard with a shared `useIsMountedRef`, and the roundtrip test's LCG divides by 2^32 so `pick()` can no longer index one past the end. Decision record `spa.list-completeness-vs-bounded-pickers` is archived as superseded by the new `spa.library-pickers-resolve-by-search`; spa-conventions §3b rewritten to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
199 lines
8.2 KiB
TypeScript
199 lines
8.2 KiB
TypeScript
import { useState } from 'react';
|
|
import { cleanup, render, screen, fireEvent, act } from '@testing-library/react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { RuleBuilder } from './RuleBuilder';
|
|
import type { RuleField } from './fieldCatalog';
|
|
import { MAX_GROUP_DEPTH, type Group } from './types';
|
|
|
|
const getSearchFieldValues = vi.fn().mockResolvedValue([]);
|
|
vi.mock('../../api/search', () => ({
|
|
getSearchFieldValues: (...args: unknown[]) => getSearchFieldValues(...args)
|
|
}));
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.useRealTimers();
|
|
getSearchFieldValues.mockReset();
|
|
getSearchFieldValues.mockResolvedValue([]);
|
|
});
|
|
|
|
const FIELDS: RuleField[] = [
|
|
{ name: 'genre', label: 'Genre', type: 'text', group: 'General', values: [] },
|
|
{ name: 'type', label: 'Item type', type: 'enum', group: 'General', values: ['movie', 'episode'] },
|
|
{ name: 'added', label: 'Added', type: 'date', group: 'General', values: [] },
|
|
{ name: 'minutes', label: 'Duration', type: 'number', group: 'Technical', values: [] }
|
|
];
|
|
|
|
function setup(initial: Group) {
|
|
const onChange = vi.fn();
|
|
const utils = render(<RuleBuilder value={initial} onChange={onChange} fields={FIELDS} />);
|
|
return { onChange, ...utils };
|
|
}
|
|
|
|
// RuleBuilder is fully controlled, so exercising a real onChange -> re-render round trip (e.g.
|
|
// an operator switch that changes which inputs are shown) needs a stateful harness rather than a
|
|
// static value + spy.
|
|
function setupControlled(initial: Group) {
|
|
function Harness() {
|
|
const [group, setGroup] = useState(initial);
|
|
return <RuleBuilder value={group} onChange={setGroup} fields={FIELDS} />;
|
|
}
|
|
return render(<Harness />);
|
|
}
|
|
|
|
describe('RuleBuilder', () => {
|
|
it('adds a rule', () => {
|
|
const { onChange } = setup({ match: 'all', children: [] });
|
|
fireEvent.click(screen.getByText('Add rule'));
|
|
expect(onChange).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
match: 'all',
|
|
children: expect.arrayContaining([expect.objectContaining({ field: 'genre' })])
|
|
})
|
|
);
|
|
});
|
|
|
|
it('removes a rule', () => {
|
|
const { onChange } = setup({ match: 'all', children: [{ field: 'genre', operator: 'is', value: 'Horror' }] });
|
|
fireEvent.click(screen.getByLabelText('Remove rule'));
|
|
expect(onChange).toHaveBeenCalledWith({ match: 'all', children: [] });
|
|
});
|
|
|
|
it('adds a nested group', () => {
|
|
const { onChange } = setup({ match: 'all', children: [] });
|
|
fireEvent.click(screen.getByText('Add group'));
|
|
// The new nested group has a single child, so the builder's onChange path normalizes its
|
|
// `match` to 'all' (normalizeGroup) — a 1-child group's connective is moot either way.
|
|
expect(onChange).toHaveBeenCalledWith(
|
|
expect.objectContaining({ children: expect.arrayContaining([expect.objectContaining({ match: 'all' })]) })
|
|
);
|
|
});
|
|
|
|
// #436: nesting is no longer capped at one level — every group up to MAX_GROUP_DEPTH offers
|
|
// "Add group", and only a group sitting at the cap stops offering it.
|
|
it('offers "Add group" at every level up to MAX_GROUP_DEPTH, and not beyond', () => {
|
|
const rule = { field: 'genre', operator: 'is' as const, value: 'Horror' };
|
|
const nest = (levels: number): Group => {
|
|
let g: Group = { match: 'all', children: [rule] };
|
|
for (let i = 0; i < levels; i++) g = { match: 'all', children: [g] };
|
|
return g;
|
|
};
|
|
|
|
// A tree whose deepest group is one level shy of the cap: every group (root + each sub-group)
|
|
// still offers "Add group".
|
|
setup(nest(MAX_GROUP_DEPTH - 1));
|
|
expect(screen.getAllByText('Add group')).toHaveLength(MAX_GROUP_DEPTH);
|
|
cleanup();
|
|
|
|
// At the cap, the deepest group drops the affordance; its ancestors keep it.
|
|
setup(nest(MAX_GROUP_DEPTH));
|
|
expect(screen.getAllByText('Add group')).toHaveLength(MAX_GROUP_DEPTH);
|
|
});
|
|
|
|
it('adds a sub-group from inside a nested group', () => {
|
|
const { onChange } = setup({
|
|
match: 'all',
|
|
children: [{ field: 'genre', operator: 'is', value: 'Horror' }, { match: 'all', children: [] }]
|
|
});
|
|
// A group's own button row renders after its children, so the nested group's "Add group"
|
|
// comes first in DOM order and the root's second.
|
|
fireEvent.click(screen.getAllByText('Add group')[0]);
|
|
expect(onChange).toHaveBeenCalledWith({
|
|
match: 'all',
|
|
children: [
|
|
{ field: 'genre', operator: 'is', value: 'Horror' },
|
|
{ match: 'all', children: [{ match: 'all', children: [{ field: 'genre', operator: 'is', value: '' }] }] }
|
|
]
|
|
});
|
|
});
|
|
|
|
it('shows enum values as a dropdown', () => {
|
|
setup({ match: 'all', children: [{ field: 'type', operator: 'is', value: 'movie' }] });
|
|
expect(screen.getByRole('option', { name: 'episode' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('resets operator and drops value/value2 when the field type changes', () => {
|
|
const onChange = vi.fn();
|
|
const fieldsWithNumber = [
|
|
{ name: 'genre', label: 'Genre', type: 'text' as const, group: 'General', values: [] },
|
|
{ name: 'minutes', label: 'Duration', type: 'number' as const, group: 'Technical', values: [] }
|
|
];
|
|
render(
|
|
<RuleBuilder
|
|
value={{ match: 'all', children: [{ field: 'minutes', operator: 'between', value: '10', value2: '90' }] }}
|
|
onChange={onChange}
|
|
fields={fieldsWithNumber}
|
|
/>
|
|
);
|
|
fireEvent.change(screen.getByLabelText('Field'), { target: { value: 'genre' } });
|
|
expect(onChange).toHaveBeenCalledWith({
|
|
match: 'all',
|
|
children: [{ field: 'genre', operator: 'is', value: '' }]
|
|
});
|
|
});
|
|
|
|
it('shows a number and unit input when a relative-date operator is selected', () => {
|
|
setupControlled({ match: 'all', children: [{ field: 'added', operator: 'before', value: '' }] });
|
|
fireEvent.change(screen.getByLabelText('Operator'), { target: { value: 'inLast' } });
|
|
expect(screen.getByLabelText('Value')).toHaveAttribute('type', 'number');
|
|
expect(screen.getByLabelText('Unit')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows the error message for an incomplete between rule', () => {
|
|
setup({ match: 'all', children: [{ field: 'minutes', operator: 'between', value: '10', value2: '' }] });
|
|
expect(screen.getByText('Both bounds are required.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('hides the any/all toggle for a single-child group', () => {
|
|
setup({ match: 'all', children: [{ field: 'genre', operator: 'is', value: 'Horror' }] });
|
|
expect(screen.queryByLabelText('Match')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('offers facet-value suggestions for a text field and still accepts free text (#434)', async () => {
|
|
vi.useFakeTimers();
|
|
getSearchFieldValues.mockResolvedValue(['Action', 'Adventure']);
|
|
|
|
const { container } = setupControlled({ match: 'all', children: [{ field: 'genre', operator: 'is', value: '' }] });
|
|
|
|
const input = screen.getByLabelText('Value') as HTMLInputElement;
|
|
|
|
// #578: nothing is fetched until the row is focused — mounting N text rules must not fire N
|
|
// requests nobody asked for.
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(400);
|
|
});
|
|
expect(getSearchFieldValues).not.toHaveBeenCalled();
|
|
|
|
fireEvent.focus(input);
|
|
fireEvent.change(input, { target: { value: 'Ac' } });
|
|
expect(input.value).toBe('Ac');
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(200);
|
|
});
|
|
|
|
expect(getSearchFieldValues).toHaveBeenCalledWith('genre', 'Ac');
|
|
const options = Array.from(container.querySelectorAll('datalist option')).map((o) => (o as HTMLOptionElement).value);
|
|
expect(options).toEqual(['Action', 'Adventure']);
|
|
|
|
// Selecting a suggestion (a native datalist selection fires the same input/change event a keystroke would).
|
|
fireEvent.change(input, { target: { value: 'Adventure' } });
|
|
expect(input.value).toBe('Adventure');
|
|
|
|
// A free-typed value that isn't in the suggestion list is still retained (free-text fallback).
|
|
fireEvent.change(input, { target: { value: 'Something else entirely' } });
|
|
expect(input.value).toBe('Something else entirely');
|
|
});
|
|
|
|
it('shows an all-negative warning for the root group', () => {
|
|
setup({
|
|
match: 'all',
|
|
children: [
|
|
{ field: 'genre', operator: 'isNot', value: 'Horror' },
|
|
{ field: 'genre', operator: 'isNot', value: 'Comedy' }
|
|
]
|
|
});
|
|
expect(screen.getByText(/entirely negative/)).toBeInTheDocument();
|
|
});
|
|
});
|