Cold cross-family review of 57aefcdf. Six findings, all web-only. F1 (HIGH, data-loss shaped): RerunCollectionsController.ProjectToResponseModel derives BOTH selectedId and selectedName from the same eager-loaded navigation, and GetRerunCollectionByIdHandler loads media metadata only for Show/Season/Artist/Movie while MediaCollections/Mapper maps RemoteStream through `_ => null`. So opening a RemoteStream rerun collection returned HTTP 200 with a null selection and the edit-load refresh CLEARED a stored id, leaving Save permanently disabled. The refresh now merges instead of replacing, so no path can clear an id it merely failed to name; the label degrades to `#id`. Covered per affected type — RemoteStream, Episode, MusicVideo, Song, OtherVideo, Image — plus a re-save assertion. The read-model gaps themselves are server-side and are NOT touched here. F2: `&` and `|` were missing from the escaped set, so `Rock && Roll` compiled with the boolean operator live. Pre-existing in Auto-Tune's original helper, but propagated to three more pickers — and now fixed for Auto-Tune too, since the helper is shared. The test that claimed to cover "every Lucene special" carried its own hand-copied sample and could not see its own omissions; it is now driven per-character off an exported LIBRARY_PICKER_LUCENE_SPECIALS. F3: a slow edit-load name resolution could relabel a newer selection. The label is now keyed to the id it was resolved for AND refuses to overwrite a label naming a different id — keying the render alone stops the mislabelling but discards the correct new label. F4: searchLibraryPickerOptions clamps pageSize instead of merely defaulting it. A bound a caller can exceed is not a bound. F6: replacing a native <select> with an input+listbox dropped keyboard operability. Full ARIA combobox pattern added — role/aria-expanded/aria-controls/aria-autocomplete, Arrow/Home/End over aria-activedescendant, Enter to commit, Escape to dismiss, options as non-tab-stops, cursor reset on each new result set. F7: both is-mounted tests were unsound. React 19 no longer warns on setState-after-unmount and an unmounted tree renders nothing either way, so the DOM assertion could not fail; the hook re-arm test used rerender rather than an effect cleanup. Now: a hook-module mock proving SearchPicker actually reads the guard and sees false, and a StrictMode double-invoke for the re-arm. Both verified by removing the mechanism and watching them fail. Same for the LCG divisor, which now has a direct boundary test. F5 (FillerPresets collection-family names) is filed as #670, not fixed here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
153 lines
6.1 KiB
TypeScript
153 lines
6.1 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
getLibraryBrowseItems,
|
|
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();
|
|
});
|
|
});
|