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('.chart-description')!; const table = figure.querySelector('.chart-values'); const values = [...description.querySelectorAll('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('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('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); });