mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
fix(playback): apply themes to player and EPG panels
This commit is contained in:
1 parent
eba68d687e
commit
2a1c04f8a8
17 files changed
+803
-191
No files matched your search
@@ -1,16 +1,30 @@
|
||||
import {
|
||||
addXtreamPortal,
|
||||
addStalkerPortal,
|
||||
waitForStalkerCatalog,
|
||||
channelItemByTitle,
|
||||
clickCategoryByNameExact,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openWorkspaceSection,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
saveSettings,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import { fetchXtreamEpgFixture } from './portal-mock-fixtures';
|
||||
import {
|
||||
fetchXtreamEpgFixture,
|
||||
fetchStalkerCategoryFixture,
|
||||
} from './portal-mock-fixtures';
|
||||
|
||||
import {
|
||||
applyTheme,
|
||||
expectTextContrast,
|
||||
expectThemeSurface,
|
||||
} from './theme-contrast';
|
||||
|
||||
const epgCredentials = {
|
||||
username: 'epg',
|
||||
@@ -32,6 +46,13 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an
|
||||
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,
|
||||
@@ -76,6 +97,40 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an
|
||||
const maxZoomWidth = await blockWidthAt('max');
|
||||
expect(maxZoomWidth).toBeGreaterThan(minZoomWidth);
|
||||
|
||||
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.
|
||||
@@ -87,14 +142,87 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an
|
||||
currentProgram.title
|
||||
);
|
||||
// An on-air programme offers "watch live" as the primary action.
|
||||
await expect(
|
||||
dialog.locator('.epg-dialog__btn--primary')
|
||||
).toBeVisible();
|
||||
await expect(dialog.locator('.epg-dialog__btn--primary')).toBeVisible();
|
||||
|
||||
for (const theme of ['light', 'dark', 'light'] as const) {
|
||||
await applyTheme(app.mainWindow, theme);
|
||||
await expectThemeSurface(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 dialog.locator('.epg-dialog__close').click();
|
||||
await app.mainWindow.waitForSelector('.epg-dialog', {
|
||||
state: 'detached',
|
||||
});
|
||||
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()
|
||||
);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import {
|
||||
channelItemByTitle,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
goToDashboard,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openGlobalFavorites,
|
||||
openGlobalRecent,
|
||||
openSettingsSection,
|
||||
saveSettings,
|
||||
test,
|
||||
workspaceRoot,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
createLocalMediaServer,
|
||||
installEmbeddedMpvSessionCapture,
|
||||
} from './embedded-mpv-frame-copy-packaged-fixtures';
|
||||
import {
|
||||
applyTheme,
|
||||
expectTextContrast,
|
||||
expectThemeSurface,
|
||||
expectOverlayContrastOnWhite,
|
||||
} from './theme-contrast';
|
||||
|
||||
for (const engine of [
|
||||
'native',
|
||||
'frame-copy',
|
||||
'html5',
|
||||
'videojs',
|
||||
'artplayer',
|
||||
] as const) {
|
||||
test(`@playback @theme @electron ${engine} keeps controls readable across live theme and fullscreen changes`, async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const embedded = engine === 'native' || engine === 'frame-copy';
|
||||
const media = embedded ? await createLocalMediaServer() : undefined;
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
env: {
|
||||
IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT: '1',
|
||||
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY:
|
||||
engine === 'frame-copy' ? '1' : '0',
|
||||
IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW: '1',
|
||||
},
|
||||
});
|
||||
try {
|
||||
if (embedded) {
|
||||
const support = await app.mainWindow.evaluate(() =>
|
||||
window.electron.getEmbeddedMpvSupport()
|
||||
);
|
||||
test.skip(
|
||||
!support.supported || support.engine !== engine,
|
||||
`Requested ${engine} runtime unavailable: ${JSON.stringify(support)}`
|
||||
);
|
||||
}
|
||||
await openSettings(app.mainWindow);
|
||||
await app.mainWindow.getByTestId('LIGHT_THEME').click();
|
||||
await openSettingsSection(app.mainWindow, 'playback');
|
||||
await app.mainWindow.getByTestId('select-video-player').click();
|
||||
await app.mainWindow
|
||||
.getByTestId(embedded ? 'embedded-mpv' : engine)
|
||||
.click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await goToDashboard(app.mainWindow);
|
||||
const playlist = join(dataDir, 'theme.m3u');
|
||||
const url =
|
||||
media?.url ??
|
||||
pathToFileURL(
|
||||
join(
|
||||
workspaceRoot,
|
||||
'apps/web-e2e/src/fixtures/playback/episode.webm'
|
||||
)
|
||||
).href;
|
||||
writeFileSync(
|
||||
playlist,
|
||||
`#EXTM3U\n#EXTINF:-1,Theme fixture\n${url}\n`
|
||||
);
|
||||
if (embedded) await installEmbeddedMpvSessionCapture(app);
|
||||
await importM3uPlaylistFromNativeDialog(app, playlist);
|
||||
if (engine === 'html5') {
|
||||
await channelItemByTitle(app.mainWindow, 'Theme fixture')
|
||||
.first()
|
||||
.locator('.favorite-button')
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
await channelItemByTitle(app.mainWindow, 'Theme fixture')
|
||||
.first()
|
||||
.click();
|
||||
if (engine === 'frame-copy') {
|
||||
// The experimental local runtime can fail its first frame-view
|
||||
// initialization. Cover the themed error UI and one user Retry;
|
||||
// successful decoding is still required below.
|
||||
await expect
|
||||
.poll(() =>
|
||||
app.mainWindow.evaluate(
|
||||
() =>
|
||||
(window.__packagedEmbeddedMpvSessions?.at(-1)
|
||||
?.positionSeconds ?? 0) > 0 ||
|
||||
!!document.querySelector(
|
||||
'.embedded-mpv-player__stalled'
|
||||
)
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
const stalled = app.mainWindow.locator(
|
||||
'.embedded-mpv-player__stalled'
|
||||
);
|
||||
if (await stalled.isVisible()) {
|
||||
test.info().annotations.push({
|
||||
type: 'runtime-retry',
|
||||
description:
|
||||
'Frame-copy initialization required one user Retry before theme checks.',
|
||||
});
|
||||
for (const theme of ['dark', 'light'] as const) {
|
||||
await applyTheme(app.mainWindow, theme);
|
||||
await expectTextContrast(stalled.locator('p'));
|
||||
await expectTextContrast(
|
||||
stalled.getByRole('button', { name: 'Retry' })
|
||||
);
|
||||
}
|
||||
await stalled
|
||||
.getByRole('button', { name: 'Retry' })
|
||||
.click();
|
||||
}
|
||||
}
|
||||
if (embedded) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
app.mainWindow.evaluate(
|
||||
() =>
|
||||
window.__packagedEmbeddedMpvSessions?.at(-1)
|
||||
?.positionSeconds ?? 0
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
} else {
|
||||
await expect
|
||||
.poll(() =>
|
||||
app.mainWindow
|
||||
.locator('app-web-player-view video')
|
||||
.evaluate(
|
||||
(video: HTMLVideoElement) => video.currentTime
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
const controls = app.mainWindow.locator(
|
||||
engine === 'native'
|
||||
? '.embedded-mpv-player__controls'
|
||||
: 'app-player-controls'
|
||||
);
|
||||
const pause = controls.getByRole('button', {
|
||||
name: 'Pause',
|
||||
exact: true,
|
||||
});
|
||||
await expect(pause).toBeEnabled({ timeout: 20000 });
|
||||
await pause.click();
|
||||
const play = controls.getByRole('button', {
|
||||
name: 'Play',
|
||||
exact: true,
|
||||
});
|
||||
await expect(play).toBeVisible();
|
||||
for (const fullscreen of [false, true]) {
|
||||
if (fullscreen) {
|
||||
await controls
|
||||
.getByRole('button', {
|
||||
name: 'Enter fullscreen',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
}
|
||||
for (const theme of ['light', 'dark', 'light'] as const) {
|
||||
await applyTheme(app.mainWindow, theme);
|
||||
await play.hover();
|
||||
if (engine === 'native') await expectTextContrast(play, 3);
|
||||
else await expectOverlayContrastOnWhite(controls, play);
|
||||
// Leave pointer hover, then Tab back into the control to
|
||||
// exercise a real keyboard focus-visible state.
|
||||
await play.press('Tab');
|
||||
await app.mainWindow.keyboard.press('Shift+Tab');
|
||||
await expect(play).toBeFocused();
|
||||
if (engine === 'native') await expectTextContrast(play, 3);
|
||||
else await expectOverlayContrastOnWhite(controls, play);
|
||||
if (!fullscreen) {
|
||||
const timeline =
|
||||
app.mainWindow.locator('app-epg-timeline');
|
||||
await expectThemeSurface(timeline, theme);
|
||||
await expectTextContrast(
|
||||
timeline.locator('.epg-empty__title')
|
||||
);
|
||||
await expectTextContrast(
|
||||
timeline.locator('.epg-empty__sub')
|
||||
);
|
||||
}
|
||||
if (engine === 'native') {
|
||||
await expectThemeSurface(controls, theme);
|
||||
await expect(play).toHaveCSS('outline-width', '2px');
|
||||
const disabled = controls
|
||||
.locator(
|
||||
'.embedded-mpv-player__transport button:disabled'
|
||||
)
|
||||
.first();
|
||||
await expect(disabled).toBeDisabled();
|
||||
await expectTextContrast(disabled, 1.5);
|
||||
expect(
|
||||
await disabled.evaluate(
|
||||
(el) => getComputedStyle(el).color
|
||||
)
|
||||
).not.toEqual(
|
||||
await play.evaluate(
|
||||
(el) => getComputedStyle(el).color
|
||||
)
|
||||
);
|
||||
await expectTextContrast(
|
||||
controls.locator('.embedded-mpv-player__time')
|
||||
);
|
||||
}
|
||||
if (engine === 'native') {
|
||||
await controls
|
||||
.locator('[data-embedded-mpv-menu-button="speed"]')
|
||||
.click();
|
||||
const panel = controls.locator(
|
||||
'app-embedded-mpv-dock-panel'
|
||||
);
|
||||
await expectTextContrast(
|
||||
panel.locator('.embedded-mpv-dock-panel__title')
|
||||
);
|
||||
const selected = panel.locator(
|
||||
'.embedded-mpv-dock-panel__chip--selected'
|
||||
);
|
||||
await expectTextContrast(selected);
|
||||
await selected.hover();
|
||||
await expectTextContrast(selected);
|
||||
await panel
|
||||
.locator('.embedded-mpv-dock-panel__back')
|
||||
.click();
|
||||
}
|
||||
const controlBox = await controls.boundingBox();
|
||||
const playBox = await play.boundingBox();
|
||||
expect(playBox!.x).toBeGreaterThanOrEqual(controlBox!.x);
|
||||
expect(playBox!.y + playBox!.height).toBeLessThanOrEqual(
|
||||
controlBox!.y + controlBox!.height + 1
|
||||
);
|
||||
await app.mainWindow.screenshot({
|
||||
path: test
|
||||
.info()
|
||||
.outputPath(
|
||||
`${engine}-${theme}-${fullscreen ? 'fullscreen' : 'window'}.png`
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
await controls
|
||||
.getByRole('button', { name: 'Exit fullscreen', exact: true })
|
||||
.click();
|
||||
if (engine === 'html5') {
|
||||
for (const openCollection of [
|
||||
openGlobalFavorites,
|
||||
openGlobalRecent,
|
||||
]) {
|
||||
await openCollection(app.mainWindow);
|
||||
await channelItemByTitle(app.mainWindow, 'Theme fixture')
|
||||
.first()
|
||||
.click();
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(app.mainWindow, theme);
|
||||
const timeline =
|
||||
app.mainWindow.locator('app-epg-timeline');
|
||||
await expect(timeline).toBeVisible();
|
||||
await expectThemeSurface(timeline, theme);
|
||||
await expectTextContrast(
|
||||
timeline.locator('.epg-timeline__heading b')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await media?.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { expect, Locator, Page } from '@playwright/test';
|
||||
import sharp = require('sharp');
|
||||
|
||||
/** Exercise the same live CSS boundary as SettingsService without navigating
|
||||
* away from (and therefore destroying) the player being measured. */
|
||||
export async function applyTheme(page: Page, theme: 'light' | 'dark') {
|
||||
await page.evaluate((dark) => {
|
||||
document.body.classList.toggle('dark-theme', dark);
|
||||
}, theme === 'dark');
|
||||
}
|
||||
|
||||
/** Contrast for app panels with solid/alpha CSS background colors. Gradients,
|
||||
* video and Material state layers use the raster check below instead. */
|
||||
export async function expectTextContrast(
|
||||
locator: Locator,
|
||||
minimum = 4.5,
|
||||
siblingFillSelector?: string
|
||||
) {
|
||||
await expect(locator).toBeVisible();
|
||||
const measure = () =>
|
||||
locator.evaluate((element, fillSelector) => {
|
||||
type Color = [number, number, number, number];
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const parse = (css: string): Color => {
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
ctx.fillStyle = css;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
return [r, g, b, a / 255];
|
||||
};
|
||||
const over = (front: Color, back: Color): Color => {
|
||||
const alpha = front[3] + back[3] * (1 - front[3]);
|
||||
return [0, 1, 2]
|
||||
.map((i) =>
|
||||
alpha
|
||||
? (front[i] * front[3] +
|
||||
back[i] * back[3] * (1 - front[3])) /
|
||||
alpha
|
||||
: 0
|
||||
)
|
||||
.concat(alpha) as Color;
|
||||
};
|
||||
let foreground = parse(getComputedStyle(element).color);
|
||||
let background: Color = [0, 0, 0, 0];
|
||||
for (
|
||||
let node: Element | null = element;
|
||||
node;
|
||||
node = node.parentElement
|
||||
) {
|
||||
const style = getComputedStyle(node);
|
||||
// A timeline progress fill is a sibling behind the text, not
|
||||
// an ancestor. Include it before its owning card background.
|
||||
const siblingFill = fillSelector
|
||||
? node.querySelector(`:scope > ${fillSelector}`)
|
||||
: null;
|
||||
if (siblingFill) {
|
||||
const layer = parse(
|
||||
getComputedStyle(siblingFill).backgroundColor
|
||||
);
|
||||
foreground = over(foreground, layer);
|
||||
background = over(background, layer);
|
||||
}
|
||||
const fill = parse(style.backgroundColor);
|
||||
foreground = over(foreground, fill);
|
||||
background = over(background, fill);
|
||||
foreground[3] *= Number(style.opacity);
|
||||
background[3] *= Number(style.opacity);
|
||||
}
|
||||
const white: Color = [255, 255, 255, 1];
|
||||
const luminance = (color: Color) => {
|
||||
const rgb = color.slice(0, 3).map((value) => {
|
||||
const s = value / 255;
|
||||
return s <= 0.04045
|
||||
? s / 12.92
|
||||
: ((s + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722;
|
||||
};
|
||||
const a = luminance(over(foreground, white));
|
||||
const b = luminance(over(background, white));
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
}, siblingFillSelector);
|
||||
await expect
|
||||
.poll(measure, { message: `Rendered contrast of ${locator}` })
|
||||
.toBeGreaterThanOrEqual(minimum);
|
||||
}
|
||||
|
||||
export async function expectThemeSurface(
|
||||
locator: Locator,
|
||||
theme: 'light' | 'dark'
|
||||
) {
|
||||
const brightness = await locator.evaluate((element) => {
|
||||
const color = getComputedStyle(element).backgroundColor;
|
||||
const channels =
|
||||
color
|
||||
.match(/[\d.]+/g)
|
||||
?.slice(0, 3)
|
||||
.map(Number) ?? [];
|
||||
return channels.reduce((sum, value) => sum + value, 0) / 3;
|
||||
});
|
||||
if (theme === 'light') expect(brightness).toBeGreaterThan(220);
|
||||
else expect(brightness).toBeLessThan(70);
|
||||
}
|
||||
|
||||
/** Put a worst-case white video frame below the real shared controls. Rasterize
|
||||
* the actual icon with its gradient scrim and Material hover/focus layers, so
|
||||
* losing the scrim cannot pass because a black video ancestor masks the bug. */
|
||||
export async function expectOverlayContrastOnWhite(
|
||||
controls: Locator,
|
||||
button: Locator
|
||||
) {
|
||||
await controls.evaluate((element) => {
|
||||
const backing = document.createElement('div');
|
||||
backing.dataset['themeContrastBacking'] = '';
|
||||
Object.assign(backing.style, {
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
background: '#fff',
|
||||
zIndex: '-1',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
element.append(backing);
|
||||
});
|
||||
try {
|
||||
const icon = button.locator('mat-icon');
|
||||
const buffer = await icon.screenshot({ animations: 'disabled' });
|
||||
const { data, info } = await sharp(buffer)
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const luminances: number[] = [];
|
||||
for (let i = 0; i < data.length; i += info.channels) {
|
||||
const linear = [data[i], data[i + 1], data[i + 2]].map((value) => {
|
||||
const s = value / 255;
|
||||
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
luminances.push(
|
||||
linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722
|
||||
);
|
||||
}
|
||||
luminances.sort((a, b) => a - b);
|
||||
// Interior background and filled glyph, excluding antialiased edges.
|
||||
const background = luminances[Math.floor(luminances.length * 0.1)];
|
||||
const foreground = luminances[Math.floor(luminances.length * 0.9)];
|
||||
expect(
|
||||
(foreground + 0.05) / (background + 0.05)
|
||||
).toBeGreaterThanOrEqual(3);
|
||||
} finally {
|
||||
await controls.evaluate((element) =>
|
||||
element.querySelector('[data-theme-contrast-backing]')?.remove()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user