Files
ersatztv/web/src/builder/rules/RuleBuilder.test.tsx
T
timothyandClaude Opus 5 fad6805b91 feat(651): media-library pickers resolve by search instead of a bounded window
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>
2026-07-27 04:25:14 +02:00

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();
});
});