Files
Portfolio/tests/browser/media-policy.spec.ts

151 lines
7.9 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { expect, test } from '../helpers/serve-dist';
import { parsePortfolioContent, parsePublicAssetManifest } from '../../src/content/schemas';
const fixture = (path: string): unknown =>
JSON.parse(readFileSync(resolve(process.cwd(), path), 'utf8')) as unknown;
const content = parsePortfolioContent(fixture('content/public/portfolio-content.json'));
const manifest = parsePublicAssetManifest(fixture('content/public/asset-manifest.json'));
const projectPresentations = [...content.projects.selected, ...content.projects.additional]
.flatMap((project) => project.media ?? []);
const approvedStillCount = projectPresentations.filter(({ kind }) => kind !== 'video').length;
const expectedImages = projectPresentations.flatMap((presentation) => {
if (presentation.kind === 'video') return [];
const asset = manifest.assets.find(({ id }) => id === presentation.assetId)!;
return [{
src: `/assets/${asset.fileName}`,
width: String(asset.width),
height: String(asset.height),
alt: presentation.alt,
}];
});
test('approved still evidence uses manifest assets and intrinsic lazy image semantics', async ({ page, distUrl }) => {
const videoRequests: string[] = [];
page.on('request', (request) => {
if (/\.(?:mp4|webm)(?:$|[?#])/iu.test(request.url())) videoRequests.push(request.url());
});
await page.goto(distUrl, { waitUntil: 'networkidle' });
await expect(page.locator('main > section')).toHaveCount(8);
await expect(page.locator('#selected-builds article')).toHaveCount(3);
await expect(page.locator('#additional-work article')).toHaveCount(4);
await expect(page.locator('[data-project-media]')).toHaveCount(approvedStillCount);
await expect(page.locator('[data-project-media] img')).toHaveCount(expectedImages.length);
expect(await page.locator('[data-project-media] img').evaluateAll((images) => images.map((image) => ({
src: new URL((image as HTMLImageElement).src).pathname,
width: image.getAttribute('width'),
height: image.getAttribute('height'),
alt: image.getAttribute('alt'),
loading: (image as HTMLImageElement).loading,
decoding: (image as HTMLImageElement).decoding,
})))).toEqual(expectedImages.map((image) => ({ ...image, loading: 'lazy', decoding: 'async' })));
await expect(page.locator('video, video source')).toHaveCount(0);
await expect(page.locator('[data-project-id="private-medication-tracker"] a')).toHaveCount(0);
await expect(page.locator('.journey-poster')).not.toHaveAttribute('src', /.+/u);
expect(videoRequests).toEqual([]);
});
test('Static and reduced motion keep approved stills without introducing video behavior', async ({ page, distUrl }) => {
const videoRequests: string[] = [];
page.on('request', (request) => {
if (/\.(?:mp4|webm)(?:$|[?#])/iu.test(request.url())) videoRequests.push(request.url());
});
await page.addInitScript(() => localStorage.setItem('portfolio.motion.maximum-quality', 'static'));
await page.goto(distUrl, { waitUntil: 'networkidle' });
await expect(page.locator('[data-project-media] img')).toHaveCount(approvedStillCount);
await expect(page.locator('video[src], video source[src]')).toHaveCount(0);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.reload({ waitUntil: 'networkidle' });
await expect(page.locator('[data-project-media] img')).toHaveCount(approvedStillCount);
await expect(page.locator('video[src], video source[src]')).toHaveCount(0);
expect(videoRequests).toEqual([]);
});
test('failed project images leave titles, captions, chart explanations, and alt text in the document', async ({ page, distUrl }) => {
await page.route('**/assets/project-*.webp', (route) => route.abort('failed'));
await page.goto(distUrl, { waitUntil: 'networkidle' });
await expect(page.locator('[data-project-media] img')).toHaveCount(approvedStillCount);
await expect(page.locator('[data-project-id="private-medication-tracker"]')).toContainText('Private self-hosted medication tracker');
await expect(page.locator('[data-project-id="private-medication-tracker"] figcaption')).toHaveCount(3);
await expect(page.locator('[data-project-id="opinion-dynamics-simulation"] [data-testid="chart-conclusion"]')).toHaveCount(2);
await expect(page.locator('[data-project-id="currency-converter"] img')).toHaveAttribute('alt', /conversion interface/iu);
});
test('desktop chart descriptions and exact values occupy sequential full-width rows', async ({ page, distUrl }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'Desktop chart grid geometry is verified once.');
await page.setViewportSize({ width: 1440, height: 900 });
await page.addInitScript(() => localStorage.setItem('portfolio.motion.maximum-quality', 'static'));
await page.goto(distUrl, { waitUntil: 'networkidle' });
const chart = page.locator('[data-project-id="opinion-dynamics-simulation"] .project-media--chart').first();
const geometry = await chart.evaluate((figure) => {
const description = figure.querySelector<HTMLElement>('.chart-description')!;
const table = figure.querySelector<HTMLElement>('.chart-values')!;
const figureRect = figure.getBoundingClientRect();
const descriptionStyle = getComputedStyle(description);
const tableStyle = getComputedStyle(table);
return {
descriptionColumn: descriptionStyle.gridColumnStart,
descriptionRow: descriptionStyle.gridRowStart,
tableColumn: tableStyle.gridColumnStart,
tableRow: tableStyle.gridRowStart,
descriptionWidthRatio: description.getBoundingClientRect().width / figureRect.width,
tableWidthRatio: table.getBoundingClientRect().width / figureRect.width,
};
});
const opinionHeight = await page.locator('[data-project-id="opinion-dynamics-simulation"]')
.evaluate((article) => article.getBoundingClientRect().height);
expect(geometry).toEqual({
descriptionColumn: '1',
descriptionRow: '3',
tableColumn: '1',
tableRow: '4',
descriptionWidthRatio: expect.any(Number),
tableWidthRatio: expect.any(Number),
});
expect(geometry.descriptionWidthRatio).toBeGreaterThanOrEqual(0.8);
expect(geometry.tableWidthRatio).toBeGreaterThanOrEqual(0.8);
expect(opinionHeight).toBeLessThan(3_000);
});
test('print project captions and evidence text retain readable contrast', async ({ page, distUrl }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'Print contrast is verified once.');
await page.setViewportSize({ width: 1440, height: 900 });
await page.emulateMedia({ media: 'print' });
await page.goto(distUrl, { waitUntil: 'networkidle' });
const result = await page.locator('.project-media').evaluateAll((figures) => {
const channel = (value: number): number => {
const normalized = value / 255;
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
};
const luminance = (color: string): number => {
const values = color.match(/[\d.]+/gu)?.slice(0, 3).map(Number) ?? [0, 0, 0];
return 0.2126 * channel(values[0]!) + 0.7152 * channel(values[1]!) + 0.0722 * channel(values[2]!);
};
const contrast = (foreground: string, background: string): number => {
const [lighter, darker] = [luminance(foreground), luminance(background)].sort((a, b) => b - a);
return (lighter! + 0.05) / (darker! + 0.05);
};
const samples = figures.flatMap((figure) => {
const background = getComputedStyle(figure).backgroundColor;
return [...figure.querySelectorAll<HTMLElement>('figcaption, .media-description, .chart-description, .chart-values')]
.map((element) => contrast(getComputedStyle(element).color, background));
});
return {
backgrounds: [...new Set(figures.map((figure) => getComputedStyle(figure).backgroundColor))],
minimumContrast: Math.min(...samples),
};
});
expect(result.backgrounds).toEqual(['rgb(255, 255, 255)']);
expect(result.minimumContrast).toBeGreaterThanOrEqual(4.5);
});