diff --git a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts index 2762bc823..e50145ea3 100644 --- a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts +++ b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts @@ -17,6 +17,7 @@ import { } from './electron-test-fixtures'; import { fetchXtreamEpgFixture, + fetchXtreamLiveFixture, fetchStalkerCategoryFixture, } from './portal-mock-fixtures'; @@ -463,3 +464,100 @@ test('@epg @stalker @theme @electron applies live themes to the shared Stalker g await closeElectronApp(app); } }); + +/** Title of the programme block holding keyboard focus, if any. */ +async function focusedTimelineBlockTitle(page: Page): Promise { + return page.evaluate(() => { + const block = document.activeElement?.closest( + 'app-epg-timeline .epg-timeline__block' + ); + return ( + block + ?.querySelector('.epg-timeline__block-title') + ?.textContent?.trim() ?? null + ); + }); +} + +/** Two frames: the focus scroll's measure, then the re-rendered blocks. */ +async function nextFrames(page: Page): Promise { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)) + ) + ); +} + +test('@epg @xtream @electron reaches programmes beyond the rendered range with Tab', async ({ + dataDir, + request, +}) => { + // The ribbon renders only the programmes near the visible range; keyboard + // focus scrolls a block into view, which renders its neighbours before + // the next key press. A generated schedule (240 half-hour slots) is far + // wider than that range. + test.setTimeout(180000); + await resetMockServers(request, ['xtream']); + const credentials = { username: 'minimal', password: 'minimal' }; + const fixture = await fetchXtreamLiveFixture(request, credentials); + const app = await launchElectronApp(dataDir); + + try { + await app.mainWindow.route('https://test-streams.mux.dev/**', () => { + // Keep the external demo request pending; guide data is local. + }); + await addXtreamPortal(app.mainWindow, { + name: 'Xtream Timeline Keyboard', + ...credentials, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + const channelRow = channelItemByTitle( + app.mainWindow, + fixture.items[0]?.name ?? '' + ).first(); + await expect(channelRow).toBeVisible({ timeout: 20000 }); + await channelRow.click(); + + const timeline = app.mainWindow.locator('app-epg-timeline'); + const nowBlock = timeline.locator('.epg-timeline__block.is-now'); + await expect(nowBlock).toBeVisible({ timeout: 20000 }); + const titles = timeline.locator('.epg-timeline__block-title'); + const initial = new Set( + (await titles.allInnerTexts()).map((title) => title.trim()) + ); + expect(initial.size).toBeLessThan(120); + + // Walk well past the rendered range in both directions: ten + // programmes that were not in the DOM when the walk started. + for (const key of ['Tab', 'Shift+Tab']) { + // The previous walk moved the window away from now. + await timeline.locator('.epg-timeline__jump').click(); + await expect(nowBlock).toBeVisible(); + await nowBlock.focus(); + const reached = new Set(); + for (let press = 0; press < 400 && reached.size < 10; press++) { + await app.mainWindow.keyboard.press(key); + await nextFrames(app.mainWindow); + const inRibbon = await app.mainWindow.evaluate( + () => + !!document.activeElement?.closest( + 'app-epg-timeline .epg-timeline__ribbon' + ) + ); + expect(inRibbon, `${key} press ${press} left the ribbon`).toBe( + true + ); + const title = await focusedTimelineBlockTitle(app.mainWindow); + if (title && !initial.has(title)) { + reached.add(title); + } + } + expect(reached.size, `${key} programmes beyond the range`).toBe(10); + } + } finally { + await closeElectronApp(app); + } +}); diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 4f28241f1..2e9729532 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -378,7 +378,10 @@ remain local when the meaning is explicit. them all competed with the stream's first frame (J3 in [performance journeys](performance-journeys.md#j3-playback-start-playback-to-the-first-frame)). Tests and features must not assume an off-screen programme is in the DOM: - scroll the ribbon to it first. + scroll the ribbon to it first. Keyboard access must keep working: focusing + a block scrolls it into view, which renders its neighbours before the next + Tab (`epg-timeline-interaction.e2e.ts` walks past the rendered range both + ways). ### Collapsible Live Sidebar