mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
* perf(epg): render only the timeline programmes near the visible range (J3) Selecting a live channel rendered every programme block of its schedule (about 240 for the Xtream mock) while the stream was starting: about 6,000 of J3's 6,199 renderer.domMutationsToPlaying. The ribbon now renders the blocks, ticks and day dividers within half a viewport of the visible range; the track keeps the full schedule's width, so positions, the scrollbar and scroll-to-now are unchanged. A resize reported before the scroll-to-now must not re-centre the window (it once jumped to the schedule's start and back); resizes only widen it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): sweep the EPG ribbon to see every programme Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(epg): estimate the ribbon window once per channel or mount The live estimate followed the centred day and the 30 s now tick while the ribbon was not yet scrolled: a small scroll across midnight re-centred the window on the next day's noon and left the visible range empty, and the host width was re-read (a forced layout) on every tick. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): keyboard Tab reaches programmes beyond the rendered EPG range Greptile flagged that windowing the ribbon could strand keyboard users at the last rendered block. Focusing a block scrolls it into view, which re-windows before the next key press; the new test walks ten unrendered programmes past the range with Tab and with Shift+Tab, and fails if focus ever leaves the ribbon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(epg): re-centre the ribbon window in the same pass as a zoom Review follow-up (Codex): a zoom (button, Ctrl/⌘ wheel, coalesced wheel burst) or a group expansion changed the scale before the anchored scrollLeft landed on a later frame, so the window was the previous centre at the new scale until the next scroll event re-measured it. The ribbon could flash empty or show the wrong section. The zoom controller now hands the window the minute its anchored scroll will centre, and a group expansion the group's centre, together with the new scale (TimelineWindowController.centreOnMinute). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(epg): window the scroll position a zoom can actually reach Review follow-up (Greptile, Codex): a zoom-out anchored right of centre at the ribbon's start computed a negative scroll position. The window centred on it, the browser kept scrollLeft at 0, and with the position unchanged no scroll event re-windowed, so the visible right-hand part stayed empty. The centre now uses the position clamped at 0, and once the frame applies scrollLeft the window re-centres on what the browser kept, which also covers the clamp at the end of the track. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(epg): close the programme popover when the window drops its block Review follow-up (Codex): the ribbon window can remove a focused narrow block on scroll, and a removed node fires no focusout, so the fixed tooltip kept showing a programme no longer on screen. The popover is now a linkedSignal over the rendered items that keeps its state only while its block (by key) is still rendered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(epg): re-measure the ribbon window when the axis origin moves Review follow-up (Codex): a time offset that carries the first programme across midnight moves the axis origin and every track position while scrollLeft and the ribbon stay put, so no scroll event fires and the window kept its epoch-time centre at a different pixel position. The window identity now includes the axis start; when only that changes, the viewport is measured from the ribbon instead of re-estimated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
564 lines
22 KiB
TypeScript
564 lines
22 KiB
TypeScript
import {
|
|
addXtreamPortal,
|
|
addStalkerPortal,
|
|
waitForStalkerCatalog,
|
|
channelItemByTitle,
|
|
clickCategoryByNameExact,
|
|
closeElectronApp,
|
|
expect,
|
|
launchElectronApp,
|
|
openWorkspaceSection,
|
|
openSettings,
|
|
openSettingsSection,
|
|
saveSettings,
|
|
resetMockServers,
|
|
test,
|
|
waitForXtreamWorkspaceReady,
|
|
} from './electron-test-fixtures';
|
|
import {
|
|
fetchXtreamEpgFixture,
|
|
fetchXtreamLiveFixture,
|
|
fetchStalkerCategoryFixture,
|
|
} from './portal-mock-fixtures';
|
|
|
|
import type { Locator, Page } from '@playwright/test';
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import {
|
|
applyTheme,
|
|
expectTextContrast,
|
|
expectThemeSurface,
|
|
expectSkeletonContrast,
|
|
} from './theme-contrast';
|
|
|
|
const epgCredentials = {
|
|
username: 'epg',
|
|
password: 'epg',
|
|
};
|
|
|
|
/**
|
|
* Every opener shares one dialog config: the programme dialog is named by its
|
|
* `mat-dialog-title` and opens in the same 540px pane wherever it starts.
|
|
*/
|
|
async function expectProgrammeDialog(
|
|
page: Page,
|
|
title?: string
|
|
): Promise<Locator> {
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog).toBeVisible();
|
|
const heading = dialog.locator('.epg-dialog__title');
|
|
if (title) {
|
|
await expect(heading).toHaveText(title);
|
|
}
|
|
await expect(dialog).toHaveAccessibleName(
|
|
(await heading.innerText()).trim()
|
|
);
|
|
await expect
|
|
.poll(() =>
|
|
dialog.evaluate(
|
|
(element) =>
|
|
element.closest<HTMLElement>('.cdk-overlay-pane')
|
|
?.offsetWidth
|
|
)
|
|
)
|
|
.toBe(540);
|
|
return dialog;
|
|
}
|
|
|
|
async function closeProgrammeDialog(page: Page, dialog: Locator) {
|
|
// The footer Close is the dialog's only visible dismiss.
|
|
await expect(dialog.getByRole('button', { name: 'Close' })).toHaveCount(1);
|
|
await dialog.locator('.epg-dialog__close').click();
|
|
await page.waitForSelector('.epg-dialog', { state: 'detached' });
|
|
}
|
|
|
|
test('@epg @xtream @electron opens the programme dialog from a timeline block and reacts to zoom', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
const fixture = await fetchXtreamEpgFixture(request, epgCredentials);
|
|
const currentProgram = fixture.shortEpg[0];
|
|
if (!currentProgram) {
|
|
throw new Error(
|
|
'Expected the Xtream EPG fixture to include a current program.'
|
|
);
|
|
}
|
|
const app = await launchElectronApp(dataDir, { env: { TZ: 'UTC' } });
|
|
|
|
try {
|
|
await app.electronApp.evaluate(({ BrowserWindow }) => {
|
|
BrowserWindow.getAllWindows()[0].setSize(1800, 1000);
|
|
});
|
|
// No external demo stream is needed for guide interaction/contrast.
|
|
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 Interaction',
|
|
username: epgCredentials.username,
|
|
password: epgCredentials.password,
|
|
});
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
|
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
|
|
|
const channelRow = channelItemByTitle(
|
|
app.mainWindow,
|
|
fixture.stream.name ?? ''
|
|
).first();
|
|
await expect(channelRow).toBeVisible({ timeout: 20000 });
|
|
await channelRow.click();
|
|
|
|
const timeline = app.mainWindow.locator('app-epg-timeline');
|
|
await expect(timeline).toBeVisible({ timeout: 20000 });
|
|
|
|
const nowBlock = timeline
|
|
.locator('.epg-timeline__block.is-now')
|
|
.first();
|
|
await expect(nowBlock).toBeVisible();
|
|
|
|
// The zoom button cycles hours → detail → day; block widths follow
|
|
// px/minute, so "detail" must render the same block wider than "day".
|
|
const zoomButton = timeline.locator('.epg-timeline__zoom');
|
|
await expect(zoomButton).toBeVisible();
|
|
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'hours');
|
|
|
|
const blockWidth = async () =>
|
|
(await nowBlock.boundingBox())?.width ?? 0;
|
|
|
|
await zoomButton.click();
|
|
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'detail');
|
|
const detailZoomWidth = await blockWidth();
|
|
|
|
await zoomButton.click();
|
|
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'day');
|
|
const dayZoomWidth = await blockWidth();
|
|
expect(detailZoomWidth).toBeGreaterThan(dayZoomWidth);
|
|
|
|
// Ctrl + wheel over the ribbon fine-tunes the zoom (and must not
|
|
// trigger Chromium's page zoom, which would scale the whole window).
|
|
const ribbon = timeline.locator('.epg-timeline__ribbon');
|
|
await ribbon.hover({ position: { x: 200, y: 40 } });
|
|
const pageZoomBefore = await app.mainWindow.evaluate(
|
|
() => window.devicePixelRatio
|
|
);
|
|
await app.mainWindow.keyboard.down('Control');
|
|
await app.mainWindow.mouse.wheel(0, -300);
|
|
await app.mainWindow.keyboard.up('Control');
|
|
await expect
|
|
.poll(blockWidth, { timeout: 5000 })
|
|
.toBeGreaterThan(dayZoomWidth);
|
|
// Chromium page zoom scales devicePixelRatio; the ribbon must have
|
|
// swallowed the gesture instead.
|
|
expect(
|
|
await app.mainWindow.evaluate(() => window.devicePixelRatio)
|
|
).toBe(pageZoomBefore);
|
|
|
|
// The contrast checks below read the block's time line, which the
|
|
// narrower tiers hide, so settle on the "detail" preset. The
|
|
// wheel-tuned scale may sit in any band (Chromium scales the delivered
|
|
// delta), so cycle from wherever it landed rather than assuming a
|
|
// fixed number of clicks.
|
|
const clicksToDetail: Record<string, number> = {
|
|
detail: 0,
|
|
day: 2,
|
|
hours: 1,
|
|
};
|
|
const landedLevel =
|
|
(await zoomButton.getAttribute('data-zoom-level')) ?? '';
|
|
expect(Object.keys(clicksToDetail)).toContain(landedLevel);
|
|
for (let i = 0; i < clicksToDetail[landedLevel]; i += 1) {
|
|
await zoomButton.click();
|
|
}
|
|
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'detail');
|
|
|
|
for (const theme of ['light', 'dark', 'light'] as const) {
|
|
await applyTheme(app.mainWindow, theme);
|
|
await expectThemeSurface(timeline, theme);
|
|
await expectTextContrast(
|
|
timeline.locator('.epg-timeline__heading b')
|
|
);
|
|
await expectTextContrast(
|
|
nowBlock.locator('.epg-timeline__block-title')
|
|
);
|
|
await expectTextContrast(
|
|
nowBlock.locator('.epg-timeline__block-time')
|
|
);
|
|
// Exercise the worst-case overlapping current/playing tints,
|
|
// including the sibling progress fill behind the small live label.
|
|
for (const playing of [false, true]) {
|
|
await nowBlock.evaluate((element, active) => {
|
|
element.classList.toggle('is-playing', active);
|
|
}, playing);
|
|
await expectTextContrast(
|
|
nowBlock.locator('.epg-timeline__tag.is-now'),
|
|
4.5,
|
|
'.epg-timeline__fill--live'
|
|
);
|
|
}
|
|
await nowBlock.evaluate((element) =>
|
|
element.classList.remove('is-playing')
|
|
);
|
|
await nowBlock.hover();
|
|
await expectTextContrast(
|
|
nowBlock.locator('.epg-timeline__info'),
|
|
3
|
|
);
|
|
}
|
|
|
|
// At max zoom the on-air block is wide enough to expose the info
|
|
// affordance (hidden on narrow/micro tiers), which opens the shared
|
|
// programme-details dialog with the programme metadata.
|
|
await nowBlock.locator('.epg-timeline__info').click();
|
|
|
|
const dialog = await expectProgrammeDialog(
|
|
app.mainWindow,
|
|
currentProgram.title
|
|
);
|
|
// An on-air programme offers "watch live" as the primary action, last
|
|
// in the footer and to the right of the dismiss.
|
|
const footer = dialog.locator('.epg-dialog__actions button');
|
|
await expect(footer).toHaveCount(2);
|
|
await expect(footer.first()).toHaveClass(/epg-dialog__close/);
|
|
await expect(footer.last()).toHaveClass(/epg-dialog__btn--primary/);
|
|
// Layout offsets, not bounding boxes: the dialog may still be scaling
|
|
// in, which transforms client rects.
|
|
const [closeBox, primaryBox] = await footer.evaluateAll((buttons) =>
|
|
buttons.map((button) => ({
|
|
left: (button as HTMLElement).offsetLeft,
|
|
top: (button as HTMLElement).offsetTop,
|
|
}))
|
|
);
|
|
expect(primaryBox.left).toBeGreaterThan(closeBox.left);
|
|
expect(primaryBox.top).toBe(closeBox.top);
|
|
|
|
for (const theme of ['light', 'dark', 'light'] as const) {
|
|
await applyTheme(app.mainWindow, theme);
|
|
await expectThemeSurface(dialog.locator('.epg-dialog'), theme);
|
|
await expectTextContrast(dialog.locator('.epg-dialog__title'));
|
|
await expectTextContrast(dialog.locator('.epg-dialog__desc'));
|
|
await expectTextContrast(dialog.locator('.epg-dialog__close'), 3);
|
|
}
|
|
await app.mainWindow.screenshot({
|
|
path: test.info().outputPath('epg-light.png'),
|
|
});
|
|
await closeProgrammeDialog(app.mainWindow, dialog);
|
|
|
|
// The channel row's info button opens the same dialog.
|
|
await channelRow.locator('.program-info-button').click();
|
|
await closeProgrammeDialog(
|
|
app.mainWindow,
|
|
await expectProgrammeDialog(app.mainWindow)
|
|
);
|
|
|
|
await openSettings(app.mainWindow);
|
|
await openSettingsSection(app.mainWindow, 'epg');
|
|
await app.mainWindow.getByTestId('epg-view-mode-list').click();
|
|
await saveSettings(app.mainWindow);
|
|
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
|
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
|
await channelItemByTitle(app.mainWindow, fixture.stream.name ?? '')
|
|
.first()
|
|
.click();
|
|
const guide = app.mainWindow.locator('app-epg-list-view');
|
|
await expect(guide).toBeVisible();
|
|
for (const theme of ['light', 'dark'] as const) {
|
|
await applyTheme(app.mainWindow, theme);
|
|
await expectThemeSurface(guide, theme);
|
|
await expectTextContrast(
|
|
guide.locator('[data-when="now"] .time').first()
|
|
);
|
|
await expectTextContrast(
|
|
guide.locator('[data-when="now"] .title').first()
|
|
);
|
|
await expectTextContrast(
|
|
guide.locator('[data-when="now"] .desc').first()
|
|
);
|
|
}
|
|
// The list view's info button opens the same dialog.
|
|
await guide
|
|
.locator('[data-when="now"]')
|
|
.first()
|
|
.getByRole('button', { name: 'Show details about this program' })
|
|
.click();
|
|
await closeProgrammeDialog(
|
|
app.mainWindow,
|
|
await expectProgrammeDialog(app.mainWindow)
|
|
);
|
|
|
|
// Keep a fresh channel's EPG IPC pending so the real list loading
|
|
// template stays mounted through both theme changes.
|
|
await app.electronApp.evaluate(({ ipcMain }) => {
|
|
ipcMain.removeHandler('XTREAM_REQUEST');
|
|
ipcMain.handle(
|
|
'XTREAM_REQUEST',
|
|
() =>
|
|
new Promise(() => {
|
|
// Released when this isolated Electron test app closes.
|
|
})
|
|
);
|
|
});
|
|
await app.mainWindow
|
|
.locator('[data-test-id="channel-item"]')
|
|
.nth(1)
|
|
.click();
|
|
const skeleton = guide.locator('.sk-time').first();
|
|
await expect(skeleton).toBeVisible();
|
|
for (const theme of ['light', 'dark'] as const) {
|
|
await applyTheme(app.mainWindow, theme);
|
|
await expectSkeletonContrast(skeleton, guide);
|
|
await expectSkeletonContrast(
|
|
guide.locator('.sk-title').first(),
|
|
guide
|
|
);
|
|
}
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('@epg @xtream @electron stacks the programme dialog actions on a phone', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
// French carries the longest primary label ("watch from start").
|
|
const fr = JSON.parse(
|
|
readFileSync(
|
|
join(__dirname, '../../web/src/assets/i18n/fr.json'),
|
|
'utf8'
|
|
)
|
|
) as {
|
|
WORKSPACE: { SHELL: { RAIL_LIVE: string } };
|
|
EPG: {
|
|
PROGRAM_DIALOG: { SHOW_PROGRAM_DETAILS: string };
|
|
TIMELINE: { WATCH_FROM_START: string };
|
|
};
|
|
};
|
|
await resetMockServers(request, ['xtream']);
|
|
const fixture = await fetchXtreamEpgFixture(request, epgCredentials);
|
|
const app = await launchElectronApp(dataDir, { env: { TZ: 'UTC' } });
|
|
|
|
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 Phone Dialog',
|
|
username: epgCredentials.username,
|
|
password: epgCredentials.password,
|
|
});
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
await openSettings(app.mainWindow);
|
|
await app.mainWindow.getByTestId('select-language').click();
|
|
await app.mainWindow.locator('mat-option[data-test-id="fr"]').click();
|
|
await openSettingsSection(app.mainWindow, 'epg');
|
|
await app.mainWindow.getByTestId('epg-view-mode-list').click();
|
|
await saveSettings(app.mainWindow);
|
|
|
|
await openWorkspaceSection(
|
|
app.mainWindow,
|
|
fr.WORKSPACE.SHELL.RAIL_LIVE
|
|
);
|
|
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
|
await channelItemByTitle(app.mainWindow, fixture.stream.name ?? '')
|
|
.first()
|
|
.click();
|
|
const guide = app.mainWindow.locator('app-epg-list-view');
|
|
await expect(guide.locator('[data-when="past"]').first()).toBeVisible();
|
|
|
|
// The window cannot shrink below its desktop minimum, so emulate a
|
|
// phone viewport below the 640px breakpoint.
|
|
await app.mainWindow.setViewportSize({ width: 360, height: 800 });
|
|
await guide
|
|
.locator('[data-when="past"]')
|
|
.first()
|
|
.getByRole('button', {
|
|
name: fr.EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS,
|
|
})
|
|
.click();
|
|
const dialog = app.mainWindow.getByRole('dialog');
|
|
await expect(dialog).toBeVisible();
|
|
await expect(dialog.locator('.epg-dialog__btn--primary')).toContainText(
|
|
fr.EPG.TIMELINE.WATCH_FROM_START
|
|
);
|
|
|
|
// Every action row stacks one full-width button per row in DOM
|
|
// order, and no label spills out of its button.
|
|
const rows = await dialog
|
|
.locator('.epg-dialog__tools, .epg-dialog__actions')
|
|
.evaluateAll((containers) =>
|
|
containers.map((container) => {
|
|
const buttons = Array.from(
|
|
container.querySelectorAll<HTMLElement>('button')
|
|
);
|
|
return {
|
|
width: (container as HTMLElement).clientWidth,
|
|
buttons: buttons.map((button) => ({
|
|
top: button.offsetTop,
|
|
bottom: button.offsetTop + button.offsetHeight,
|
|
width: button.offsetWidth,
|
|
overflows:
|
|
button.scrollWidth > button.clientWidth + 1,
|
|
})),
|
|
};
|
|
})
|
|
);
|
|
expect(rows).toHaveLength(2);
|
|
for (const row of rows) {
|
|
expect(row.buttons.length).toBeGreaterThan(1);
|
|
row.buttons.forEach((button, index) => {
|
|
expect(Math.abs(button.width - row.width)).toBeLessThanOrEqual(
|
|
1
|
|
);
|
|
expect(button.overflows).toBe(false);
|
|
if (index > 0) {
|
|
expect(button.top).toBeGreaterThanOrEqual(
|
|
row.buttons[index - 1].bottom
|
|
);
|
|
}
|
|
});
|
|
}
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('@epg @stalker @theme @electron applies live themes to the shared Stalker guide', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['stalker']);
|
|
const fixture = await fetchStalkerCategoryFixture(request, 'itv');
|
|
const item = fixture.items[0];
|
|
const app = await launchElectronApp(dataDir);
|
|
try {
|
|
await app.electronApp.evaluate(({ BrowserWindow }) => {
|
|
BrowserWindow.getAllWindows()[0].setSize(1800, 1000);
|
|
});
|
|
await app.mainWindow.route('https://test-streams.mux.dev/**', () => {
|
|
// Keep the external demo request pending; guide data is local.
|
|
});
|
|
await addStalkerPortal(app.mainWindow, {
|
|
name: 'Stalker Theme Fixture',
|
|
});
|
|
await waitForStalkerCatalog(app.mainWindow);
|
|
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
|
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
|
await channelItemByTitle(app.mainWindow, item.o_name || item.name || '')
|
|
.first()
|
|
.click();
|
|
const timeline = app.mainWindow.locator('app-epg-timeline');
|
|
await expect(timeline).toBeVisible();
|
|
for (const theme of ['light', 'dark', 'light'] as const) {
|
|
await applyTheme(app.mainWindow, theme);
|
|
await expectThemeSurface(timeline, theme);
|
|
await expectTextContrast(
|
|
timeline.locator('.epg-timeline__heading b')
|
|
);
|
|
await app.mainWindow.screenshot({
|
|
path: test.info().outputPath(`stalker-${theme}.png`),
|
|
});
|
|
}
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
/** Title of the programme block holding keyboard focus, if any. */
|
|
async function focusedTimelineBlockTitle(page: Page): Promise<string | null> {
|
|
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<void> {
|
|
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<string>();
|
|
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);
|
|
}
|
|
});
|