mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
feat(ui): cinematic movie and series details pages and dashboard hero (#1792)
This commit is contained in:
1 parent
07c5dffab0
commit
ab8460338a
213 files changed
+12494
-3930
No files matched your search
@@ -244,10 +244,18 @@ export async function expectSeriesSurfacesInBothThemes(
|
||||
const shell = page.locator('app-portal-detail-shell');
|
||||
await expect
|
||||
.poll(() =>
|
||||
rasterizedBorderContrast(shell.locator('.favorite-btn').first())
|
||||
rasterizedBorderContrast(
|
||||
shell
|
||||
.locator('[data-testid="series-favorite-toggle"]')
|
||||
.first()
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(1.1);
|
||||
for (const selector of ['.episode-card', 'mat-button-toggle-group']) {
|
||||
// The flat card's visible edge is its artwork's hairline.
|
||||
for (const selector of [
|
||||
'.episode-card__thumbnail',
|
||||
'mat-button-toggle-group',
|
||||
]) {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
@@ -278,3 +286,24 @@ export async function expectSeriesSurfacesInBothThemes(
|
||||
.click();
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the series "…" menu (if closed) and returns the row with that test id. */
|
||||
export async function seriesMenuRow(page: Page, rowTestId: string) {
|
||||
const row = page.locator(`[data-test-id="${rowTestId}"]`);
|
||||
if (!(await row.isVisible().catch(() => false))) {
|
||||
await page.locator('[data-testid="series-more-menu"]').click();
|
||||
}
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Closes an open series menu without choosing a row. */
|
||||
export async function closeSeriesMenu(page: Page): Promise<void> {
|
||||
// The panel, not the backdrop: a backdrop locator can match a second
|
||||
// overlay and then fail the strict check, leaving the menu open.
|
||||
const panel = page.locator('[data-test-id="vod-more-menu"]');
|
||||
if (await panel.isVisible().catch(() => false)) {
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
await expect(panel).toBeHidden({ timeout: 10_000 });
|
||||
}
|
||||
@@ -393,7 +393,7 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(inlineVideo(page)).toHaveCount(0);
|
||||
const playButton = page.locator('[data-test-id="m3u-vod-play"]');
|
||||
const playButton = page.locator('[data-testid="m3u-vod-play"]');
|
||||
await expect(playButton).toBeVisible();
|
||||
|
||||
// Browse → Play remounts the engine without a channel change.
|
||||
@@ -417,12 +417,13 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
|
||||
.click();
|
||||
await expect(inlineVideo(page)).toHaveCount(0);
|
||||
await expect(headerBack).toHaveCount(0);
|
||||
// The hero keeps its own inset.
|
||||
// The hero keeps its own inset (32px, or 20px in a pane narrower than
|
||||
// 760px) in both states.
|
||||
expect(
|
||||
await shell
|
||||
.locator('.hero__content')
|
||||
.evaluate((el) => getComputedStyle(el).paddingInlineStart)
|
||||
).toBe('40px');
|
||||
).toMatch(/^(20|32)px$/);
|
||||
await shell.focus();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(playButton).toBeVisible();
|
||||
|
||||
@@ -29,9 +29,7 @@ async function addXtreamPortal(page: Page): Promise<void> {
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
|
||||
await dialog
|
||||
.getByRole('radio', { name: /Xtream credentials/i })
|
||||
.click();
|
||||
await dialog.getByRole('radio', { name: /Xtream credentials/i }).click();
|
||||
|
||||
await dialog.locator('#title').fill('Self-hosted Xtream');
|
||||
await dialog.locator('#serverUrl').fill(XTREAM_MOCK_SERVER);
|
||||
@@ -165,9 +163,9 @@ test('@self-hosted Xtream portal loads through web-backend proxy', async ({
|
||||
await expect(vodItem).toBeVisible({ timeout: 30_000 });
|
||||
await vodItem.click();
|
||||
await expect(page).toHaveURL(/\/workspace\/xtreams\/[^/]+\/vod\/\d+\/\d+/);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Play', exact: true })
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
const primaryAction = page.locator('[data-testid="vod-primary-action"]');
|
||||
await expect(primaryAction).toBeVisible({ timeout: 15_000 });
|
||||
await expect(primaryAction).toContainText('Play');
|
||||
|
||||
expect(
|
||||
consoleErrors.filter((message) =>
|
||||
@@ -262,9 +260,9 @@ test('@self-hosted M3U User-Agent reaches the provider on import and refresh', a
|
||||
const row = sourceRowByTitle(page, 'Protected M3U');
|
||||
await row.hover();
|
||||
await row.locator('.refresh-btn').click();
|
||||
await expect(page.locator('.mat-mdc-snack-bar-label').last()).toContainText(
|
||||
'updated'
|
||||
);
|
||||
await expect(
|
||||
page.locator('.mat-mdc-snack-bar-label').last()
|
||||
).toContainText('updated');
|
||||
await page.goto(catalogUrl);
|
||||
await expect(page.getByText('1. Refreshed UA Channel')).toBeVisible();
|
||||
expect(received.slice(1)).toEqual([userAgent, userAgent]);
|
||||
|
||||
@@ -147,6 +147,7 @@ export async function verifyStalkerSeasonMarkers(
|
||||
'src',
|
||||
new RegExp(`/season-${season}\\.jpg$`)
|
||||
);
|
||||
await page.locator('[data-testid="series-more-menu"]').click();
|
||||
await page.locator('[data-test-id="toggle-season-watched"]').click();
|
||||
await expect(page.locator('.episode-card--watched')).toHaveCount(1);
|
||||
await page.getByRole('button', { name: 'Back', exact: true }).click();
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { type APIRequestContext, type Page } from '@playwright/test';
|
||||
import { expectSeriesSurfacesInBothThemes, setInputValue } from './e2e-helpers';
|
||||
import {
|
||||
closeSeriesMenu,
|
||||
expectSeriesSurfacesInBothThemes,
|
||||
seriesMenuRow,
|
||||
setInputValue,
|
||||
} from './e2e-helpers';
|
||||
import {
|
||||
verifyStalkerCategorySearch,
|
||||
verifyStalkerPlaybackCategoryReturn,
|
||||
@@ -1110,10 +1115,9 @@ test('@stalker season watched toggle — embedded series marks and clears every
|
||||
|
||||
await expectSeriesSurfacesInBothThemes(page, testInfo);
|
||||
|
||||
// The season header's bulk toggle (data-test-id with a dash — getByTestId
|
||||
// The hero menu's bulk toggle (data-test-id with a dash — getByTestId
|
||||
// only matches data-testid in this suite) counts every unwatched episode.
|
||||
const seasonToggle = page.locator('[data-test-id="toggle-season-watched"]');
|
||||
await expect(seasonToggle).toBeVisible();
|
||||
let seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
|
||||
await expect(seasonToggle).toContainText(
|
||||
`Mark season as watched (${episodeCount})`
|
||||
);
|
||||
@@ -1122,25 +1126,25 @@ test('@stalker season watched toggle — embedded series marks and clears every
|
||||
|
||||
await seasonToggle.click();
|
||||
|
||||
// All episodes get full-progress positions: the button flips and every
|
||||
// All episodes get full-progress positions: the row flips and every
|
||||
// episode card plus per-episode toggle shows the watched state.
|
||||
await expect(seasonToggle).toContainText('Mark season as unwatched', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(watchedCards).toHaveCount(episodeCount);
|
||||
await expect(watchedCards).toHaveCount(episodeCount, { timeout: 15_000 });
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-testid="episode-watched-toggle"].episode-card__watched-toggle--watched'
|
||||
)
|
||||
).toHaveCount(episodeCount);
|
||||
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
|
||||
await expect(seasonToggle).toContainText('Mark season as unwatched');
|
||||
|
||||
// Second click clears every episode's position again.
|
||||
await seasonToggle.click();
|
||||
await expect(watchedCards).toHaveCount(0, { timeout: 15_000 });
|
||||
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
|
||||
await expect(seasonToggle).toContainText(
|
||||
`Mark season as watched (${episodeCount})`,
|
||||
{ timeout: 15_000 }
|
||||
`Mark season as watched (${episodeCount})`
|
||||
);
|
||||
await expect(watchedCards).toHaveCount(0);
|
||||
await closeSeriesMenu(page);
|
||||
});
|
||||
|
||||
test('@stalker series watched toggle — embedded series marks and clears from the header menu', async ({
|
||||
@@ -1179,14 +1183,10 @@ test('@stalker series watched toggle — embedded series marks and clears from t
|
||||
})
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The ⋮ trigger sits after the view toggle (data-test-id with a dash —
|
||||
// getByTestId only matches data-testid in this suite; the menu item
|
||||
// renders into the CDK overlay).
|
||||
const menuTrigger = page.locator('[data-test-id="series-watch-menu"]');
|
||||
await expect(menuTrigger).toBeVisible();
|
||||
await menuTrigger.click();
|
||||
const seriesToggle = page.locator('[data-test-id="toggle-series-watched"]');
|
||||
await expect(seriesToggle).toBeVisible();
|
||||
// The series row sits in the hero's "…" menu (data-test-id with a dash —
|
||||
// getByTestId only matches data-testid in this suite; the row renders
|
||||
// into the CDK overlay).
|
||||
let seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
|
||||
await expect(seriesToggle).toContainText(
|
||||
`Mark series as watched (${episodeCount})`
|
||||
);
|
||||
@@ -1198,12 +1198,12 @@ test('@stalker series watched toggle — embedded series marks and clears from t
|
||||
await expect(watchedCards).toHaveCount(episodeCount, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await menuTrigger.click();
|
||||
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
|
||||
await expect(seriesToggle).toContainText('Mark series as unwatched');
|
||||
await seriesToggle.click();
|
||||
|
||||
await expect(watchedCards).toHaveCount(0, { timeout: 15_000 });
|
||||
await menuTrigger.click();
|
||||
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
|
||||
await expect(seriesToggle).toContainText(
|
||||
`Mark series as watched (${episodeCount})`
|
||||
);
|
||||
@@ -1211,7 +1211,7 @@ test('@stalker series watched toggle — embedded series marks and clears from t
|
||||
const detailUrl = page.url();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(seriesToggle).toBeHidden();
|
||||
await menuTrigger.focus();
|
||||
await page.locator('[data-testid="series-more-menu"]').focus();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('app-portal-detail-shell')).toHaveCount(0);
|
||||
await expect(page).toHaveURL(detailUrl);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { APIRequestContext, Page } from '@playwright/test';
|
||||
import { expect, test } from './fixtures';
|
||||
import {
|
||||
closeSeriesMenu,
|
||||
pressTab,
|
||||
rasterizedBorderContrast,
|
||||
seriesMenuRow,
|
||||
setInputValue,
|
||||
surfaceContrast,
|
||||
waitForScrollIdle,
|
||||
@@ -656,24 +658,26 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle
|
||||
|
||||
// Serial details: default scenario has 3 seasons × 8 episodes and no
|
||||
// playback positions yet, so season 1 is auto-selected fully unwatched.
|
||||
const seasonToggle = page.locator('[data-test-id="toggle-season-watched"]');
|
||||
await expect(seasonToggle).toBeVisible({ timeout: 15_000 });
|
||||
// The season toggle is a row of the hero's "…" menu.
|
||||
let seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
|
||||
await expect(seasonToggle).toContainText('Mark season as watched (8)');
|
||||
await closeSeriesMenu(page);
|
||||
|
||||
const episodeCards = page.locator('.episode-card');
|
||||
await expect(episodeCards).toHaveCount(8, { timeout: 10_000 });
|
||||
const watchedCards = page.locator('.episode-card--watched');
|
||||
await expect(watchedCards).toHaveCount(0);
|
||||
|
||||
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
|
||||
await seasonToggle.click();
|
||||
|
||||
// Full-progress rows land for all 8 episodes: the button flips, every
|
||||
// Full-progress rows land for all 8 episodes: the row flips, every
|
||||
// episode card gets the watched state, and the per-episode toggles show
|
||||
// the filled check.
|
||||
await expect(seasonToggle).toContainText('Mark season as unwatched', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(watchedCards).toHaveCount(8);
|
||||
await expect(watchedCards).toHaveCount(8, { timeout: 15_000 });
|
||||
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
|
||||
await expect(seasonToggle).toContainText('Mark season as unwatched');
|
||||
await closeSeriesMenu(page);
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-testid="episode-watched-toggle"].episode-card__watched-toggle--watched'
|
||||
@@ -696,26 +700,31 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle
|
||||
// fresh mount auto-selects the earliest season with unwatched episodes
|
||||
// (issue #1441), so season 2 opens while season 1's tab keeps the check.
|
||||
await page.reload();
|
||||
await expect(seasonToggle).toBeVisible({ timeout: 20_000 });
|
||||
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
|
||||
await expect(seasonToggle).toContainText('Mark season as watched (8)');
|
||||
await closeSeriesMenu(page);
|
||||
await expect(watchedCards).toHaveCount(0, { timeout: 10_000 });
|
||||
await expect(
|
||||
seasonTabs.first().locator('.season-tabs__done')
|
||||
).toBeVisible();
|
||||
|
||||
// Season 1 itself still holds the 8 persisted rows.
|
||||
// Season 1 itself still holds the 8 persisted rows. The row lives in
|
||||
// the overlay, so it is reacquired after every close.
|
||||
await seasonTabs.first().click();
|
||||
await expect(watchedCards).toHaveCount(8, { timeout: 10_000 });
|
||||
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
|
||||
await expect(seasonToggle).toContainText('Mark season as unwatched', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(watchedCards).toHaveCount(8, { timeout: 10_000 });
|
||||
|
||||
// Second click clears every episode's position again.
|
||||
await seasonToggle.click();
|
||||
await expect(watchedCards).toHaveCount(0, { timeout: 15_000 });
|
||||
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
|
||||
await expect(seasonToggle).toContainText('Mark season as watched (8)', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(watchedCards).toHaveCount(0);
|
||||
await closeSeriesMenu(page);
|
||||
await expect(page.locator('.season-tabs__done')).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -830,17 +839,13 @@ test('@xtream series watched toggle — marks every season from the header menu,
|
||||
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
|
||||
await seriesCard.click();
|
||||
|
||||
// The ⋮ series menu sits at the end of the season-header actions row
|
||||
// (data-test-id with a dash — getByTestId only matches data-testid in
|
||||
// this suite; the menu item renders into the CDK overlay).
|
||||
const menuTrigger = page.locator('[data-test-id="series-watch-menu"]');
|
||||
await expect(menuTrigger).toBeVisible({ timeout: 15_000 });
|
||||
// The series row sits in the hero's "…" menu (data-test-id with a dash —
|
||||
// getByTestId only matches data-testid in this suite; the row renders
|
||||
// into the CDK overlay).
|
||||
const seasonTabs = page.locator('.season-tabs__pill');
|
||||
await expect(seasonTabs).toHaveCount(3);
|
||||
|
||||
await menuTrigger.click();
|
||||
const seriesToggle = page.locator('[data-test-id="toggle-series-watched"]');
|
||||
await expect(seriesToggle).toBeVisible();
|
||||
let seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
|
||||
await expect(seriesToggle).toContainText('Mark series as watched (24)');
|
||||
await seriesToggle.click();
|
||||
|
||||
@@ -852,27 +857,29 @@ test('@xtream series watched toggle — marks every season from the header menu,
|
||||
await expect(page.locator('.episode-card--watched')).toHaveCount(8);
|
||||
|
||||
// The action now offers unwatch-all.
|
||||
await menuTrigger.click();
|
||||
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
|
||||
await expect(seriesToggle).toContainText('Mark series as unwatched');
|
||||
await page.keyboard.press('Escape');
|
||||
await closeSeriesMenu(page);
|
||||
|
||||
// PWA persistence: positions live in localStorage, so a reload must come
|
||||
// back fully watched across all seasons.
|
||||
await page.reload();
|
||||
await expect(menuTrigger).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator('[data-testid="series-more-menu"]')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.locator('.season-tabs__done')).toHaveCount(3, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Unwatch-all clears every season again.
|
||||
await menuTrigger.click();
|
||||
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
|
||||
await expect(seriesToggle).toContainText('Mark series as unwatched');
|
||||
await seriesToggle.click();
|
||||
await expect(page.locator('.season-tabs__done')).toHaveCount(0, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.locator('.episode-card--watched')).toHaveCount(0);
|
||||
await menuTrigger.click();
|
||||
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
|
||||
await expect(seriesToggle).toContainText('Mark series as watched (24)');
|
||||
});
|
||||
|
||||
@@ -989,8 +996,12 @@ for (const theme of ['light', 'dark']) {
|
||||
(dark) => document.body.classList.toggle('dark-theme', dark),
|
||||
theme === 'dark'
|
||||
);
|
||||
const favorite = shell.locator('.favorite-btn').first();
|
||||
const favorite = shell
|
||||
.locator('[data-testid="series-favorite-toggle"]')
|
||||
.first();
|
||||
const card = shell.locator('.episode-card').first();
|
||||
// The card is flat; its visible edge is the artwork's hairline.
|
||||
const artwork = card.locator('.episode-card__thumbnail');
|
||||
const toggle = shell.locator('mat-button-toggle-group');
|
||||
await expect(card).toBeVisible();
|
||||
await expect(shell.locator('.hero__content')).toHaveCSS('opacity', '1');
|
||||
@@ -1000,7 +1011,7 @@ for (const theme of ['light', 'dark']) {
|
||||
await expect
|
||||
.poll(() => rasterizedBorderContrast(favorite))
|
||||
.toBeGreaterThan(1.1);
|
||||
for (const surface of [card, toggle]) {
|
||||
for (const surface of [artwork, toggle]) {
|
||||
await expect
|
||||
.poll(async () => (await surfaceContrast(surface)).border)
|
||||
.toBeGreaterThan(1.15);
|
||||
@@ -1015,7 +1026,7 @@ for (const theme of ['light', 'dark']) {
|
||||
});
|
||||
await card.hover();
|
||||
await expect
|
||||
.poll(async () => (await surfaceContrast(card)).border)
|
||||
.poll(async () => (await surfaceContrast(artwork)).border)
|
||||
.toBeGreaterThan(1.15);
|
||||
await page
|
||||
.getByRole('radio', { name: 'List view', exact: true })
|
||||
|
||||
Reference in new issue
Block a user