|
|
|
|
@@ -1,6 +1,6 @@
|
|
|
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
|
|
|
import { resolve } from 'node:path';
|
|
|
|
|
import { expect, test, type Page, type TestInfo } from '@playwright/test';
|
|
|
|
|
import { expect, test, type Page } from '@playwright/test';
|
|
|
|
|
import { createServer, type ViteDevServer } from 'vite';
|
|
|
|
|
|
|
|
|
|
const stylesheetFiles = [
|
|
|
|
|
@@ -33,6 +33,11 @@ const syntheticProjectMedia = [
|
|
|
|
|
{ kind: 'image', assetId: 'synthetic-portrait-three', alt: 'Synthetic schedule evidence.', caption: 'Fictional demo schedule.' },
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
const syntheticChartPair = [
|
|
|
|
|
{ kind: 'image', assetId: 'synthetic-chart-wide', alt: 'Synthetic wide chart evidence.', caption: 'Fictional wide chart.' },
|
|
|
|
|
{ kind: 'image', assetId: 'synthetic-chart-tall', alt: 'Synthetic tall chart evidence.', caption: 'Fictional tall chart.' },
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
function withSyntheticProjectMedia(contentInput: unknown, manifestInput: unknown): { content: unknown; manifest: unknown } {
|
|
|
|
|
const content = structuredClone(contentInput) as {
|
|
|
|
|
projects: { selected: Array<{ assetIds: string[]; media?: unknown; mediaLayout?: unknown }> };
|
|
|
|
|
@@ -43,6 +48,10 @@ function withSyntheticProjectMedia(contentInput: unknown, manifestInput: unknown
|
|
|
|
|
project.assetIds = syntheticProjectMedia.map((media) => media.assetId);
|
|
|
|
|
project.media = syntheticProjectMedia;
|
|
|
|
|
project.mediaLayout = 'portrait-strip';
|
|
|
|
|
const chartPairProject = content.projects.selected[1]!;
|
|
|
|
|
chartPairProject.assetIds = syntheticChartPair.map((media) => media.assetId);
|
|
|
|
|
chartPairProject.media = syntheticChartPair;
|
|
|
|
|
chartPairProject.mediaLayout = 'chart-pair';
|
|
|
|
|
manifest.assets.push(...syntheticProjectMedia.map((media, index) => ({
|
|
|
|
|
id: media.assetId,
|
|
|
|
|
fileName: `${media.assetId}.webp`,
|
|
|
|
|
@@ -55,6 +64,32 @@ function withSyntheticProjectMedia(contentInput: unknown, manifestInput: unknown
|
|
|
|
|
license: 'Test-only synthetic asset',
|
|
|
|
|
attribution: 'Synthetic test fixture',
|
|
|
|
|
})));
|
|
|
|
|
manifest.assets.push(
|
|
|
|
|
{
|
|
|
|
|
id: syntheticChartPair[0].assetId,
|
|
|
|
|
fileName: `${syntheticChartPair[0].assetId}.webp`,
|
|
|
|
|
sha256: '4'.repeat(64),
|
|
|
|
|
mediaType: 'image/webp',
|
|
|
|
|
width: 1200,
|
|
|
|
|
height: 600,
|
|
|
|
|
durationMs: null,
|
|
|
|
|
rightsHolder: 'Synthetic test fixture',
|
|
|
|
|
license: 'Test-only synthetic asset',
|
|
|
|
|
attribution: 'Synthetic test fixture',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: syntheticChartPair[1].assetId,
|
|
|
|
|
fileName: `${syntheticChartPair[1].assetId}.webp`,
|
|
|
|
|
sha256: '5'.repeat(64),
|
|
|
|
|
mediaType: 'image/webp',
|
|
|
|
|
width: 600,
|
|
|
|
|
height: 1200,
|
|
|
|
|
durationMs: null,
|
|
|
|
|
rightsHolder: 'Synthetic test fixture',
|
|
|
|
|
license: 'Test-only synthetic asset',
|
|
|
|
|
attribution: 'Synthetic test fixture',
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return { content, manifest };
|
|
|
|
|
}
|
|
|
|
|
@@ -98,10 +133,6 @@ async function loadInformationLayer(page: Page): Promise<void> {
|
|
|
|
|
await page.setContent(informationLayerHarness());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
|
|
|
|
await page.screenshot({ path: testInfo.outputPath(name) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('renders the validated eight-section portfolio structure before responsive checks', async ({ page }) => {
|
|
|
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
|
|
|
await loadInformationLayer(page);
|
|
|
|
|
@@ -123,7 +154,7 @@ for (const viewport of [
|
|
|
|
|
{ width: 1440, height: 900 },
|
|
|
|
|
{ width: 2560, height: 1440 },
|
|
|
|
|
]) {
|
|
|
|
|
test(`keeps information content within the viewport at ${viewport.width}px`, async ({ page }, testInfo) => {
|
|
|
|
|
test(`keeps information content within the viewport at ${viewport.width}px`, async ({ page }) => {
|
|
|
|
|
await page.setViewportSize(viewport);
|
|
|
|
|
await loadInformationLayer(page);
|
|
|
|
|
|
|
|
|
|
@@ -138,13 +169,12 @@ for (const viewport of [
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await capture(page, testInfo, `information-layer-${viewport.width}.png`);
|
|
|
|
|
expect(geometry.noOverflow).toBe(true);
|
|
|
|
|
expect(geometry.allBoxesFit).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('uses explicit desktop layout families while preserving the primary-project road corridor', async ({ page }, testInfo) => {
|
|
|
|
|
test('uses explicit desktop layout families while preserving the primary-project road corridor', async ({ page }) => {
|
|
|
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
|
|
|
await loadInformationLayer(page);
|
|
|
|
|
|
|
|
|
|
@@ -161,7 +191,6 @@ test('uses explicit desktop layout families while preserving the primary-project
|
|
|
|
|
expect(projects[0]!.right).toBeLessThanOrEqual(1440 * 0.51);
|
|
|
|
|
expect(projects[1]!.left).toBeGreaterThanOrEqual(1440 * 0.53);
|
|
|
|
|
expect(projects.every((project) => project.width <= 1440 * 0.47)).toBe(true);
|
|
|
|
|
await capture(page, testInfo, 'information-layer-desktop-corridor.png');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('lays out synthetic portrait evidence in a desktop strip and preserves mobile reflow', async ({ page }) => {
|
|
|
|
|
@@ -188,7 +217,38 @@ test('lays out synthetic portrait evidence in a desktop strip and preserves mobi
|
|
|
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('uses a stable tablet rhythm and a single readable mobile column with usable navigation targets', async ({ page }, testInfo) => {
|
|
|
|
|
test('aligns chart-pair captions for deliberately mixed source aspect ratios', async ({ page }) => {
|
|
|
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
|
|
|
await loadInformationLayer(page);
|
|
|
|
|
|
|
|
|
|
const captions = page.locator('.project-media-gallery[data-media-layout="chart-pair"] figcaption');
|
|
|
|
|
await expect(captions).toHaveCount(2);
|
|
|
|
|
const tops = await captions.evaluateAll((nodes) => nodes.map((node) => node.getBoundingClientRect().top));
|
|
|
|
|
expect(Math.abs(tops[0]! - tops[1]!)).toBeLessThanOrEqual(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('keeps portrait evidence and its caption within an isolated print-page height', async ({ page }) => {
|
|
|
|
|
await page.setViewportSize({ width: 800, height: 1000 });
|
|
|
|
|
await page.emulateMedia({ media: 'print' });
|
|
|
|
|
await loadInformationLayer(page);
|
|
|
|
|
|
|
|
|
|
const portrait = page.locator('.project-media-gallery[data-media-layout="portrait-strip"] figure').first();
|
|
|
|
|
const printGeometry = await portrait.evaluate((figure) => {
|
|
|
|
|
const image = figure.querySelector('img');
|
|
|
|
|
const caption = figure.querySelector('figcaption');
|
|
|
|
|
if (image === null || caption === null) throw new Error('Missing synthetic portrait image or caption.');
|
|
|
|
|
const imageRect = image.getBoundingClientRect();
|
|
|
|
|
const captionRect = caption.getBoundingClientRect();
|
|
|
|
|
const figureStyle = getComputedStyle(figure);
|
|
|
|
|
return {
|
|
|
|
|
mediaAndCaptionHeight: imageRect.height + captionRect.height + Number.parseFloat(figureStyle.marginBlockEnd),
|
|
|
|
|
printableHeight: window.innerHeight - 3 * 16,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
expect(printGeometry.mediaAndCaptionHeight).toBeLessThanOrEqual(printGeometry.printableHeight);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('uses a stable tablet rhythm and a single readable mobile column with usable navigation targets', async ({ page }) => {
|
|
|
|
|
await page.setViewportSize({ width: 768, height: 1024 });
|
|
|
|
|
await loadInformationLayer(page);
|
|
|
|
|
const tabletPanels = await page.locator('.portfolio-main > .portfolio-section').evaluateAll((sections) =>
|
|
|
|
|
@@ -198,7 +258,6 @@ test('uses a stable tablet rhythm and a single readable mobile column with usabl
|
|
|
|
|
expect(tabletPanels['left-timeline'].left).toBeLessThan(768 * 0.1);
|
|
|
|
|
expect(tabletPanels['full-diagram'].width).toBeGreaterThan(768 * 0.8);
|
|
|
|
|
expect(tabletPanels['compact-rail'].left).toBeGreaterThan(768 * 0.3);
|
|
|
|
|
await capture(page, testInfo, 'information-layer-tablet.png');
|
|
|
|
|
|
|
|
|
|
await page.setViewportSize({ width: 320, height: 800 });
|
|
|
|
|
await loadInformationLayer(page);
|
|
|
|
|
@@ -215,10 +274,9 @@ test('uses a stable tablet rhythm and a single readable mobile column with usabl
|
|
|
|
|
});
|
|
|
|
|
expect(mobile.singleColumn).toBe(true);
|
|
|
|
|
expect(mobile.touchTargets).toBe(true);
|
|
|
|
|
await capture(page, testInfo, 'information-layer-mobile.png');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('turns the infrastructure route from a horizontal trust flow into a readable mobile stack', async ({ page }, testInfo) => {
|
|
|
|
|
test('turns the infrastructure route from a horizontal trust flow into a readable mobile stack', async ({ page }) => {
|
|
|
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
|
|
|
await loadInformationLayer(page);
|
|
|
|
|
|
|
|
|
|
@@ -231,7 +289,6 @@ test('turns the infrastructure route from a horizontal trust flow into a readabl
|
|
|
|
|
expect(desktop.slice(0, 4).every((node) => Math.abs(node.top - desktop[0]!.top) <= 1)).toBe(true);
|
|
|
|
|
expect(desktop[4]!.top).toBeGreaterThanOrEqual(desktop[3]!.bottom);
|
|
|
|
|
expect(desktop.slice(0, 4).every((node, index, nodes) => index === 0 || node.left > nodes[index - 1]!.left)).toBe(true);
|
|
|
|
|
await capture(page, testInfo, 'infrastructure-flow-desktop.png');
|
|
|
|
|
|
|
|
|
|
await page.setViewportSize({ width: 320, height: 800 });
|
|
|
|
|
await loadInformationLayer(page);
|
|
|
|
|
@@ -244,7 +301,6 @@ test('turns the infrastructure route from a horizontal trust flow into a readabl
|
|
|
|
|
expect(mobile.every((node) => Math.abs(node.left - mobile[0]!.left) <= 1)).toBe(true);
|
|
|
|
|
expect(mobile.every((node) => node.width >= 200)).toBe(true);
|
|
|
|
|
expect(mobile.every((node, index, nodes) => index === 0 || node.top > nodes[index - 1]!.bottom)).toBe(true);
|
|
|
|
|
await capture(page, testInfo, 'infrastructure-flow-mobile.png');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('keeps the compact HUD in one safe lower row without covering the road corridor', async ({ page }) => {
|
|
|
|
|
|