test(ui): measure detail action edges over rendered artwork

This commit is contained in:
4gray committed 2026-09-05 22:24:54 +02:00
1 parent 3cdbb2bcff
commit 2137ba2090
3 files changed
+82 -6

No files matched your search

+74 -5
View File
@@ -5,6 +5,7 @@ import type {
TestInfo,
} from '@playwright/test';
import { expect } from './fixtures';
import sharp from 'sharp';
export async function setInputValue(
input: Locator,
@@ -134,6 +135,73 @@ export async function surfaceContrast(locator: Locator): Promise<{
});
}
/** Compare the actual hero edge with the same pixels with just its border hidden.
* Both rasters retain artwork, gradients, opacity and backdrop filters. Matching
* pixels prevent artwork detail from being mistaken for a visible button edge. */
export async function rasterizedBorderContrast(
locator: Locator
): Promise<number> {
const screenshot = () =>
locator.screenshot({ animations: 'disabled', scale: 'css' });
const visible = await screenshot();
const originalStyle = await locator.getAttribute('style');
let hidden: Buffer;
try {
await locator.evaluate((element) => {
const style = (element as HTMLElement).style;
style.setProperty('transition', 'none', 'important');
style.setProperty('border-top-color', 'transparent', 'important');
});
hidden = await screenshot();
} finally {
await locator.evaluate((element, style) => {
if (style === null) element.removeAttribute('style');
else element.setAttribute('style', style);
}, originalStyle);
}
const decode = (buffer: Buffer) =>
sharp(buffer).removeAlpha().raw().toBuffer({ resolveWithObject: true });
const [before, after] = await Promise.all([
decode(visible),
decode(hidden),
]);
expect(after.info).toEqual(before.info);
const luminance = (data: Buffer, x: number, y: number) => {
const offset = (y * before.info.width + x) * before.info.channels;
return [0.2126, 0.7152, 0.0722].reduce((sum, weight, index) => {
const value = data[offset + index] / 255;
return (
sum +
weight *
(value <= 0.04045
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4)
);
}, 0);
};
const ratios: number[] = [];
// Avoid rounded corners. Inspect three raster rows to tolerate a fractional
// CSS edge, retaining the strongest edge pixel in each sampled column.
for (
let x = Math.ceil(before.info.width / 4);
x < (before.info.width * 3) / 4;
x++
) {
let strongest = 1;
for (let y = 0; y < Math.min(3, before.info.height); y++) {
const a = luminance(before.data, x, y);
const b = luminance(after.data, x, y);
strongest = Math.max(
strongest,
(Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
);
}
ratios.push(strongest);
}
ratios.sort((a, b) => a - b);
return ratios[Math.floor(ratios.length / 2)];
}
/** Check the shared series UI under a provider host before testing watched state. */
export async function expectSeriesSurfacesInBothThemes(
page: Page,
@@ -148,11 +216,12 @@ export async function expectSeriesSurfacesInBothThemes(
theme === 'dark'
);
const shell = page.locator('app-portal-detail-shell');
for (const selector of [
'.favorite-btn',
'.episode-card',
'mat-button-toggle-group',
]) {
await expect
.poll(() =>
rasterizedBorderContrast(shell.locator('.favorite-btn').first())
)
.toBeGreaterThan(1.1);
for (const selector of ['.episode-card', 'mat-button-toggle-group']) {
await expect
.poll(
async () =>
+5 -1
View File
@@ -3,6 +3,7 @@ import { join } from 'node:path';
import type { APIRequestContext, Locator, Page } from '@playwright/test';
import { expect, test } from './fixtures';
import {
rasterizedBorderContrast,
setInputValue,
surfaceContrast,
waitForScrollIdle,
@@ -1249,7 +1250,10 @@ for (const theme of ['light', 'dark']) {
await page.mouse.move(0, 0);
// A subtle edge must survive compositing on the actual theme surface.
// This catches white-alpha borders that disappear in the light theme.
for (const surface of [favorite, card, toggle]) {
await expect
.poll(() => rasterizedBorderContrast(favorite))
.toBeGreaterThan(1.1);
for (const surface of [card, toggle]) {
await expect
.poll(async () => (await surfaceContrast(surface)).border)
.toBeGreaterThan(1.15);
@@ -380,6 +380,9 @@ partial so Xtream and Stalker share the same behavior.
Browser regression coverage measures the composited neutral edges and selected
toggle fill, in addition to capturing light/dark grid and list screenshots.
Hero action edges use matching pixels from rendered screenshots with the border
visible and transparent, retaining the artwork and gradient behind the button;
flat ancestor-color compositing is only appropriate outside that layered hero.
## Settings Surfaces