From cd111b6c36d852d8d3fe8de798dd558f568a263d Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 25 Apr 2026 14:09:59 +0200 Subject: [PATCH] fix(stalker): enhance pagination handling and add search phrase management Entire-Checkpoint: c6e522b4276c --- .../src/catalog-sorting.e2e.ts | 302 +++++++++++++++++- .../with-stalker-content.feature.spec.ts | 86 +++++ .../features/with-stalker-content.feature.ts | 37 +++ .../with-stalker-selection.feature.spec.ts | 19 ++ .../with-stalker-selection.feature.ts | 12 + 5 files changed, 442 insertions(+), 14 deletions(-) diff --git a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts index ef2fb8ce9..3c6b256b1 100644 --- a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts +++ b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts @@ -3,10 +3,12 @@ import { addStalkerPortal, addXtreamPortal, clickCategoryByNameExact, + clickFirstGridListCard, closeElectronApp, defaultXtreamPassword, defaultXtreamUsername, expect, + expectPathname, launchElectronApp, openWorkspaceSection, resetMockServers, @@ -15,6 +17,7 @@ import { waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; import { + fetchStalkerCategoryFixture, fetchXtreamLiveFixture, fetchXtreamSeriesFixture, fetchXtreamVodFixture, @@ -28,7 +31,10 @@ test.describe('Electron Catalog Sorting', () => { request, }) => { await resetMockServers(request, ['xtream']); - const fixture = await fetchXtreamLiveFixture(request, xtreamCredentials); + const fixture = await fetchXtreamLiveFixture( + request, + xtreamCredentials + ); const expectedServerOrder = fixture.items .map((item) => getXtreamTitle(item)) .filter((title, index, titles) => titles.indexOf(title) === index) @@ -50,20 +56,32 @@ test.describe('Electron Catalog Sorting', () => { await addXtreamPortal(app.mainWindow); await waitForXtreamWorkspaceReady(app.mainWindow); await openWorkspaceSection(app.mainWindow, 'Live TV'); - await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + await clickCategoryByNameExact( + app.mainWindow, + fixture.categoryName + ); - await expectVisibleChannelTitles(app.mainWindow, expectedServerOrder); + await expectVisibleChannelTitles( + app.mainWindow, + expectedServerOrder + ); await setLiveSortMode(app.mainWindow, 'Name A-Z'); await expectVisibleChannelTitles(app.mainWindow, expectedAscending); await setLiveSortMode(app.mainWindow, 'Name Z-A'); - await expectVisibleChannelTitles(app.mainWindow, expectedDescending); + await expectVisibleChannelTitles( + app.mainWindow, + expectedDescending + ); await setLiveSortMode(app.mainWindow, 'Name A-Z'); await openWorkspaceSection(app.mainWindow, 'Movies'); await openWorkspaceSection(app.mainWindow, 'Live TV'); - await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + await clickCategoryByNameExact( + app.mainWindow, + fixture.categoryName + ); await expectVisibleChannelTitles(app.mainWindow, expectedAscending); } finally { await closeElectronApp(app); @@ -75,17 +93,26 @@ test.describe('Electron Catalog Sorting', () => { request, }) => { await resetMockServers(request, ['xtream']); - const vodFixture = await fetchXtreamVodFixture(request, xtreamCredentials); + const vodFixture = await fetchXtreamVodFixture( + request, + xtreamCredentials + ); const seriesFixture = await fetchXtreamSeriesFixture( request, xtreamCredentials ); const expectedVodDateDesc = [...vodFixture.items] - .sort((left, right) => getXtreamDateValue(right) - getXtreamDateValue(left)) + .sort( + (left, right) => + getXtreamDateValue(right) - getXtreamDateValue(left) + ) .map((item) => getXtreamTitle(item)) .slice(0, visibleComparisonSize); const expectedVodDateAsc = [...vodFixture.items] - .sort((left, right) => getXtreamDateValue(left) - getXtreamDateValue(right)) + .sort( + (left, right) => + getXtreamDateValue(left) - getXtreamDateValue(right) + ) .map((item) => getXtreamTitle(item)) .slice(0, visibleComparisonSize); const expectedVodNameAsc = [...vodFixture.items] @@ -108,10 +135,16 @@ test.describe('Electron Catalog Sorting', () => { await waitForXtreamWorkspaceReady(app.mainWindow); await openWorkspaceSection(app.mainWindow, 'Movies'); - await clickCategoryByNameExact(app.mainWindow, vodFixture.categoryName); + await clickCategoryByNameExact( + app.mainWindow, + vodFixture.categoryName + ); await expectVisibleGridTitles(app.mainWindow, expectedVodDateDesc); - await setContentSortMode(app.mainWindow, 'Date Added (Oldest First)'); + await setContentSortMode( + app.mainWindow, + 'Date Added (Oldest First)' + ); await expectVisibleGridTitles(app.mainWindow, expectedVodDateAsc); await setContentSortMode(app.mainWindow, 'Name A-Z'); @@ -121,14 +154,26 @@ test.describe('Electron Catalog Sorting', () => { await expectVisibleGridTitles(app.mainWindow, expectedVodNameDesc); await openWorkspaceSection(app.mainWindow, 'Series'); - await clickCategoryByNameExact(app.mainWindow, seriesFixture.categoryName); + await clickCategoryByNameExact( + app.mainWindow, + seriesFixture.categoryName + ); await setContentSortMode(app.mainWindow, 'Name A-Z'); - await expectVisibleGridTitles(app.mainWindow, expectedSeriesNameAsc); + await expectVisibleGridTitles( + app.mainWindow, + expectedSeriesNameAsc + ); await openWorkspaceSection(app.mainWindow, 'Live TV'); await openWorkspaceSection(app.mainWindow, 'Series'); - await clickCategoryByNameExact(app.mainWindow, seriesFixture.categoryName); - await expectVisibleGridTitles(app.mainWindow, expectedSeriesNameAsc); + await clickCategoryByNameExact( + app.mainWindow, + seriesFixture.categoryName + ); + await expectVisibleGridTitles( + app.mainWindow, + expectedSeriesNameAsc + ); } finally { await closeElectronApp(app); } @@ -165,6 +210,103 @@ test.describe('Electron Catalog Sorting', () => { await closeElectronApp(app); } }); + + test('keeps Xtream catalog page after opening VOD and series details, and resets grid scroll on page changes', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const vodFixture = await fetchXtreamVodFixture( + request, + xtreamCredentials + ); + const seriesFixture = await fetchXtreamSeriesFixture( + request, + xtreamCredentials + ); + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow); + await waitForXtreamWorkspaceReady(app.mainWindow); + + await openWorkspaceSection(app.mainWindow, 'Movies'); + await clickCategoryByNameExact( + app.mainWindow, + vodFixture.categoryName + ); + await expectCatalogGridReady(app.mainWindow); + await expectCatalogScrollResetAfterNextPage(app.mainWindow); + await clickFirstGridListCard(app.mainWindow); + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/ + ); + await goBackFromDetail(app.mainWindow); + await expectCatalogPageQuery(app.mainWindow, '2'); + await expectCatalogGridReady(app.mainWindow); + + await openWorkspaceSection(app.mainWindow, 'Series'); + await clickCategoryByNameExact( + app.mainWindow, + seriesFixture.categoryName + ); + await expectCatalogGridReady(app.mainWindow); + await expectCatalogScrollResetAfterNextPage(app.mainWindow); + await clickFirstGridListCard(app.mainWindow); + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/ + ); + await goBackFromDetail(app.mainWindow); + await expectCatalogPageQuery(app.mainWindow, '2'); + await expectCatalogGridReady(app.mainWindow); + } finally { + await closeElectronApp(app); + } + }); + + test('resets Stalker VOD and series grid scroll when changing pages', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['stalker']); + const vodFixture = await fetchStalkerCategoryFixture(request, 'vod'); + const seriesFixture = await fetchStalkerCategoryFixture( + request, + 'series' + ); + const app = await launchElectronApp(dataDir); + + try { + await addStalkerPortal(app.mainWindow); + await waitForStalkerCatalog(app.mainWindow); + + await openWorkspaceSection(app.mainWindow, 'Movies'); + await clickCategoryByVisibleName( + app.mainWindow, + vodFixture.categoryName + ); + await expectCatalogGridReady(app.mainWindow); + await expectCatalogScrollResetAfterNextPage(app.mainWindow, { + expectContentChange: false, + }); + await expectStalkerDetailBackPreservesCatalogPage(app.mainWindow); + + await openWorkspaceSection(app.mainWindow, 'Series'); + await clickCategoryByVisibleName( + app.mainWindow, + seriesFixture.categoryName + ); + await expectCatalogGridReady(app.mainWindow); + await expectCatalogScrollResetAfterNextPage(app.mainWindow, { + expectContentChange: false, + }); + await expectStalkerDetailBackPreservesCatalogPage(app.mainWindow); + } finally { + await closeElectronApp(app); + } + }); }); const collator = new Intl.Collator(undefined, { @@ -221,6 +363,138 @@ async function expectVisibleGridTitles( .toEqual(expectedTitles); } +async function expectCatalogGridReady(page: Page): Promise { + await expect( + page.locator('.category-content-layout mat-card').first() + ).toBeVisible({ + timeout: 20000, + }); +} + +async function expectCatalogScrollResetAfterNextPage( + page: Page, + options: { expectContentChange?: boolean } = {} +): Promise { + const grid = catalogGrid(page); + + await expect(grid).toBeVisible({ timeout: 20000 }); + await ensureCatalogCanGoNext(page); + const rangeBefore = await catalogRangeText(page); + const titlesBefore = await visibleGridTitles(page); + await grid.evaluate((element: HTMLElement) => { + element.scrollTo({ top: element.scrollHeight }); + }); + await expect.poll(() => getCatalogGridScrollTop(page)).toBeGreaterThan(0); + + await page + .locator('.category-content-header') + .getByRole('button', { name: 'Next page' }) + .click(); + await expectCatalogPageQuery(page, '2'); + await expect.poll(() => catalogRangeText(page)).not.toBe(rangeBefore); + if (options.expectContentChange !== false) { + await expect + .poll(() => visibleGridTitles(page)) + .not.toEqual(titlesBefore); + } + await expect.poll(() => getCatalogGridScrollTop(page)).toBeLessThan(2); + return visibleGridTitles(page); +} + +async function expectStalkerDetailBackPreservesCatalogPage( + page: Page +): Promise { + const titlesOnPage = await visibleGridTitles(page); + + await clickFirstGridListCard(page); + await goBackFromDetail(page); + + await expectCatalogPageQuery(page, '2'); + await expectCatalogGridReady(page); + await expect.poll(() => visibleGridTitles(page)).toEqual(titlesOnPage); +} + +async function expectCatalogPageQuery( + page: Page, + expectedPage: string +): Promise { + await expect + .poll(() => new URL(page.url()).searchParams.get('page')) + .toBe(expectedPage); +} + +async function goBackFromDetail(page: Page): Promise { + const backButton = page + .locator('app-content-hero .hero__back-button') + .first(); + + await expect(backButton).toBeVisible({ timeout: 20000 }); + try { + await backButton.click({ timeout: 5000 }); + } catch { + await backButton.evaluate((button: HTMLButtonElement) => + button.click() + ); + } +} + +async function clickCategoryByVisibleName( + page: Page, + categoryName: string +): Promise { + const category = page + .locator('app-workspace-context-panel .category-item:visible') + .filter({ + has: page.locator('.nav-item-label', { + hasText: new RegExp(`^\\s*${escapeRegex(categoryName)}\\s*$`), + }), + }) + .first(); + + await expect(category).toBeVisible({ timeout: 20000 }); + await category.click(); + await expect.poll(() => category.getAttribute('aria-current')).toBe('true'); +} + +function catalogGrid(page: Page) { + return page.locator('app-category-content-view app-grid-list').first(); +} + +async function ensureCatalogCanGoNext(page: Page): Promise { + const header = page.locator('.category-content-header'); + const nextButton = header.getByRole('button', { name: 'Next page' }); + + await expect(nextButton).toBeVisible({ timeout: 20000 }); + + if (await nextButton.isDisabled()) { + await header.getByRole('button', { name: 'Previous page' }).click(); + await expect + .poll(() => new URL(page.url()).searchParams.get('page')) + .toBe(null); + } + + await expect(nextButton).toBeEnabled({ timeout: 20000 }); +} + +async function catalogRangeText(page: Page): Promise { + return ( + (await page + .locator('.category-content-header .mat-mdc-paginator-range-label') + .first() + .textContent()) ?? '' + ).trim(); +} + +async function getCatalogGridScrollTop(page: Page): Promise { + return catalogGrid(page).evaluate((element: HTMLElement) => + Math.round(element.scrollTop) + ); +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + async function visibleChannelTitles(page: Page): Promise { return page .locator('[data-test-id="channel-item"] .channel-name') diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts index 8e9616916..1525032bb 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts @@ -45,10 +45,39 @@ const TestContentStore = signalStore( setSelectedCategory(id: string | null | undefined) { patchState(store, { selectedCategoryId: id }); }, + setPage(page: number) { + patchState(store, { page }); + }, })), withStalkerContent() ); +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + +function createContentResponse(title: string) { + return { + js: { + data: [ + { + id: title, + name: title, + category_id: '5', + }, + ], + total_items: 28, + }, + }; +} + async function flushResources(): Promise { TestBed.flushEffects(); await Promise.resolve(); @@ -194,4 +223,61 @@ describe('withStalkerContent failure states', () => { 'get_ordered_list failed' ); }); + + it('ignores stale content responses after the selected page changes', async () => { + const pendingByPage = new Map< + number, + ReturnType>[] + >(); + + dataService.sendIpcEvent.mockImplementation( + (_event: unknown, payload: { params?: { p?: number } }) => { + const page = Number(payload.params?.p); + const deferred = createDeferred(); + const pending = pendingByPage.get(page) ?? []; + pending.push(deferred); + pendingByPage.set(page, pending); + return deferred.promise; + } + ); + + store.setSelectedContentType('vod'); + store.setCategories('vod', [ + { + category_id: '5', + category_name: 'Movies', + }, + ]); + store.setSelectedCategory('5'); + store.setCurrentPlaylist(PLAYLIST); + void store.isPaginatedContentLoading(); + + await waitForCondition(() => pendingByPage.get(1)?.length === 1); + pendingByPage + .get(1)?.[0] + .resolve(createContentResponse('Page 1 initial')); + await waitForCondition( + () => store.getPaginatedContent()[0]?.name === 'Page 1 initial' + ); + + store.setPage(1); + await waitForCondition(() => pendingByPage.get(2)?.length === 1); + + store.setPage(0); + await waitForCondition(() => pendingByPage.get(1)?.length === 2); + pendingByPage + .get(1)?.[1] + .resolve(createContentResponse('Page 1 restored')); + await waitForCondition( + () => store.getPaginatedContent()[0]?.name === 'Page 1 restored' + ); + + pendingByPage + .get(2)?.[0] + .resolve(createContentResponse('Page 2 stale')); + await flushResources(); + + expect(store.page()).toBe(0); + expect(store.getPaginatedContent()[0]?.name).toBe('Page 1 restored'); + }); }); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index a2c75cd36..a1ea2a029 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -323,6 +323,30 @@ export function withStalkerContent() { } const categoryParam = params.category || '*'; + const paramsPlaylistKey = + params.currentPlaylist?._id ?? + params.currentPlaylist?.portalUrl ?? + null; + const isCurrentRequest = (): boolean => { + const currentPlaylist = + storeContext.currentPlaylist(); + const currentPlaylistKey = + currentPlaylist?._id ?? + currentPlaylist?.portalUrl ?? + null; + + return ( + params.contentType === + storeContext.selectedContentType() && + params.category === + storeContext.selectedCategoryId() && + params.search === + storeContext.searchPhrase() && + params.pageIndex === + storeContext.page() + 1 && + paramsPlaylistKey === currentPlaylistKey + ); + }; const queryParams: Record = { action: StalkerContentTypes[ @@ -347,6 +371,11 @@ export function withStalkerContent() { } try { + patchState(store, { + paginatedContent: [], + contentError: null, + }); + const response = await executeStalkerRequest( requestDeps, @@ -354,6 +383,10 @@ export function withStalkerContent() { queryParams ); + if (!isCurrentRequest()) { + return []; + } + if (!Array.isArray(response?.js?.data)) { const invalidResponseError = new Error( 'Invalid response structure' @@ -411,6 +444,10 @@ export function withStalkerContent() { return newItems; } catch (error) { + if (!isCurrentRequest()) { + return []; + } + logger.warn('Error loading content', { contentType: params.contentType, category: params.category, diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts index 48ac3e9ad..54c3f1856 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts @@ -23,6 +23,25 @@ describe('withStalkerSelection', () => { expect(store.page()).toBe(0); }); + it('keeps paging when the search phrase is unchanged', () => { + store.setSearchPhrase('matrix'); + store.setPage(2); + + store.setSearchPhrase('matrix'); + + expect(store.searchPhrase()).toBe('matrix'); + expect(store.page()).toBe(2); + }); + + it('resets paging when the search phrase changes', () => { + store.setPage(2); + + store.setSearchPhrase('matrix'); + + expect(store.searchPhrase()).toBe('matrix'); + expect(store.page()).toBe(0); + }); + it('synchronizes entity ids when the selected item changes', () => { store.setSelectedItem({ id: '55', diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts index 21857cea2..f2bb0787f 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts @@ -62,12 +62,24 @@ export function withStalkerSelection() { patchState(store, { selectedItvId: id }); }, setLimit(limit: number) { + if (store.limit() === limit) { + return; + } + patchState(store, { limit }); }, setPage(page: number) { + if (store.page() === page) { + return; + } + patchState(store, { page }); }, setSearchPhrase(phrase: string) { + if (store.searchPhrase() === phrase) { + return; + } + patchState(store, { searchPhrase: phrase, page: 0 }); }, setSelectedItem(selectedItem: StalkerVodSource | null | undefined) {