mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
* 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
201 lines
7.8 KiB
TypeScript
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);
|
|
}
|
|
}
|