fix(634): page the rerun-collection picker to completeness #645

Merged
timothy merged 3 commits from fix/634-rerun-picker-paging into main 2026-07-26 12:20:28 +02:00
2 changed files with 81 additions and 11 deletions
+40 -4
View File
@@ -180,12 +180,48 @@ describe('SchedulesScreen — load', () => {
// clamped to 100 server-side that skipped the first 100 rows — so the picker showed nothing at
// all for the ordinary case of <=100 rerun collections. Assert the offset, not just the URL
// shape: `pageNum=1` here is a silent empty picker, never an error.
for (const request of rerunRequests) {
const pageNum = new URL(request.url, 'http://localhost').searchParams.get('pageNum');
expect(pageNum).toBe('0');
}
//
// Assert only the FIRST request. Since #634 the loader legitimately walks pageNum 1, 2, … to
// page to completeness, so "every request is page 0" is an invariant the CORRECT code violates.
// It holds today only because this fixture's totalCount fits in one page — raising that default
// would fail this test with a misleading "requested page 1" signal for what is proper paging.
const firstPageNum = new URL(rerunRequests[0].url, 'http://localhost').searchParams.get('pageNum');
expect(firstPageNum).toBe('0');
});
it('pages the rerun-collection picker to completeness when totalCount exceeds one page (#634)', async () => {
// Server clamps pageSize to 100 (MaxPageSize) regardless of what's requested, so a single
// request only ever returns 100 rows. With 150 rerun collections the picker must issue a
// second page (pageNum=1) and end up with all 150 — not silently stop at 100.
const page0 = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, name: `Rerun ${i + 1}` }));
const page1 = Array.from({ length: 50 }, (_, i) => ({ id: i + 101, name: `Rerun ${i + 101}` }));
const handle = await renderReady({
items: [responseItem({ collectionType: 'RerunFirstRun', collectionId: null, rerunCollectionId: null })],
onRequest: (url) => {
if (!url.startsWith('/api/v1/rerun-collections')) {
return null;
}
const pageNum = new URL(url, 'http://localhost').searchParams.get('pageNum');
if (pageNum === '0') {
return jsonResponse({ totalCount: 150, page: page0 });
}
if (pageNum === '1') {
return jsonResponse({ totalCount: 150, page: page1 });
}
return jsonResponse({ totalCount: 150, page: [] });
}
});
const rerunRequests = handle.requests.filter((r) => r.url.startsWith('/api/v1/rerun-collections'));
expect(rerunRequests.length).toBe(2);
const select = screen.getByLabelText('Rerun collection') as HTMLSelectElement;
// '(none)' + 150 rerun collections — the exact expected set, not a truthy/non-empty check.
const values = Array.from(select.options).map((o) => o.value);
expect(values).toEqual(['', ...Array.from({ length: 150 }, (_, i) => `${i + 1}`)]);
}, 15000);
it('renders the "Active schedule" selector non-full-width so it cannot overflow the header (#463)', async () => {
await renderReady();
const select = screen.getByLabelText('Active schedule');
+41 -7
View File
@@ -22,7 +22,8 @@ import {
getWatermarks,
messageFromScheduleError,
type ProgramSchedule,
replaceScheduleItems
replaceScheduleItems,
type RerunCollection
} from '../api';
import { registerNavigationGuard } from '../navigationGuard';
import { usePrimaryAction } from '../primaryAction';
@@ -41,18 +42,51 @@ import { ScheduleItemInspector, type SchedulePickerData } from '../schedules/Sch
const DIRTY_PROMPT = 'You have unsaved schedule changes. Discard them?';
// Server clamps pageSize to MaxPageSize=100 (RerunCollectionController) regardless of what's
// requested, so a single `pageSize: 1000` request only ever returns the first 100 rows. Page to
// completeness against totalCount instead of raising the server cap (api.search-allitems-paging
// precedent — the client pages, the server stays bounded). Mirrors CollectionsScreen.enterReorder.
//
// `pageNum` is 0-BASED here and everywhere on /api/v1 (api.paging-zero-based): the first page is 0,
// so the loop below starts its follow-up requests at 1. Before ersatztv#616 this call site passed
// `pageNum: 1`, which skipped the first page entirely and showed nothing at all for the ordinary
// case of <=100 rerun collections — the same call site, one defect earlier.
const RERUN_PAGE_SIZE = 100;
async function loadAllRerunCollections(): Promise<RerunCollection[]> {
const first = await getRerunCollections({ pageNum: 0, pageSize: RERUN_PAGE_SIZE });
let all = first.page ?? [];
const totalCount = first.totalCount ?? all.length;
let pageNum = 1;
while (all.length < totalCount) {
const next = await getRerunCollections({ pageNum, pageSize: RERUN_PAGE_SIZE });
const nextPage = next.page ?? [];
if (nextPage.length === 0) {
break;
}
all = [...all, ...nextPage];
pageNum += 1;
}
return all;
}
type BootState =
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'ready'; schedules: ProgramSchedule[]; pickers: SchedulePickerData };
// Other picker fetches below (getPlaylistGroups, getWatermarks, getGraphicsElements, getLanguages,
// getFillerPresetsByKind) hit endpoints that return a plain, unpaged array server-side — no
// pageNum/pageSize params, no clamp — so they aren't subject to the same silent-truncation defect
// as rerun collections and don't need the same treatment (ersatztv#634).
async function loadPickerData(): Promise<SchedulePickerData> {
const [rerun, playlistGroups, watermarks, graphicsElements, languages, pre, mid, post, tail, fallback] =
const [rerunCollections, playlistGroups, watermarks, graphicsElements, languages, pre, mid, post, tail, fallback] =
await Promise.all([
// pageNum is 0-BASED (api.paging-zero-based). This asked for page 1, which skipped the first
// page entirely: pageSize is clamped to 100 server-side, so the picker was served rows 101+
// and showed nothing at all for the normal case of <=100 rerun collections (ersatztv#616).
getRerunCollections({ pageNum: 0, pageSize: 1000 }),
loadAllRerunCollections(),
getPlaylistGroups(),
getWatermarks(),
getGraphicsElements(),
@@ -65,7 +99,7 @@ async function loadPickerData(): Promise<SchedulePickerData> {
]);
return {
rerunCollections: rerun.page ?? [],
rerunCollections,
playlistGroups,
watermarks,
graphicsElements,