mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* feat(settings): split settings into per-section pages with an unsaved-changes bar Replace the single scrolling settings page with routed section pages (/workspace/settings/:section): the context-panel rail links each section, only the active section renders, and unknown or capability-gated sections redirect to General. The shared form lives on the parent component, so staged edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the always-visible footer Save button. Rail links navigate with replaceUrl so Back still leaves settings in one step. Along the way: - delete the unreachable settings dialog mode and the dead AppPortalNavigationActionsService with both of its never-injected DI tokens (PORTAL_NAVIGATION_ACTIONS, PLAYLIST_PLAYER_ACTIONS) - delete the scroll-spy directive and pendingScrollTarget plumbing - revive the EPG panel's "Open EPG settings" empty-state button as a deep link to /workspace/settings/epg; the M3U player now reports m3u-needs-setup only when the channel has no programmes and no EPG source exists in settings or on the playlist itself - load TMDB cache stats when the Metadata page opens (the section component now only exists while its page is open) - add SETTINGS.UNSAVED_CHANGES / SETTINGS.DISCARD_CHANGES to all 19 locales Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(settings): confirm before leaving with unsaved changes Add settingsUnsavedChangesGuard (canDeactivate on the :section route) with a three-action dialog: save and leave, leave without saving, keep editing. The guard only intercepts leaving the settings AREA — section switches share the one settings form and pass unconditionally, so the dialog can never nag while moving between pages. A failed save cancels the navigation instead of silently dropping the edits it promised to keep; leaving without saving also reverts the live theme preview. Save-and-leave is disabled while the form is invalid, with a hint explaining why. New SETTINGS.UNSAVED_DIALOG_* keys in all 19 locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): stage cover size and EPG view mode; adapt e2e to section pages Cover size and EPG view mode were the only two controls that persisted eagerly on click, which made Discard (and leave-without-saving) unable to revert them: hydrateFromStore() faithfully reloaded the just-persisted edit. They now stage in the form like every other setting and reach the store on Save. Review finding by Greptile (P1) and Codex. E2E suites that walk through settings are updated for one-section-page rendering (epg, backup-roundtrip, xtream-epg, remote-control) and for the staged cover size (downloads asserts the dataset after Save); the EPG icon fallback test saves before leaving settings so the new unsaved-changes dialog does not block its navigation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
602 lines
21 KiB
TypeScript
602 lines
21 KiB
TypeScript
import type { Locator } from '@playwright/test';
|
|
import {
|
|
addStalkerPortal,
|
|
addXtreamPortal,
|
|
channelItemByTitle,
|
|
clickCategoryByNameExact,
|
|
closeElectronApp,
|
|
expect,
|
|
goToDashboard,
|
|
launchElectronApp,
|
|
openSettings,
|
|
openSettingsSection,
|
|
openWorkspaceSection,
|
|
resetMockServers,
|
|
saveSettings,
|
|
test,
|
|
waitForStalkerCatalog,
|
|
waitForXtreamWorkspaceReady,
|
|
} from './electron-test-fixtures';
|
|
import {
|
|
fetchStalkerCategoryFixture,
|
|
fetchXtreamEpgFixture,
|
|
} from './portal-mock-fixtures';
|
|
|
|
const epgPortalName = 'Xtream EPG Fixture';
|
|
const epgCredentials = {
|
|
username: 'epg',
|
|
password: 'epg',
|
|
};
|
|
|
|
for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
|
|
test(`@epg @xtream @electron renders Xtream EPG previews and the timeline schedule in ${timeZone}`, 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: timeZone },
|
|
});
|
|
|
|
try {
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: `${epgPortalName} ${timeZone}`,
|
|
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 });
|
|
|
|
// Sidebar channel list shows the per-channel "now" programme line.
|
|
await expect
|
|
.poll(async () =>
|
|
(
|
|
(await channelRow
|
|
.locator('.epg-title')
|
|
.textContent()) ?? ''
|
|
).trim()
|
|
)
|
|
.toBe(currentProgram.title);
|
|
await expect
|
|
.poll(async () => {
|
|
return (
|
|
await channelRow.locator('.epg-time').allInnerTexts()
|
|
).map((value) => value.trim());
|
|
})
|
|
.toEqual([
|
|
formatTimeInZone(currentProgram.startTimestamp, timeZone),
|
|
formatTimeInZone(currentProgram.stopTimestamp, timeZone),
|
|
]);
|
|
await expect
|
|
.poll(async () => getProgressWidthPercent(channelRow))
|
|
.toBeGreaterThan(0);
|
|
|
|
await channelRow.click();
|
|
await expect(
|
|
app.mainWindow.locator('app-epg-timeline')
|
|
).toBeVisible({ timeout: 20000 });
|
|
|
|
// The timeline renders the full multi-day window as blocks,
|
|
// sorted by start time (no per-day filtering — it scrolls).
|
|
const allTitles = [...fixture.fullEpg]
|
|
.sort((a, b) => a.startTimestamp - b.startTimestamp)
|
|
.map((listing) => listing.title);
|
|
await expect
|
|
.poll(() => timelineBlockTitles(app.mainWindow))
|
|
.toEqual(allTitles);
|
|
|
|
// The current programme is highlighted as the "now" block.
|
|
await expect(
|
|
app.mainWindow
|
|
.locator(
|
|
'app-epg-timeline .epg-timeline__block.is-now .epg-timeline__block-title'
|
|
)
|
|
.first()
|
|
).toHaveText(currentProgram.title);
|
|
|
|
// The date stepper advances the day label.
|
|
const dayLabel = app.mainWindow
|
|
.locator('app-epg-timeline .epg-timeline__day small')
|
|
.first();
|
|
const beforeLabel = (await dayLabel.textContent())?.trim();
|
|
await app.mainWindow
|
|
.locator(
|
|
'app-epg-timeline .epg-timeline__stepper .epg-timeline__nav'
|
|
)
|
|
.last()
|
|
.click();
|
|
await expect
|
|
.poll(async () => (await dayLabel.textContent())?.trim())
|
|
.not.toBe(beforeLabel);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
}
|
|
|
|
test('@epg @xtream @electron keeps EPG context and actions at narrow channel-row widths', 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 addXtreamPortal(app.mainWindow, {
|
|
name: `${epgPortalName} Narrow`,
|
|
username: epgCredentials.username,
|
|
password: epgCredentials.password,
|
|
});
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
|
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
|
|
|
const currentProgramRow = channelItemByTitle(
|
|
app.mainWindow,
|
|
fixture.stream.name ?? ''
|
|
).first();
|
|
const noProgramRow = channelItemByTitle(
|
|
app.mainWindow,
|
|
'Night Sports'
|
|
).first();
|
|
await expect(currentProgramRow).toBeVisible({ timeout: 20000 });
|
|
await expect(noProgramRow).toBeVisible({ timeout: 20000 });
|
|
|
|
const currentTitle = currentProgramRow.locator('.epg-title');
|
|
const progressTrack = currentProgramRow.locator('.epg-progress-track');
|
|
const favoriteAction = currentProgramRow.locator('.favorite-button');
|
|
const placeholder = noProgramRow.locator('.epg-placeholder');
|
|
const logo = currentProgramRow.locator('.channel-logo-shell');
|
|
const timeLabels = currentProgramRow.locator('.epg-time');
|
|
|
|
await setPortalChannelItemWidth(app.mainWindow, 300);
|
|
|
|
await expect(currentTitle).toHaveText(currentProgram.title);
|
|
await expect(currentTitle).toBeVisible();
|
|
await expect(progressTrack).toBeVisible();
|
|
await expect(favoriteAction).toBeVisible();
|
|
await expect(placeholder).toBeVisible();
|
|
await expect(logo).toBeVisible();
|
|
await expect(timeLabels).toHaveCount(2);
|
|
await expect(timeLabels.first()).toBeVisible();
|
|
await expect(timeLabels.last()).toBeHidden();
|
|
await expectPortalRowHeightAndStride(currentProgramRow, 'Night Sports');
|
|
await expectPortalRowHeightAndStride(noProgramRow, 'Archive Cinema');
|
|
await expectTimelineStartAndProgressAligned(currentProgramRow);
|
|
await expectNarrowRowContentFits(currentProgramRow);
|
|
await expectNarrowRowContentFits(noProgramRow);
|
|
|
|
await setPortalChannelItemWidth(app.mainWindow, 232);
|
|
|
|
await expect(currentTitle).toHaveText(currentProgram.title);
|
|
await expect(currentTitle).toBeVisible();
|
|
await expect(progressTrack).toBeVisible();
|
|
await expect(favoriteAction).toBeVisible();
|
|
await expect(placeholder).toBeVisible();
|
|
await expect(logo).toBeHidden();
|
|
await expect(timeLabels).toHaveCount(2);
|
|
await expect(timeLabels.first()).toBeVisible();
|
|
await expect(timeLabels.last()).toBeHidden();
|
|
await expectPortalRowHeightAndStride(currentProgramRow, 'Night Sports');
|
|
await expectPortalRowHeightAndStride(noProgramRow, 'Archive Cinema');
|
|
await expectTimelineStartAndProgressAligned(currentProgramRow);
|
|
await expectNarrowRowContentFits(currentProgramRow);
|
|
await expectNarrowRowContentFits(noProgramRow);
|
|
|
|
await setPortalChannelItemWidth(app.mainWindow, 200);
|
|
|
|
await expect(currentTitle).toHaveText(currentProgram.title);
|
|
await expect(currentTitle).toBeVisible();
|
|
await expect(progressTrack).toBeVisible();
|
|
await expect(favoriteAction).toBeVisible();
|
|
await expect(placeholder).toBeVisible();
|
|
await expect(logo).toBeHidden();
|
|
await expect(timeLabels.first()).toBeHidden();
|
|
await expect(timeLabels.last()).toBeHidden();
|
|
await expectPortalRowHeightAndStride(currentProgramRow, 'Night Sports');
|
|
await expectPortalRowHeightAndStride(noProgramRow, 'Archive Cinema');
|
|
await expectNarrowRowContentFits(currentProgramRow);
|
|
await expectNarrowRowContentFits(noProgramRow);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('@radio @stalker @electron keeps radio rows compact at narrow widths', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['stalker']);
|
|
const fixture = await fetchStalkerCategoryFixture(request, 'radio');
|
|
const [firstItem, secondItem] = fixture.items;
|
|
const firstTitle = firstItem?.o_name || firstItem?.name;
|
|
const secondTitle = secondItem?.o_name || secondItem?.name;
|
|
if (!firstTitle || !secondTitle) {
|
|
throw new Error(
|
|
'Expected the Stalker radio fixture to include two named stations.'
|
|
);
|
|
}
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await addStalkerPortal(app.mainWindow, {
|
|
name: 'Stalker Radio Row Fixture',
|
|
});
|
|
await waitForStalkerCatalog(app.mainWindow);
|
|
await openWorkspaceSection(app.mainWindow, 'Radio');
|
|
const categoryButton = app.mainWindow.getByRole('button', {
|
|
name: fixture.categoryName,
|
|
exact: true,
|
|
});
|
|
await expect(categoryButton).toBeVisible();
|
|
await categoryButton.click();
|
|
|
|
const radioRow = channelItemByTitle(
|
|
app.mainWindow,
|
|
firstTitle
|
|
).first();
|
|
await expect(radioRow).toBeVisible({ timeout: 20000 });
|
|
await expect(radioRow).toHaveClass(/compact/);
|
|
await expect(radioRow.locator('.epg-placeholder')).toHaveCount(0);
|
|
await expect(radioRow.locator('.epg-title')).toHaveCount(0);
|
|
await expect(radioRow.locator('.epg-timeline')).toHaveCount(0);
|
|
await expectCompactRadioRowHeightAndStride(radioRow, secondTitle);
|
|
|
|
await setStalkerChannelItemWidth(app.mainWindow, 232);
|
|
|
|
await expect(radioRow.locator('.channel-logo-shell')).toBeVisible();
|
|
await expect(radioRow.locator('.favorite-button')).toBeVisible();
|
|
await expectCompactRadioRowHeightAndStride(radioRow, secondTitle);
|
|
|
|
await setStalkerChannelItemWidth(app.mainWindow, 200);
|
|
|
|
await expect(radioRow.locator('.channel-name')).toBeVisible();
|
|
await expect(radioRow.locator('.channel-logo-shell')).toBeHidden();
|
|
await expect(radioRow.locator('.favorite-button')).toBeHidden();
|
|
await expectCompactRadioRowHeightAndStride(radioRow, secondTitle);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('@epg @xtream @electron renders the vertical list view when the setting is "list"', 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);
|
|
|
|
try {
|
|
// Opt into the list view first (from the fresh workspace) so the portal
|
|
// → Live TV → channel flow afterwards mirrors the timeline test exactly.
|
|
await openSettings(app.mainWindow);
|
|
await openSettingsSection(app.mainWindow, 'epg');
|
|
await app.mainWindow
|
|
.locator('[data-test-id="epg-view-mode-list"]')
|
|
.click();
|
|
await saveSettings(app.mainWindow);
|
|
await goToDashboard(app.mainWindow);
|
|
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: `${epgPortalName} List`,
|
|
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();
|
|
|
|
// The list view renders instead of the timeline.
|
|
await expect(app.mainWindow.locator('app-epg-list-view')).toBeVisible({
|
|
timeout: 20000,
|
|
});
|
|
await expect(app.mainWindow.locator('app-epg-timeline')).toHaveCount(0);
|
|
|
|
// The on-air programme is the highlighted "now" row.
|
|
await expect(
|
|
app.mainWindow
|
|
.locator('app-epg-list-view .g-row[data-when="now"] .title')
|
|
.first()
|
|
).toHaveText(currentProgram.title);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
function formatTimeInZone(timestampSeconds: number, timeZone: string): string {
|
|
return new Intl.DateTimeFormat('en-US', {
|
|
timeZone,
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
}).format(new Date(timestampSeconds * 1000));
|
|
}
|
|
|
|
async function setPortalChannelItemWidth(
|
|
page: Parameters<typeof channelItemByTitle>[0],
|
|
width: number
|
|
) {
|
|
const channelItems = page.locator(
|
|
'app-portal-channels-list app-channel-list-item'
|
|
);
|
|
await channelItems.evaluateAll((elements, itemWidth) => {
|
|
for (const element of elements) {
|
|
(element as HTMLElement).style.width = `${itemWidth}px`;
|
|
}
|
|
}, width);
|
|
await expect
|
|
.poll(() =>
|
|
channelItems
|
|
.first()
|
|
.evaluate((element) =>
|
|
Math.round(element.getBoundingClientRect().width)
|
|
)
|
|
)
|
|
.toBe(width);
|
|
}
|
|
|
|
async function setStalkerChannelItemWidth(
|
|
page: Parameters<typeof channelItemByTitle>[0],
|
|
width: number
|
|
) {
|
|
const channelItems = page.locator(
|
|
'app-stalker-live-stream-layout app-channel-list-item'
|
|
);
|
|
await channelItems.evaluateAll((elements, itemWidth) => {
|
|
for (const element of elements) {
|
|
(element as HTMLElement).style.width = `${itemWidth}px`;
|
|
}
|
|
}, width);
|
|
await expect
|
|
.poll(() =>
|
|
channelItems
|
|
.first()
|
|
.evaluate((element) =>
|
|
Math.round(element.getBoundingClientRect().width)
|
|
)
|
|
)
|
|
.toBe(width);
|
|
}
|
|
|
|
const portalChannelItemSize = 68;
|
|
const compactRadioItemSize = 52;
|
|
const stalkerChannelItemGap = 2;
|
|
const rowGeometryTolerance = 0.25;
|
|
const contentGeometryTolerance = 1;
|
|
|
|
type ElementBox = {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
async function expectPortalRowHeightAndStride(
|
|
row: ReturnType<typeof channelItemByTitle>,
|
|
expectedFollowingTitle: string
|
|
) {
|
|
const followingRow = row
|
|
.locator('xpath=ancestor::app-channel-list-item')
|
|
.locator('xpath=following-sibling::app-channel-list-item[1]')
|
|
.getByTestId('channel-item');
|
|
await expect(followingRow.locator('.channel-name')).toHaveText(
|
|
expectedFollowingTitle
|
|
);
|
|
|
|
const [rowBox, followingRowBox] = await Promise.all([
|
|
row.boundingBox(),
|
|
followingRow.boundingBox(),
|
|
]);
|
|
if (!rowBox || !followingRowBox) {
|
|
throw new Error(
|
|
`Expected visible channel-row geometry before "${expectedFollowingTitle}" for the virtual-stride assertion.`
|
|
);
|
|
}
|
|
|
|
expect(Math.abs(rowBox.height - portalChannelItemSize)).toBeLessThanOrEqual(
|
|
rowGeometryTolerance
|
|
);
|
|
expect(
|
|
Math.abs(followingRowBox.y - rowBox.y - portalChannelItemSize)
|
|
).toBeLessThanOrEqual(rowGeometryTolerance);
|
|
}
|
|
|
|
async function expectCompactRadioRowHeightAndStride(
|
|
row: ReturnType<typeof channelItemByTitle>,
|
|
expectedFollowingTitle: string
|
|
) {
|
|
const followingRow = row
|
|
.locator('xpath=ancestor::app-channel-list-item')
|
|
.locator('xpath=following-sibling::app-channel-list-item[1]')
|
|
.getByTestId('channel-item');
|
|
await expect(followingRow.locator('.channel-name')).toHaveText(
|
|
expectedFollowingTitle
|
|
);
|
|
|
|
const [rowBox, followingRowBox] = await Promise.all([
|
|
row.boundingBox(),
|
|
followingRow.boundingBox(),
|
|
]);
|
|
if (!rowBox || !followingRowBox) {
|
|
throw new Error(
|
|
`Expected compact radio-row geometry before "${expectedFollowingTitle}".`
|
|
);
|
|
}
|
|
|
|
expect(Math.abs(rowBox.height - compactRadioItemSize)).toBeLessThanOrEqual(
|
|
rowGeometryTolerance
|
|
);
|
|
expect(
|
|
Math.abs(
|
|
followingRowBox.y -
|
|
rowBox.y -
|
|
compactRadioItemSize -
|
|
stalkerChannelItemGap
|
|
)
|
|
).toBeLessThanOrEqual(rowGeometryTolerance);
|
|
}
|
|
|
|
async function expectTimelineStartAndProgressAligned(
|
|
row: ReturnType<typeof channelItemByTitle>
|
|
) {
|
|
const [startTimeBox, progressBox] = await Promise.all([
|
|
row.locator('.epg-time').first().boundingBox(),
|
|
row.locator('.epg-progress-track').first().boundingBox(),
|
|
]);
|
|
if (!startTimeBox || !progressBox) {
|
|
throw new Error(
|
|
'Expected visible start-time and progress geometry in the narrow channel row.'
|
|
);
|
|
}
|
|
|
|
expect(
|
|
Math.abs(boxCenterY(startTimeBox) - boxCenterY(progressBox))
|
|
).toBeLessThanOrEqual(contentGeometryTolerance);
|
|
}
|
|
|
|
async function expectNarrowRowContentFits(
|
|
row: ReturnType<typeof channelItemByTitle>
|
|
) {
|
|
const [rowBox, detailsBox, actionsBox] = await Promise.all([
|
|
row.boundingBox(),
|
|
row.locator('.channel-details').boundingBox(),
|
|
row.locator('.action-buttons').boundingBox(),
|
|
]);
|
|
|
|
if (!rowBox || !detailsBox || !actionsBox) {
|
|
throw new Error(
|
|
'Expected visible narrow channel-row, details, and action geometry.'
|
|
);
|
|
}
|
|
|
|
expectBoxContainedWithin(detailsBox, rowBox);
|
|
expectBoxContainedWithin(actionsBox, rowBox);
|
|
expect(boxRight(detailsBox)).toBeLessThanOrEqual(
|
|
actionsBox.x + contentGeometryTolerance
|
|
);
|
|
await expectVisibleElementsContainedWithin(
|
|
row.locator(
|
|
'.channel-details > .channel-name:visible, .channel-details > .epg-title:visible, .channel-details > .epg-placeholder:visible, .channel-details > .epg-timeline:visible'
|
|
),
|
|
detailsBox,
|
|
'channel-details child'
|
|
);
|
|
await expectVisibleElementsContainedWithin(
|
|
row.locator('.action-buttons > button:visible'),
|
|
actionsBox,
|
|
'channel action button'
|
|
);
|
|
|
|
const progressTrack = row.locator('.epg-progress-track');
|
|
if ((await progressTrack.count()) > 0) {
|
|
const progressBox = await progressTrack.first().boundingBox();
|
|
if (!progressBox) {
|
|
throw new Error(
|
|
'Expected visible progress-track geometry in the narrow channel row.'
|
|
);
|
|
}
|
|
expectBoxContainedWithin(progressBox, detailsBox);
|
|
expect(progressBox.width).toBeGreaterThanOrEqual(24);
|
|
}
|
|
}
|
|
|
|
async function expectVisibleElementsContainedWithin(
|
|
elements: Locator,
|
|
outer: ElementBox,
|
|
description: string
|
|
) {
|
|
const count = await elements.count();
|
|
if (count === 0) {
|
|
throw new Error(`Expected at least one visible ${description}.`);
|
|
}
|
|
|
|
for (let index = 0; index < count; index += 1) {
|
|
const elementBox = await elements.nth(index).boundingBox();
|
|
if (!elementBox) {
|
|
throw new Error(
|
|
`Expected visible ${description} geometry at index ${index}.`
|
|
);
|
|
}
|
|
expectBoxContainedWithin(elementBox, outer);
|
|
}
|
|
}
|
|
|
|
function expectBoxContainedWithin(inner: ElementBox, outer: ElementBox) {
|
|
expect(inner.x).toBeGreaterThanOrEqual(outer.x - contentGeometryTolerance);
|
|
expect(inner.y).toBeGreaterThanOrEqual(outer.y - contentGeometryTolerance);
|
|
expect(boxRight(inner)).toBeLessThanOrEqual(
|
|
boxRight(outer) + contentGeometryTolerance
|
|
);
|
|
expect(boxBottom(inner)).toBeLessThanOrEqual(
|
|
boxBottom(outer) + contentGeometryTolerance
|
|
);
|
|
}
|
|
|
|
function boxRight(box: ElementBox) {
|
|
return box.x + box.width;
|
|
}
|
|
|
|
function boxBottom(box: ElementBox) {
|
|
return box.y + box.height;
|
|
}
|
|
|
|
function boxCenterY(box: ElementBox) {
|
|
return box.y + box.height / 2;
|
|
}
|
|
|
|
async function timelineBlockTitles(
|
|
page: Parameters<typeof channelItemByTitle>[0]
|
|
) {
|
|
return page
|
|
.locator('app-epg-timeline .epg-timeline__block-title')
|
|
.allInnerTexts()
|
|
.then((titles) => titles.map((title) => title.trim()).filter(Boolean));
|
|
}
|
|
|
|
async function getProgressWidthPercent(
|
|
row: ReturnType<typeof channelItemByTitle>
|
|
) {
|
|
const style = await row.locator('.epg-progress-fill').getAttribute('style');
|
|
const width = style?.match(/width:\s*([\d.]+)%/)?.[1] ?? '0';
|
|
return Number.parseFloat(width);
|
|
}
|