From 28ce8c4dfe065f131e470741e60c5d06a020893b Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 5 Aug 2026 18:27:25 +0200 Subject: [PATCH 1/5] fix(685): gate AddItemsDialog on a real query instead of windowing the whole type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AddItemsDialog.runSearch was reachable with an empty query two ways — a blank form submit, and a kind-chip click, which called it immediately — and getLibraryBrowseItems omits a falsy `query`, so each path degraded into an unfiltered browse of the whole media-library type (first 50 rows, per kind) presented as the answer with nothing surfacing the truncation. All ten ADDABLE_TYPE_LIST entries are spa-conventions §3b Class B media-library types. The dialog is multi-select, so §3b's SearchPicker (single-select) does not fit; it takes §3b's constraints instead: - no request below LIBRARY_PICKER_MIN_QUERY, enforced in runSearch — the single sink both entry paths route through, not duplicated per caller - typed text compiled with titleContainsQuery rather than forwarded raw (a second latent §3b violation here: the search index's default field does not match bare title words) - each kind bounded to LIBRARY_PICKER_RESULTS - merged.slice(0, 50) removed — it silently dropped up to 100 of 150 fetched rows even for a real query Tests assert zero requests below the gate on both paths, exactly one bounded request per kind above it (20k-row fixture), the compiled+escaped query, and that no fetched row is dropped. Each was verified to fail with its mechanism removed. The pageSize registry entry moves from `deviation` to `search-bounded`. That leaves zero deviation entries, so the anti-vacuity assertion guarding that list is deleted deliberately, per its own instruction. Done-when box 4 (collection-family truncation hint) has no subject: this screen offers no collection-family type. fixes #685 --- docs/spa-conventions.md | 11 ++ web/src/api/pageSizeCallSites.guard.test.ts | 28 ++-- web/src/screens/CollectionsScreen.test.tsx | 167 +++++++++++++++++++- web/src/screens/CollectionsScreen.tsx | 35 +++- 4 files changed, 220 insertions(+), 21 deletions(-) diff --git a/docs/spa-conventions.md b/docs/spa-conventions.md index 63a26266f..62e29fab5 100644 --- a/docs/spa-conventions.md +++ b/docs/spa-conventions.md @@ -183,6 +183,17 @@ the #644 follow-up got Class A right and Class B only half right): is gone from these pickers along with the window it described. Prove the bound with a **request-count assertion against a large (20k-row) fixture**, not by inspection. + - **`SearchPicker` is the single-select SHAPE, not the rule itself.** A MULTI-select picker + (`CollectionsScreen`'s `AddItemsDialog` — checkbox rows, many items added at once, fanned out + over several kinds) cannot render `SearchPicker` and must not be forced to. It satisfies this + section by keeping its own result list and taking the *constraints* instead: gate on + `LIBRARY_PICKER_MIN_QUERY`, `titleContainsQuery` the typed text, `LIBRARY_PICKER_RESULTS` per + kind, and no post-fetch `slice` — a fetched-then-discarded row is a silent drop, and removing + the drop is the fix, not hinting at it. **Put the gate in the single search sink, not at each + caller**: `AddItemsDialog` has two entry paths (blank form submit, kind-chip click), and #685's + first fix duplicated the check at one of them, which measurably tested nothing — deleting the + duplicate reddened no test, while deleting the sink's gate reddened both paths. + - **Compile typed text; never forward raw Lucene.** Send `titleContainsQuery(text)` → `title:**`. The index's default field does not match bare title words (`Alpha` finds nothing for "Show Alpha" — see `e2e-local.md`), so a raw forward looks broken in a *name* picker. diff --git a/web/src/api/pageSizeCallSites.guard.test.ts b/web/src/api/pageSizeCallSites.guard.test.ts index a923d9126..27af1822f 100644 --- a/web/src/api/pageSizeCallSites.guard.test.ts +++ b/web/src/api/pageSizeCallSites.guard.test.ts @@ -249,18 +249,16 @@ const REGISTRY: RegistryEntry[] = [ { file: 'screens/CollectionsScreen.tsx', kind: 'literal', - value: '50', - classification: 'deviation', - issue: 685, + value: 'LIBRARY_PICKER_RESULTS', + classification: 'search-bounded', note: - 'TRACKED §3b VIOLATION — #685. AddItemsDialog.runSearch is reachable with an EMPTY query ' + - '(blank form submit, and a kind-chip click, which calls it immediately), and ' + - 'getLibraryBrowseItems omits a falsy query — so it degrades to an unfiltered 50-row window ' + - 'over the whole media-library type, per kind. Nothing surfaces it: totalCount is never read ' + - 'here and no hint renders, and merged.slice(0, 50) drops up to 100 of 150 fetched rows even ' + - 'for a real query. Under the cap, so #644 and #650 both missed it. NOT search-bounded (the ' + - 'query is not required) and NOT class-b (no hint) — labelling it either would make this ' + - 'registry vouch for behaviour that does not exist.' + 'AddItemsDialog.runSearch — fixed for #685. No request is issued below ' + + 'LIBRARY_PICKER_MIN_QUERY (a blank form submit and a kind-chip click below the gate both ' + + 'issue zero requests, and results are cleared instead), the typed text is compiled via ' + + 'titleContainsQuery rather than forwarded raw, and each kind is bounded to one ' + + 'getLibraryBrowseItems request per settled query at LIBRARY_PICKER_RESULTS rows. The old ' + + 'merged.slice(0, 50) post-fetch drop is gone: every merged row renders, so there is nothing ' + + 'left to hint at.' }, { file: 'screens/FillerPresetsScreen.tsx', @@ -535,9 +533,11 @@ describe('pageSize call-site guard (#650)', () => { it("every 'deviation' entry names the issue tracking it", () => { const deviations = REGISTRY.filter((entry) => entry.classification === 'deviation'); - // Anti-vacuity: if the deviations are ever all fixed, this must be deleted deliberately, not - // silently pass over an empty list while claiming to enforce something. - expect(deviations.length).toBeGreaterThan(0); + // No live 'deviation' entries as of #685 (the last one — CollectionsScreen.tsx's raw-50 window + // — was fixed and reclassified 'search-bounded' above). The anti-vacuity + // `expect(deviations.length).toBeGreaterThan(0)` this comment used to enforce is deleted + // DELIBERATELY here, per its own instruction, rather than left to silently pass over an empty + // list — re-add it the day a new 'deviation' entry is registered. for (const entry of deviations) { expect(entry.issue, `${entry.file}:${entry.value} is a deviation but names no tracking issue`).toEqual( diff --git a/web/src/screens/CollectionsScreen.test.tsx b/web/src/screens/CollectionsScreen.test.tsx index b238a82f8..7323dee09 100644 --- a/web/src/screens/CollectionsScreen.test.tsx +++ b/web/src/screens/CollectionsScreen.test.tsx @@ -1,5 +1,11 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + LIBRARY_PICKER_LUCENE_SPECIALS, + LIBRARY_PICKER_MIN_QUERY, + LIBRARY_PICKER_RESULTS, + titleContainsQuery +} from '../api'; import { CollectionsScreen } from './CollectionsScreen'; // The rule builder's field catalog normally comes from a live /api/v1/search/fields fetch; mock it @@ -401,7 +407,7 @@ describe('CollectionsScreen', () => { const dialog = await openAddItemsDialog(); fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), { - target: { value: 'a' } + target: { value: 'za' } }); fireEvent.click(within(dialog).getByRole('button', { name: 'Search' })); @@ -419,7 +425,7 @@ describe('CollectionsScreen', () => { const dialog = await openAddItemsDialog(); fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), { - target: { value: 'a' } + target: { value: 'za' } }); fireEvent.click(within(dialog).getByRole('button', { name: 'Seasons' })); @@ -451,7 +457,7 @@ describe('CollectionsScreen', () => { const dialog = await openAddItemsDialog(); fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), { - target: { value: 'a' } + target: { value: 'za' } }); fireEvent.click(within(dialog).getByRole('button', { name: 'Song' })); @@ -475,6 +481,161 @@ describe('CollectionsScreen', () => { }); }); + /* ---------- add-items search bounds (#685) ---------- */ + + function buildLibraryFixture(mediaType: string, count: number): { id: number; mediaItemId: number; mediaType: string; title: string }[] { + return Array.from({ length: count }, (_, index) => ({ + id: index + 1, + mediaItemId: index + 1, + mediaType, + title: `${mediaType} Item ${index + 1}` + })); + } + + // Simulates a library with `rowsPerKind` rows of EACH media type (20,000 by default — large + // enough that "the client bounds itself" is the only thing keeping this fast), honoring the + // requested pageSize the way the real server-side clamp does (spa-conventions §3b). + function mockLargeLibraryApi(rowsPerKind = 20000) { + const fixtures = new Map>(); + + return mockApi({ + onRequest: (url) => { + if (!url.startsWith('/api/v1/library/browse')) { + return null; + } + + const params = new URL(url, 'http://localhost').searchParams; + const mediaType = params.get('mediaType') ?? 'Movie'; + const pageSize = Number(params.get('pageSize') ?? '0'); + + if (!fixtures.has(mediaType)) { + fixtures.set(mediaType, buildLibraryFixture(mediaType, rowsPerKind)); + } + + const fixture = fixtures.get(mediaType) ?? []; + return jsonResponse({ page: fixture.slice(0, pageSize), totalCount: fixture.length }); + } + }); + } + + function browseCallsOf(fetchMock: ReturnType) { + return fetchMock.mock.calls.filter(([u]) => u.toString().startsWith('/api/v1/library/browse')); + } + + it('issues zero requests for a blank submit, and renders the min-query guidance', async () => { + const fetchMock = mockLargeLibraryApi(); + + const dialog = await openAddItemsDialog(); + + expect( + within(dialog).getByText(`Type at least ${LIBRARY_PICKER_MIN_QUERY} characters to search.`) + ).toBeInTheDocument(); + + fireEvent.click(within(dialog).getByRole('button', { name: 'Search' })); + + // No await-for-settle race: a blank submit never issues a fetch, so there is nothing async + // to wait on — assert immediately that the guidance is still the only thing shown. + expect( + within(dialog).getByText(`Type at least ${LIBRARY_PICKER_MIN_QUERY} characters to search.`) + ).toBeInTheDocument(); + expect(browseCallsOf(fetchMock).length).toBe(0); + }); + + it('a kind-chip click below the min-query gate issues zero requests', async () => { + const fetchMock = mockLargeLibraryApi(); + + const dialog = await openAddItemsDialog(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Song' })); + + expect( + within(dialog).getByText(`Type at least ${LIBRARY_PICKER_MIN_QUERY} characters to search.`) + ).toBeInTheDocument(); + expect(browseCallsOf(fetchMock).length).toBe(0); + }); + + it( + 'a valid query issues exactly one request per kind, bounded to LIBRARY_PICKER_RESULTS, with the compiled query', + { timeout: 15000 }, + async () => { + const fetchMock = mockLargeLibraryApi(); + + const dialog = await openAddItemsDialog(); + fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), { + target: { value: 'za' } + }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Search' })); + + await within(dialog).findByText('Movie Item 1'); + + const browseCalls = browseCallsOf(fetchMock); + // DEFAULT_SEARCH_KINDS fan-out: Movie, TelevisionShow, Artist — exactly one request each, + // never more. + expect(browseCalls.length).toBe(3); + + const kindsRequested = browseCalls.map( + ([u]) => new URL(u.toString(), 'http://localhost').searchParams.get('mediaType') + ); + expect(new Set(kindsRequested)).toEqual(new Set(['Movie', 'TelevisionShow', 'Artist'])); + + for (const [u] of browseCalls) { + const params = new URL(u.toString(), 'http://localhost').searchParams; + expect(params.get('pageSize')).toBe(String(LIBRARY_PICKER_RESULTS)); + expect(params.get('query')).toBe(titleContainsQuery('za')); + } + } + ); + + it( + 'compiles typed text and escapes a Lucene special character rather than forwarding it raw', + { timeout: 15000 }, + async () => { + const fetchMock = mockLargeLibraryApi(); + // Drive the escaping test from the exported character set, not a hand-copied sample — a + // sample can't see what it omits. + const special = LIBRARY_PICKER_LUCENE_SPECIALS[0]; + const typed = `ro${special}ck`; + + const dialog = await openAddItemsDialog(); + fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), { + target: { value: typed } + }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Search' })); + + await within(dialog).findByText('Movie Item 1'); + + const browseCall = browseCallsOf(fetchMock)[0]; + const params = new URL(String(browseCall?.[0]), 'http://localhost').searchParams; + const sentQuery = params.get('query'); + + expect(sentQuery).toBe(titleContainsQuery(typed)); + // A raw, unescaped forward would still contain the bare special character; the compiled + // form never does (it is backslash-escaped instead). + expect(sentQuery).not.toBe(`title:*${typed}*`); + expect(sentQuery).toContain(`\\${special}`); + } + ); + + it( + 'renders every merged row across all kinds — no post-fetch drop', + { timeout: 15000 }, + async () => { + mockLargeLibraryApi(LIBRARY_PICKER_RESULTS); + + const dialog = await openAddItemsDialog(); + fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), { + target: { value: 'za' } + }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Search' })); + + await within(dialog).findByText('Movie Item 1'); + + // Each of the 3 default-fan-out kinds returns a full LIBRARY_PICKER_RESULTS-row page; + // nothing fetched should be discarded, so all of them must render. + const titleEls = dialog.querySelectorAll('.ctv-collections-picker-row-title'); + expect(titleEls.length).toBe(3 * LIBRARY_PICKER_RESULTS); + } + ); + /* ---------- reorder mode (#211) ---------- */ function reorderPages(id: number) { diff --git a/web/src/screens/CollectionsScreen.tsx b/web/src/screens/CollectionsScreen.tsx index 8b726e1f4..b781aac23 100644 --- a/web/src/screens/CollectionsScreen.tsx +++ b/web/src/screens/CollectionsScreen.tsx @@ -37,8 +37,11 @@ import { getCollections, getLibraryBrowseItems, getSmartCollections, + LIBRARY_PICKER_MIN_QUERY, + LIBRARY_PICKER_RESULTS, messageFromCollectionError, removeItemFromCollection, + titleContainsQuery, toAddItemsRequest, updateCollection, updateCollectionCustomOrder, @@ -223,21 +226,36 @@ function AddItemsDialog({ const [adding, setAdding] = useState(false); const [kindFilter, setKindFilter] = useState('all'); + // Below LIBRARY_PICKER_MIN_QUERY, this issues NOTHING: a blank/short form submit and a + // kind-chip click (see selectKindFilter) both route through here, and #685 was exactly this + // gate missing — an empty query degraded into an unfiltered browse of the whole + // media-library type, per kind, with no truncation indicator. const runSearch = async (filter: MediaKindFilter = kindFilter) => { + const trimmed = query.trim(); + + if (trimmed.length < LIBRARY_PICKER_MIN_QUERY) { + setResults([]); + setError(null); + return; + } + setSearching(true); setError(null); try { - const trimmed = query.trim(); const kinds = filter === 'all' ? DEFAULT_SEARCH_KINDS : [filter]; const perKind = await Promise.all( - kinds.map((mediaType) => getLibraryBrowseItems({ pageSize: 50, query: trimmed, mediaType })) + kinds.map((mediaType) => + getLibraryBrowseItems({ mediaType, pageSize: LIBRARY_PICKER_RESULTS, query: titleContainsQuery(trimmed) }) + ) ); const merged = perKind .flatMap((result) => result.page ?? []) .filter((item) => ADDABLE_TYPES.has(item.mediaType)) .sort((left, right) => left.title.localeCompare(right.title, undefined, { sensitivity: 'base' })); - setResults(merged.slice(0, 50)); + // No slice: each kind is already bounded to LIBRARY_PICKER_RESULTS server-side, so nothing + // fetched is discarded here (#685 — merged.slice(0, 50) used to drop up to 100 of 150 rows). + setResults(merged); } catch (searchError) { setError(messageFromCollectionError(searchError, 'Unable to search library')); } finally { @@ -245,6 +263,11 @@ function AddItemsDialog({ } }; + // A kind-chip click is the second path into runSearch (a blank form submit is the other), and it + // is deliberately NOT gated here: runSearch is the single sink and owns the min-query gate for + // every caller. A duplicate check at this one call site was verified non-load-bearing (removing + // it changed no test), and §3b is explicit that an unreachable guard is an untested one — the + // next path added would be the one nobody remembers to gate. const selectKindFilter = (filter: MediaKindFilter) => { setKindFilter(filter); void runSearch(filter); @@ -347,7 +370,11 @@ function AddItemsDialog({ )}
- {results.length === 0 && !searching ? ( + {query.trim().length < LIBRARY_PICKER_MIN_QUERY ? ( +
+ Type at least {LIBRARY_PICKER_MIN_QUERY} characters to search. +
+ ) : results.length === 0 && !searching ? (
No results — try a search above.
) : ( results.map((item) => { From 4be3f247d8938912dabe7516705790b52dded923 Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 5 Aug 2026 18:46:43 +0200 Subject: [PATCH 2/5] fix(685): move the picker bound into the helper; surface the per-kind cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review round 2. Verdict was MERGEABLE with no blockers; this takes the two recommended fixes plus the structural one it listed as a follow-up. - The bound was caller discipline, not code: getLibraryBrowseItems does not clamp pageSize, so the bound was only the constant this one call site chose to pass, and §3b is explicit that a bound a caller can exceed is not a bound. New searchLibraryBrowseItems in libraryBrowse.ts owns the min-query gate, the pageSize clamp and the titleContainsQuery compile, returning full LibraryBrowseItem rows plus totalCount (searchLibraryPickerOptions' {id,name} shape loses the mediaType that toAddItemsRequest needs). runSearch keeps one early return, for the spinner only, and no longer re-implements the gate. - The min-query guidance was keyed to the LIVE input, so backspacing below the gate after a search wiped the rendered rows and their checkmarks while `selected` and the Add button still counted them. Keyed to results.length too. - "Nothing left to hint at" was false: each kind is still capped at LIBRARY_PICKER_RESULTS and totalCount was never read. This is a bulk multi-select add, so the cap is surfaced — per-kind totalCounts are summed and rendered as "Showing N of M matches" once it exceeds the rendered rows. The registry note and the §3b bullet are corrected to stop claiming otherwise. - Gate boundary tested at 1 character (§3b: inclusive endpoints, or a > for >= slip passes the whole suite). - The guard test's deviation loop iterates an empty list now, so it gains one bidirectional assertion that is non-vacuous: the set carrying an `issue` field must equal the set classified 'deviation'. - The §3b bullet no longer reads as a conformance certificate: AddItemsDialog still lacks the seqRef and useIsMountedRef guards §3b mandates. That defect is PRE-EXISTING, not introduced here, and is tracked in #740. refs #740 --- docs/spa-conventions.md | 21 +++-- web/src/api/libraryBrowse.ts | 35 ++++++++ web/src/api/pageSizeCallSites.guard.test.ts | 46 ++++++---- web/src/screens/CollectionsScreen.test.tsx | 99 ++++++++++++++++++++- web/src/screens/CollectionsScreen.tsx | 38 +++++--- 5 files changed, 200 insertions(+), 39 deletions(-) diff --git a/docs/spa-conventions.md b/docs/spa-conventions.md index 62e29fab5..4b27e59d0 100644 --- a/docs/spa-conventions.md +++ b/docs/spa-conventions.md @@ -186,13 +186,22 @@ the #644 follow-up got Class A right and Class B only half right): - **`SearchPicker` is the single-select SHAPE, not the rule itself.** A MULTI-select picker (`CollectionsScreen`'s `AddItemsDialog` — checkbox rows, many items added at once, fanned out over several kinds) cannot render `SearchPicker` and must not be forced to. It satisfies this - section by keeping its own result list and taking the *constraints* instead: gate on + section by taking the same *constraints* the helper enforces for single-select — the gate on `LIBRARY_PICKER_MIN_QUERY`, `titleContainsQuery` the typed text, `LIBRARY_PICKER_RESULTS` per - kind, and no post-fetch `slice` — a fetched-then-discarded row is a silent drop, and removing - the drop is the fix, not hinting at it. **Put the gate in the single search sink, not at each - caller**: `AddItemsDialog` has two entry paths (blank form submit, kind-chip click), and #685's - first fix duplicated the check at one of them, which measurably tested nothing — deleting the - duplicate reddened no test, while deleting the sink's gate reddened both paths. + kind — via `searchLibraryBrowseItems` (`web/src/api/libraryBrowse.ts`), a sibling of + `searchLibraryPickerOptions` that returns full `LibraryBrowseItem` rows plus `totalCount` + instead of `{id, name}`, so the bound lives in the helper rather than the caller (#685 review + finding 2). There is no post-fetch `slice`, but the per-kind cap can still truncate the real + match count — this is a bulk multi-select add, where "add the 40 matching episodes" is a + first-class use, so `AddItemsDialog` sums each kind's `totalCount` and renders a `Showing N of + M matches` hint once it exceeds the rendered rows (finding 4 — an earlier revision of this + bullet called the truncation nothing left to hint at). **Put the gate in the single search + sink, not at each caller**: `AddItemsDialog` has two entry paths (blank form submit, kind-chip + click), and #685's first fix duplicated the check at one of them, which measurably tested + nothing — deleting the duplicate reddened no test, while deleting the sink's gate reddened both + paths. **Outstanding on this screen**: `AddItemsDialog` still lacks the monotonic `seqRef` + stale-response guard and `useIsMountedRef()` this section mandates elsewhere (see "Debounced + typeaheads" below) — tracked in **ersatztv#740**, not yet fixed here. - **Compile typed text; never forward raw Lucene.** Send `titleContainsQuery(text)` → `title:**`. The index's default field does not match bare title words (`Alpha` finds diff --git a/web/src/api/libraryBrowse.ts b/web/src/api/libraryBrowse.ts index 0d9ea5deb..59f0857ec 100644 --- a/web/src/api/libraryBrowse.ts +++ b/web/src/api/libraryBrowse.ts @@ -111,6 +111,41 @@ export function searchLibraryPickerOptions( ); } +export interface LibraryBrowseSearchResult { + items: LibraryBrowseItem[]; + totalCount: number; +} + +// Like `searchLibraryPickerOptions` above — same min-query gate, same clamp, same compiled query — +// but for a MULTI-select caller (`CollectionsScreen`'s `AddItemsDialog`) that needs the full +// `LibraryBrowseItem` row (mediaType + id, for `toAddItemsRequest`) rather than the `{id, name}` +// shape a single-select `SearchPicker` renders, plus the response's `totalCount` so the caller can +// surface how much of a match was actually returned. The gate/clamp/compile live HERE, not at the +// call site, so no caller can accidentally skip them (§3b — "the bound belongs to the helper, not +// the caller"). +export function searchLibraryBrowseItems( + mediaType: LibraryBrowseMediaType, + text: string, + pageSize: number = LIBRARY_PICKER_RESULTS +): Promise { + const trimmed = text.trim(); + if (trimmed.length < LIBRARY_PICKER_MIN_QUERY) { + return Promise.resolve({ items: [], totalCount: 0 }); + } + + const boundedPageSize = Math.max(1, Math.min(Math.floor(pageSize), LIBRARY_PICKER_RESULTS)); + + return getLibraryBrowseItems({ + mediaType, + pageNum: 0, + pageSize: boundedPageSize, + query: titleContainsQuery(trimmed) + }).then((result) => ({ + items: result.page ?? [], + totalCount: result.totalCount ?? 0 + })); +} + export function messageFromLibraryBrowseError(error: unknown, fallback = 'Unable to load library items'): string { if (error instanceof ApiError) { return error.detail ?? error.message; diff --git a/web/src/api/pageSizeCallSites.guard.test.ts b/web/src/api/pageSizeCallSites.guard.test.ts index 27af1822f..49a8a0483 100644 --- a/web/src/api/pageSizeCallSites.guard.test.ts +++ b/web/src/api/pageSizeCallSites.guard.test.ts @@ -163,6 +163,28 @@ const REGISTRY: RegistryEntry[] = [ 'correctly absent). The bound is a clamp, not a default: Math.min(pageSize, ' + 'LIBRARY_PICKER_RESULTS) inside the helper, so a caller cannot widen it.' }, + { + file: 'api/libraryBrowse.ts', + kind: 'literal', + value: 'boundedPageSize', + classification: 'search-bounded', + note: + "searchLibraryBrowseItems — the #685 review fix that moved AddItemsDialog.runSearch's " + + 'pageSize call site out of screens/CollectionsScreen.tsx and into this shared helper (same ' + + 'gate/clamp/compile as searchLibraryPickerOptions above), so a multi-select caller needing ' + + 'full LibraryBrowseItem rows plus totalCount cannot skip the bound either. A deliberate ' + + 'second occurrence of the same (file, kind, value) identity — the multiset comparison ' + + 'requires it be discovered twice. No request is issued below LIBRARY_PICKER_MIN_QUERY (a ' + + 'blank form submit and a kind-chip click below the gate both issue zero requests, and ' + + 'AddItemsDialog clears results instead), the typed text is compiled via titleContainsQuery ' + + 'rather than forwarded raw, and each kind is bounded to one request per settled query at ' + + 'LIBRARY_PICKER_RESULTS rows. Unlike the #685 first fix, this is NOT "nothing left to hint ' + + 'at": the per-kind cap can still truncate the real match count below what totalCount reports, ' + + "and AddItemsDialog now sums each kind's totalCount and renders a 'Showing N of M' hint when " + + "it exceeds the rendered rows. The only remaining client-side filter in AddItemsDialog " + + "(ADDABLE_TYPES.has(item.mediaType)) is inert, not a silent drop — every mediaType " + + 'AddItemsDialog requests is already in ADDABLE_TYPE_LIST.' + }, { file: 'api/paging.ts', kind: 'shorthand', @@ -246,20 +268,6 @@ const REGISTRY: RegistryEntry[] = [ 'Playout block history: forwards a user-adjustable `pageSize` state (persisted, backed by a ' + 'page-size