Independent review round 2 returned BLOCKED on two findings, both correct. - The helper's bound was dead code to the suite. searchLibraryBrowseItems had zero tests, so deleting its clamp OR its gate left the whole suite green — while the registry note claimed a caller "cannot skip the bound". That is the previous round's finding relocated, not removed. It now has the three tests its sibling searchLibraryPickerOptions already had (clamp, gate, compile), plus one pinning the full-row return that is its reason to exist. - The screen kept a second copy of the min-query check, and the two masked each other: the 1-character boundary test passed with EITHER gate alone, so it pinned nothing. The screen's copy is deleted; the helper is the sole gate. Measured before/after: with the duplicate present, removing the helper's gate left that test green; with it gone, the same removal reddens it. - §3b contradicted itself two lines apart — the parent still said "there is no truncation, so there is no truncation hint" above a sub-bullet mandating one. Reworded so a hint is permitted, required only where bulk selection makes the count actionable. Same correction to the 'search-bounded' definition. - A failed search left results/totalMatches stale, rendering a confident "Showing 75 of 60000 matches" beside the error banner. The catch clears them. - Results now carry a `Results for "<query>"` heading and the guidance is keyed to the settled query, not the live input, so rows are never shown without saying which search produced them. `selected` persists across queries (correct for a multi-select picker); the Add button's count keeps it discoverable. - The hint sums pre-filter totalCount against post-filter rows, which is only correct because every filterable kind is addable. MediaKindFilter is now derived from ADDABLE_TYPE_LIST, making that a compile error rather than prose. - aria-live on the hint; the #740 doc caveat no longer overstates the typeahead rule as a mandate this screen violates. Declined: the NaN pageSize edge (copied faithfully from the sibling helper) and the registry's same-identity substitution gap (already disclosed in that file). refs #740
204 lines
8.4 KiB
TypeScript
204 lines
8.4 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
getLibraryBrowseItems,
|
|
searchLibraryBrowseItems,
|
|
searchLibraryPickerOptions,
|
|
titleContainsQuery,
|
|
LIBRARY_PICKER_LUCENE_SPECIALS,
|
|
LIBRARY_PICKER_RESULTS
|
|
} from './libraryBrowse';
|
|
|
|
function jsonResponse(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
status
|
|
});
|
|
}
|
|
|
|
function browseUrl(fetchMock: ReturnType<typeof vi.spyOn>): URL {
|
|
return new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
|
|
}
|
|
|
|
describe('getLibraryBrowseItems', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('maps the paging/library/mediaType params into the query string', async () => {
|
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 }));
|
|
|
|
await getLibraryBrowseItems({
|
|
query: 'star',
|
|
libraryId: 5,
|
|
mediaType: 'TelevisionShow',
|
|
pageNum: 2,
|
|
pageSize: 25
|
|
});
|
|
|
|
const url = browseUrl(fetchMock);
|
|
expect(url.pathname).toBe('/api/v1/library/browse');
|
|
expect(url.searchParams.get('query')).toBe('star');
|
|
expect(url.searchParams.get('libraryId')).toBe('5');
|
|
expect(url.searchParams.get('mediaType')).toBe('TelevisionShow');
|
|
expect(url.searchParams.get('pageNum')).toBe('2');
|
|
expect(url.searchParams.get('pageSize')).toBe('25');
|
|
expect(url.searchParams.has('parentId')).toBe(false);
|
|
});
|
|
|
|
it('sends parentId to scope seasons to a specific show', async () => {
|
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 }));
|
|
|
|
await getLibraryBrowseItems({ mediaType: 'TelevisionSeason', parentId: 42, pageSize: 100 });
|
|
|
|
const url = browseUrl(fetchMock);
|
|
expect(url.searchParams.get('mediaType')).toBe('TelevisionSeason');
|
|
expect(url.searchParams.get('parentId')).toBe('42');
|
|
});
|
|
|
|
it('omits parentId when it is not provided', async () => {
|
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 }));
|
|
|
|
await getLibraryBrowseItems({ mediaType: 'Movie' });
|
|
|
|
expect(browseUrl(fetchMock).searchParams.has('parentId')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('titleContainsQuery (#651 — compile typed text, never forward raw Lucene)', () => {
|
|
it('wraps the escaped text in boundary wildcards on the title field', () => {
|
|
expect(titleContainsQuery('Show Alpha')).toBe('title:*Show\\ Alpha*');
|
|
});
|
|
|
|
// The previous version of this test hand-copied a sample string and claimed to cover "every
|
|
// Lucene special" — it silently omitted `&` and `|`, and a completeness test that carries its own
|
|
// list of what to check cannot see what is missing from that list (#651 F2). Drive the assertion
|
|
// from the exported character set instead, one character at a time, so adding a character to the
|
|
// set without escaping it fails here.
|
|
it.each(LIBRARY_PICKER_LUCENE_SPECIALS.split(''))('escapes the Lucene special %j', (char) => {
|
|
expect(titleContainsQuery(`a${char}b`)).toBe(`title:*a\\${char}b*`);
|
|
});
|
|
|
|
it.each([' ', '\t', '\n'])('escapes whitespace %j so it cannot split the term', (char) => {
|
|
expect(titleContainsQuery(`a${char}b`)).toBe(`title:*a\\${char}b*`);
|
|
});
|
|
|
|
it('leaves every character that is NOT special untouched', () => {
|
|
const plain = 'abcXYZ019_,.\'@#$%';
|
|
for (const char of plain) {
|
|
expect(LIBRARY_PICKER_LUCENE_SPECIALS).not.toContain(char);
|
|
}
|
|
expect(titleContainsQuery(plain)).toBe(`title:*${plain}*`);
|
|
});
|
|
|
|
it('neutralises the && and || BOOLEAN operators, not just single characters (#651 F2)', () => {
|
|
// The regression: `Rock && Roll` used to compile with `&&` live, so Lucene parsed it as boolean
|
|
// syntax (or rejected the query) and an exactly-matching title returned nothing.
|
|
expect(titleContainsQuery('Rock && Roll')).toBe('title:*Rock\\ \\&\\&\\ Roll*');
|
|
expect(titleContainsQuery('A || B')).toBe('title:*A\\ \\|\\|\\ B*');
|
|
expect(titleContainsQuery('Rock & Roll')).toBe('title:*Rock\\ \\&\\ Roll*');
|
|
});
|
|
|
|
it('leaves a plain single word alone apart from the boundary stars', () => {
|
|
expect(titleContainsQuery('Alpha')).toBe('title:*Alpha*');
|
|
});
|
|
});
|
|
|
|
describe('searchLibraryPickerOptions (#651)', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('issues ONE bounded request with the compiled query and maps to {id, name}', async () => {
|
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(
|
|
jsonResponse({
|
|
page: [
|
|
{ id: 1, mediaItemId: 7, mediaType: 'Movie', title: 'Show Alpha' },
|
|
{ id: 2, mediaItemId: null, mediaType: 'Movie', title: null }
|
|
],
|
|
totalCount: 20000
|
|
})
|
|
);
|
|
|
|
const options = await searchLibraryPickerOptions('Movie', ' Show Alpha ');
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const url = browseUrl(fetchMock);
|
|
expect(url.searchParams.get('query')).toBe('title:*Show\\ Alpha*');
|
|
expect(url.searchParams.get('mediaType')).toBe('Movie');
|
|
expect(url.searchParams.get('pageNum')).toBe('0');
|
|
expect(url.searchParams.get('pageSize')).toBe(String(LIBRARY_PICKER_RESULTS));
|
|
// `mediaItemId` wins when present; `id` is the fallback, and a missing title degrades to `#id`.
|
|
expect(options).toEqual([
|
|
{ id: 7, name: 'Show Alpha' },
|
|
{ id: 2, name: '#2' }
|
|
]);
|
|
});
|
|
|
|
it('#651 F4: CLAMPS an oversized pageSize rather than forwarding it', async () => {
|
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 20000 }));
|
|
|
|
await searchLibraryPickerOptions('Episode', 'Alpha', 5000);
|
|
|
|
// The 25-row bound is a property of the helper, not of caller discipline.
|
|
expect(browseUrl(fetchMock).searchParams.get('pageSize')).toBe(String(LIBRARY_PICKER_RESULTS));
|
|
});
|
|
|
|
it('issues NO request for a query below the minimum length', async () => {
|
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 }));
|
|
|
|
expect(await searchLibraryPickerOptions('Episode', 'a')).toEqual([]);
|
|
expect(await searchLibraryPickerOptions('Episode', ' ')).toEqual([]);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('searchLibraryBrowseItems (#685 — AddItemsDialog sibling of searchLibraryPickerOptions)', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// The reviewer proved this helper was dead code to the suite: deleting its clamp, or deleting
|
|
// its gate, both left the whole suite green. These three tests mirror the ones above for
|
|
// searchLibraryPickerOptions so the same bound is pinned for the sibling helper.
|
|
|
|
it('#651 F4: CLAMPS an oversized pageSize rather than forwarding it', async () => {
|
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 20000 }));
|
|
|
|
await searchLibraryBrowseItems('Episode', 'Alpha', 5000);
|
|
|
|
expect(browseUrl(fetchMock).searchParams.get('pageSize')).toBe(String(LIBRARY_PICKER_RESULTS));
|
|
});
|
|
|
|
it('issues NO request for a query below the minimum length, and resolves an empty result', async () => {
|
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 }));
|
|
|
|
expect(await searchLibraryBrowseItems('Episode', 'a')).toEqual({ items: [], totalCount: 0 });
|
|
expect(await searchLibraryBrowseItems('Episode', ' ')).toEqual({ items: [], totalCount: 0 });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('compiles/escapes the trimmed query, and returns the FULL row (mediaType present) plus totalCount', async () => {
|
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(
|
|
jsonResponse({
|
|
page: [{ id: 1, mediaItemId: 7, mediaType: 'Movie', title: 'Show Alpha' }],
|
|
totalCount: 42
|
|
})
|
|
);
|
|
|
|
const result = await searchLibraryBrowseItems('Movie', ' Show Alpha ');
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const url = browseUrl(fetchMock);
|
|
expect(url.searchParams.get('query')).toBe(titleContainsQuery('Show Alpha'));
|
|
expect(url.searchParams.get('mediaType')).toBe('Movie');
|
|
expect(url.searchParams.get('pageNum')).toBe('0');
|
|
expect(url.searchParams.get('pageSize')).toBe(String(LIBRARY_PICKER_RESULTS));
|
|
// The reason this helper exists rather than reusing searchLibraryPickerOptions: the full row
|
|
// (mediaType included), not the {id, name} shape.
|
|
expect(result).toEqual({
|
|
items: [{ id: 1, mediaItemId: 7, mediaType: 'Movie', title: 'Show Alpha' }],
|
|
totalCount: 42
|
|
});
|
|
});
|
|
});
|