Files
iptvnator/apps/electron-backend-e2e/src/theme-contrast.ts
T
4gray 8eda5370eb fix(playback): apply themes to player and EPG panels (#1541)
* fix(playback): apply themes to player and EPG panels

* test(playback): verify active recording icon theme

* fix(epg): keep loading shimmer visible in both themes
2026-09-05 15:32:22 +02:00

201 lines
7.8 KiB
TypeScript

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()
);
}
}
/** Skeletons are decorative, but their faintest gradient stop must remain
* distinguishable from the guide's background in either theme. */
export async function expectSkeletonContrast(
skeleton: Locator,
guide: Locator
) {
const background = await guide.evaluate((element) =>
getComputedStyle(element)
.backgroundColor.match(/[\d.]+/g)!
.slice(0, 3)
.map(Number)
);
const { data, info } = await sharp(
await skeleton.screenshot({ animations: 'disabled' })
)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const luminance = (rgb: number[]) =>
rgb
.map((value) => {
const s = value / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
})
.reduce(
(sum, value, index) =>
sum + value * [0.2126, 0.7152, 0.0722][index],
0
);
const bg = luminance(background);
// Middle row avoids the rounded transparent corners.
for (let x = 8; x < info.width - 8; x++) {
const offset =
(Math.floor(info.height / 2) * info.width + x) * info.channels;
const fg = luminance([
data[offset],
data[offset + 1],
data[offset + 2],
]);
expect(
(Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05)
).toBeGreaterThanOrEqual(1.3);
}
}