195 lines
10 KiB
TypeScript
195 lines
10 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(projectPresentations.length);
|
|
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')).toHaveCount(1);
|
|
await expect(page.locator('video source')).toHaveCount(0);
|
|
await expect(page.locator('[data-project-id="private-medication-tracker"] a.project-link')).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 keep readable value columns and exact values in 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 charts = page.locator('[data-project-id="opinion-dynamics-simulation"] .project-media--chart');
|
|
const geometry = await charts.evaluateAll((figures) => figures.map((figure) => {
|
|
const description = figure.querySelector<HTMLElement>('.chart-description')!;
|
|
const table = figure.querySelector<HTMLElement>('.chart-values');
|
|
const values = [...description.querySelectorAll<HTMLElement>('dd')];
|
|
const figureRect = figure.getBoundingClientRect();
|
|
const descriptionStyle = getComputedStyle(description);
|
|
const tableStyle = table === null ? null : getComputedStyle(table);
|
|
return {
|
|
descriptionColumn: descriptionStyle.gridColumnStart,
|
|
descriptionRow: descriptionStyle.gridRowStart,
|
|
tableColumn: tableStyle?.gridColumnStart ?? null,
|
|
tableRow: tableStyle?.gridRowStart ?? null,
|
|
descriptionWidthRatio: description.getBoundingClientRect().width / figureRect.width,
|
|
tableWidthRatio: table === null ? null : table.getBoundingClientRect().width / figureRect.width,
|
|
minimumValueWidth: Math.min(...values.map((value) => value.getBoundingClientRect().width)),
|
|
descriptionHeight: description.getBoundingClientRect().height,
|
|
};
|
|
}));
|
|
const opinionHeight = await page.locator('[data-project-id="opinion-dynamics-simulation"]')
|
|
.evaluate((article) => article.getBoundingClientRect().height);
|
|
|
|
expect(geometry[0]).toEqual({
|
|
descriptionColumn: '1',
|
|
descriptionRow: '3',
|
|
tableColumn: '1',
|
|
tableRow: '4',
|
|
descriptionWidthRatio: expect.any(Number),
|
|
tableWidthRatio: expect.any(Number),
|
|
minimumValueWidth: expect.any(Number),
|
|
descriptionHeight: expect.any(Number),
|
|
});
|
|
expect(geometry[1]).toMatchObject({
|
|
descriptionColumn: '1',
|
|
descriptionRow: '3',
|
|
tableColumn: null,
|
|
tableRow: null,
|
|
});
|
|
expect(geometry.every(({ descriptionWidthRatio }) => descriptionWidthRatio >= 0.8)).toBe(true);
|
|
expect(geometry[0]!.tableWidthRatio).toBeGreaterThanOrEqual(0.8);
|
|
expect.soft(
|
|
Math.min(...geometry.map(({ minimumValueWidth }) => minimumValueWidth)),
|
|
JSON.stringify(geometry),
|
|
).toBeGreaterThanOrEqual(160);
|
|
expect.soft(Math.max(...geometry.map(({ descriptionHeight }) => descriptionHeight))).toBeLessThan(600);
|
|
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.goto(distUrl, { waitUntil: 'networkidle' });
|
|
const opinionCharts = page.locator('[data-project-id="opinion-dynamics-simulation"] .project-media--chart img');
|
|
await opinionCharts.first().scrollIntoViewIfNeeded();
|
|
await expect.poll(() => opinionCharts.evaluateAll((images) => images.every((image) => (
|
|
(image as HTMLImageElement).complete && (image as HTMLImageElement).naturalWidth > 0
|
|
)))).toBe(true);
|
|
await page.emulateMedia({ media: 'print' });
|
|
|
|
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));
|
|
});
|
|
const chartImages = figures
|
|
.filter((figure) => figure.matches('.project-media--chart'))
|
|
.map((figure) => figure.querySelector<HTMLImageElement>('img')!);
|
|
return {
|
|
backgrounds: [...new Set(figures.map((figure) => getComputedStyle(figure).backgroundColor))],
|
|
minimumContrast: Math.min(...samples),
|
|
chartImages: chartImages.map((image) => {
|
|
const rendered = image.getBoundingClientRect();
|
|
const renderedAspect = rendered.width / rendered.height;
|
|
const intrinsicAspect = Number(image.getAttribute('width')) / Number(image.getAttribute('height'));
|
|
return {
|
|
background: getComputedStyle(image).backgroundColor,
|
|
renderedWidth: rendered.width,
|
|
renderedHeight: rendered.height,
|
|
renderedAspect,
|
|
intrinsicAspect,
|
|
aspectError: Math.abs(renderedAspect - intrinsicAspect) / intrinsicAspect,
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
|
|
expect(result.backgrounds).toEqual(['rgb(255, 255, 255)']);
|
|
expect(result.minimumContrast).toBeGreaterThanOrEqual(4.5);
|
|
expect(result.chartImages).toHaveLength(2);
|
|
expect.soft(result.chartImages.every(({ background }) => background === 'rgb(255, 255, 255)')).toBe(true);
|
|
expect.soft(
|
|
Math.max(...result.chartImages.map(({ aspectError }) => aspectError)),
|
|
JSON.stringify(result.chartImages),
|
|
).toBeLessThanOrEqual(0.02);
|
|
});
|