Integrate the Blender grand tourer with a cohesive shader-driven road, terrain, atmosphere, lighting, and post-processing pass. Refine responsive drive dynamics and sign lifecycle behavior, and add durable visual, accessibility, and performance verification evidence.
550 lines
20 KiB
TypeScript
550 lines
20 KiB
TypeScript
import sharp from "sharp";
|
|
import type { Locator, Page, TestInfo } from "@playwright/test";
|
|
import { expect, test } from "../helpers/serve-dist";
|
|
|
|
interface VisualViewportCase {
|
|
readonly label: string;
|
|
readonly width: number;
|
|
readonly height: number;
|
|
}
|
|
|
|
interface SampledFrame {
|
|
readonly pixels: Uint8Array;
|
|
readonly width: number;
|
|
readonly height: number;
|
|
}
|
|
|
|
interface FlashEvent {
|
|
readonly direction: -1 | 1;
|
|
readonly frame: number;
|
|
readonly affectedRatio: number;
|
|
}
|
|
|
|
const viewports = [
|
|
{ label: "mobile-compact-portrait", width: 320, height: 568 },
|
|
{ label: "mobile-portrait", width: 390, height: 844 },
|
|
{ label: "mobile-landscape", width: 844, height: 390 },
|
|
{ label: "tablet-portrait", width: 768, height: 1024 },
|
|
{ label: "desktop-compact", width: 1280, height: 720 },
|
|
{ label: "desktop-standard", width: 1440, height: 900 },
|
|
{ label: "desktop-wide", width: 1920, height: 1080 },
|
|
{ label: "desktop-ultrawide", width: 2560, height: 1440 },
|
|
] as const satisfies readonly VisualViewportCase[];
|
|
|
|
const waitForRenderedPortfolio = async (
|
|
page: Page,
|
|
distUrl: string,
|
|
): Promise<void> => {
|
|
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
|
await page.evaluate(() => document.fonts.ready);
|
|
await expect(
|
|
page.getByRole("heading", { level: 1, name: "Luca Pîslaru" }),
|
|
).toBeVisible();
|
|
await expect(page.locator(".journey-world")).toHaveAttribute(
|
|
"data-scene-state",
|
|
"running",
|
|
);
|
|
};
|
|
|
|
const captureViewport = async (
|
|
page: Page,
|
|
testInfo: TestInfo,
|
|
name: string,
|
|
): Promise<Buffer> =>
|
|
page.screenshot({
|
|
animations: "allow",
|
|
path: testInfo.outputPath(`${name}.png`),
|
|
scale: "css",
|
|
});
|
|
|
|
const sampleFrame = async (
|
|
image: Buffer,
|
|
width = 160,
|
|
height = 90,
|
|
): Promise<SampledFrame> => {
|
|
const { data, info } = await sharp(image)
|
|
.resize(width, height, { fit: "fill" })
|
|
.removeAlpha()
|
|
.raw()
|
|
.toBuffer({ resolveWithObject: true });
|
|
return { pixels: data, width: info.width, height: info.height };
|
|
};
|
|
|
|
const paletteRatios = async (
|
|
image: Buffer,
|
|
): Promise<{ dark: number; restrainedAccent: number }> => {
|
|
const frame = await sampleFrame(image, 120, 68);
|
|
let dark = 0;
|
|
let accent = 0;
|
|
for (let offset = 0; offset < frame.pixels.length; offset += 3) {
|
|
const red = frame.pixels[offset] ?? 0;
|
|
const green = frame.pixels[offset + 1] ?? 0;
|
|
const blue = frame.pixels[offset + 2] ?? 0;
|
|
const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
|
const chroma = Math.max(red, green, blue) - Math.min(red, green, blue);
|
|
if (luminance < 86) dark += 1;
|
|
if (chroma >= 34 && luminance >= 35) accent += 1;
|
|
}
|
|
const pixelCount = frame.width * frame.height;
|
|
return { dark: dark / pixelCount, restrainedAccent: accent / pixelCount };
|
|
};
|
|
|
|
const averageNormalizedDifference = async (
|
|
first: Buffer,
|
|
second: Buffer,
|
|
): Promise<number> => {
|
|
const [a, b] = await Promise.all([sampleFrame(first), sampleFrame(second)]);
|
|
expect({ width: a.width, height: a.height }).toEqual({
|
|
width: b.width,
|
|
height: b.height,
|
|
});
|
|
let difference = 0;
|
|
for (let offset = 0; offset < a.pixels.length; offset += 1) {
|
|
difference += Math.abs((a.pixels[offset] ?? 0) - (b.pixels[offset] ?? 0));
|
|
}
|
|
return difference / (a.pixels.length * 255);
|
|
};
|
|
|
|
const captureWorldSequence = async (
|
|
world: Locator,
|
|
frameCount: number,
|
|
intervalMs: number,
|
|
): Promise<SampledFrame[]> => {
|
|
const frames: SampledFrame[] = [];
|
|
const clip = await world.boundingBox();
|
|
if (clip === null) throw new Error("Journey world is not visible.");
|
|
for (let index = 0; index < frameCount; index += 1) {
|
|
frames.push(
|
|
await sampleFrame(
|
|
await world
|
|
.page()
|
|
.screenshot({ animations: "allow", clip, scale: "css" }),
|
|
),
|
|
);
|
|
if (index + 1 < frameCount) await world.page().waitForTimeout(intervalMs);
|
|
}
|
|
return frames;
|
|
};
|
|
|
|
const relativeLuminance = (pixels: Uint8Array, offset: number): number =>
|
|
(0.2126 * (pixels[offset] ?? 0) +
|
|
0.7152 * (pixels[offset + 1] ?? 0) +
|
|
0.0722 * (pixels[offset + 2] ?? 0)) /
|
|
255;
|
|
|
|
const analyzeFlashSequence = (
|
|
frames: readonly SampledFrame[],
|
|
framesPerSecond: number,
|
|
): { events: FlashEvent[]; maximumFlashesPerSecond: number } => {
|
|
const events: FlashEvent[] = [];
|
|
for (let frameIndex = 1; frameIndex < frames.length; frameIndex += 1) {
|
|
const previous = frames[frameIndex - 1];
|
|
const current = frames[frameIndex];
|
|
if (previous === undefined || current === undefined) continue;
|
|
expect({ width: current.width, height: current.height }).toEqual({
|
|
width: previous.width,
|
|
height: previous.height,
|
|
});
|
|
|
|
let brightened = 0;
|
|
let darkened = 0;
|
|
const pixelCount = current.width * current.height;
|
|
for (let offset = 0; offset < current.pixels.length; offset += 3) {
|
|
const before = relativeLuminance(previous.pixels, offset);
|
|
const after = relativeLuminance(current.pixels, offset);
|
|
const delta = after - before;
|
|
// The WCAG general-flash luminance boundary: a >=0.1 change with the
|
|
// darker state below 0.8. A quarter-screen area is intentionally more
|
|
// conservative than the normative small-area exclusion.
|
|
if (Math.abs(delta) >= 0.1 && Math.min(before, after) < 0.8) {
|
|
if (delta > 0) brightened += 1;
|
|
else darkened += 1;
|
|
}
|
|
}
|
|
const direction = brightened >= darkened ? 1 : -1;
|
|
const affectedRatio = Math.max(brightened, darkened) / pixelCount;
|
|
if (affectedRatio >= 0.25)
|
|
events.push({ direction, frame: frameIndex, affectedRatio });
|
|
}
|
|
|
|
let maximumFlashesPerSecond = 0;
|
|
const windowFrames = Math.max(1, Math.round(framesPerSecond));
|
|
for (let startFrame = 1; startFrame < frames.length; startFrame += 1) {
|
|
const windowEvents = events.filter(
|
|
(event) =>
|
|
event.frame >= startFrame && event.frame < startFrame + windowFrames,
|
|
);
|
|
let reversals = 0;
|
|
for (let index = 1; index < windowEvents.length; index += 1) {
|
|
if (windowEvents[index]?.direction !== windowEvents[index - 1]?.direction)
|
|
reversals += 1;
|
|
}
|
|
maximumFlashesPerSecond = Math.max(
|
|
maximumFlashesPerSecond,
|
|
Math.ceil(reversals / 2),
|
|
);
|
|
}
|
|
return { events, maximumFlashesPerSecond };
|
|
};
|
|
|
|
test.describe("production visual composition", () => {
|
|
test.beforeEach(async ({}, testInfo) => {
|
|
test.skip(
|
|
testInfo.project.name !== "desktop-chromium",
|
|
"Visual evidence is captured once in the available current Chromium engine.",
|
|
);
|
|
});
|
|
|
|
for (const viewport of viewports) {
|
|
test(`${viewport.label} keeps the professional information layer over the journey world`, async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
await page.setViewportSize(viewport);
|
|
await waitForRenderedPortfolio(page, distUrl);
|
|
|
|
const composition = await page.evaluate(() => {
|
|
const root = document.documentElement;
|
|
const panels = [
|
|
...document.querySelectorAll<HTMLElement>(
|
|
".portfolio-main > .portfolio-section",
|
|
),
|
|
];
|
|
const firstPanel = panels[0];
|
|
if (firstPanel === undefined)
|
|
throw new Error("Missing portfolio panel.");
|
|
const firstPanelStyle = getComputedStyle(firstPanel);
|
|
const textStyles = [
|
|
...document.querySelectorAll<HTMLElement>(
|
|
".portfolio-main :is(h2,h3,p)",
|
|
),
|
|
].map((element) => getComputedStyle(element));
|
|
const boxes = [
|
|
...document.querySelectorAll<HTMLElement>(
|
|
".site-header, .portfolio-section, .drive-controls, .journey-hud",
|
|
),
|
|
]
|
|
.filter((element) => getComputedStyle(element).display !== "none")
|
|
.map((element) => element.getBoundingClientRect())
|
|
.filter((rect) => rect.width > 0 && rect.height > 0);
|
|
const fixedSurfaces = [
|
|
...document.querySelectorAll<HTMLElement>(
|
|
".site-navigation, .drive-controls, .journey-hud",
|
|
),
|
|
]
|
|
.filter(
|
|
(element) =>
|
|
getComputedStyle(element).position === "fixed" &&
|
|
getComputedStyle(element).display !== "none",
|
|
)
|
|
.map((element) => ({
|
|
selector: element.className,
|
|
rect: element.getBoundingClientRect(),
|
|
}));
|
|
const fixedSurfaceOverlaps = fixedSurfaces.flatMap((surface, index) =>
|
|
fixedSurfaces
|
|
.slice(index + 1)
|
|
.filter(
|
|
(candidate) =>
|
|
Math.max(
|
|
0,
|
|
Math.min(surface.rect.right, candidate.rect.right) -
|
|
Math.max(surface.rect.left, candidate.rect.left),
|
|
) *
|
|
Math.max(
|
|
0,
|
|
Math.min(surface.rect.bottom, candidate.rect.bottom) -
|
|
Math.max(surface.rect.top, candidate.rect.top),
|
|
) >
|
|
1,
|
|
)
|
|
.map(
|
|
(candidate) =>
|
|
`${surface.selector} overlaps ${candidate.selector}`,
|
|
),
|
|
);
|
|
return {
|
|
noHorizontalOverflow: root.scrollWidth <= root.clientWidth + 1,
|
|
boxesFitHorizontally: boxes.every(
|
|
(rect) => rect.left >= -1 && rect.right <= root.clientWidth + 1,
|
|
),
|
|
panelGeometry: panels.map((panel) => ({
|
|
layout: panel.dataset.layout ?? "",
|
|
width: panel.getBoundingClientRect().width,
|
|
widthRatio: panel.getBoundingClientRect().width / root.clientWidth,
|
|
})),
|
|
panelBackgroundImage: firstPanelStyle.backgroundImage,
|
|
panelFilter: firstPanelStyle.filter,
|
|
textShadows: textStyles.map((style) => style.textShadow),
|
|
publicProjectMediaCount: document.querySelectorAll(
|
|
".project-media img, .project-media video",
|
|
).length,
|
|
controlHeightRatio:
|
|
(document
|
|
.querySelector<HTMLElement>(".drive-controls")
|
|
?.getBoundingClientRect().height ?? 0) / window.innerHeight,
|
|
fixedSurfaceOverlaps,
|
|
};
|
|
});
|
|
|
|
const screenshot = await captureViewport(
|
|
page,
|
|
testInfo,
|
|
`visual-${viewport.label}-default`,
|
|
);
|
|
const palette = await paletteRatios(screenshot);
|
|
|
|
expect(composition.noHorizontalOverflow).toBe(true);
|
|
expect(composition.boxesFitHorizontally).toBe(true);
|
|
expect(composition.panelBackgroundImage).toBe("none");
|
|
expect(composition.panelFilter).toBe("none");
|
|
expect(composition.textShadows.every((shadow) => shadow === "none")).toBe(
|
|
true,
|
|
);
|
|
expect(composition.publicProjectMediaCount).toBe(0);
|
|
expect(composition.fixedSurfaceOverlaps).toEqual([]);
|
|
expect(palette.dark).toBeGreaterThan(0.58);
|
|
expect(palette.restrainedAccent).toBeGreaterThan(0.0005);
|
|
expect(palette.restrainedAccent).toBeLessThan(0.28);
|
|
|
|
if (viewport.width >= 1280) {
|
|
for (const panel of composition.panelGeometry) {
|
|
if (["right-rail", "compact-rail"].includes(panel.layout)) {
|
|
expect(panel.widthRatio).toBeGreaterThanOrEqual(0.4);
|
|
expect(panel.widthRatio).toBeLessThanOrEqual(0.45);
|
|
} else if (panel.layout === "left-timeline") {
|
|
expect(panel.widthRatio).toBeGreaterThanOrEqual(0.44);
|
|
expect(panel.widthRatio).toBeLessThanOrEqual(0.48);
|
|
} else if (["full-diagram", "toolkit-field"].includes(panel.layout)) {
|
|
expect(panel.widthRatio).toBeGreaterThanOrEqual(0.66);
|
|
expect(panel.widthRatio).toBeLessThanOrEqual(0.72);
|
|
} else if (panel.layout === "project-stage") {
|
|
expect(panel.widthRatio).toBeGreaterThanOrEqual(0.9);
|
|
expect(panel.widthRatio).toBeLessThanOrEqual(0.94);
|
|
} else if (panel.layout === "destination") {
|
|
expect(panel.width).toBeGreaterThanOrEqual(580);
|
|
expect(panel.width).toBeLessThanOrEqual(690);
|
|
}
|
|
}
|
|
} else if (viewport.width < 768) {
|
|
expect(
|
|
composition.panelGeometry.every(
|
|
(panel) => panel.widthRatio >= 0.85 && panel.widthRatio <= 1,
|
|
),
|
|
).toBe(true);
|
|
}
|
|
if (viewport.width <= 390 && viewport.height <= 568) {
|
|
expect(composition.controlHeightRatio).toBeLessThanOrEqual(0.34);
|
|
}
|
|
});
|
|
}
|
|
|
|
test("Static is screenshot-stable and retains the same professional content", async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await waitForRenderedPortfolio(page, distUrl);
|
|
await page.getByRole("button", { name: "Drive settings" }).click();
|
|
await page.getByLabel("Quality control").selectOption("static");
|
|
await expect(page.locator(".journey-world")).toHaveAttribute(
|
|
"data-scene-state",
|
|
"static",
|
|
);
|
|
await expect(page.locator(".journey-canvas")).toHaveCount(0);
|
|
await expect(page.locator(".journey-poster")).toHaveCount(1);
|
|
await page.keyboard.press("Escape");
|
|
await page.evaluate(() =>
|
|
(document.activeElement as HTMLElement | null)?.blur(),
|
|
);
|
|
await page.mouse.move(1, 1);
|
|
|
|
const first = await captureViewport(page, testInfo, "visual-static-a");
|
|
await page.waitForTimeout(200);
|
|
const second = await captureViewport(page, testInfo, "visual-static-b");
|
|
expect(await averageNormalizedDifference(first, second)).toBeLessThan(
|
|
0.001,
|
|
);
|
|
});
|
|
|
|
test("reduced motion uses the stable poster without requesting the live scene", async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
|
await page.evaluate(() => document.fonts.ready);
|
|
await expect(page.locator(".journey-canvas")).toHaveCount(0);
|
|
await expect(page.locator(".journey-poster")).toHaveCount(1);
|
|
await page.getByRole("button", { name: "Drive settings" }).click();
|
|
await expect(page.getByRole("status")).toContainText(
|
|
"reduced motion requested",
|
|
);
|
|
await page.keyboard.press("Escape");
|
|
await page.evaluate(() =>
|
|
(document.activeElement as HTMLElement | null)?.blur(),
|
|
);
|
|
await page.mouse.move(1, 1);
|
|
await captureViewport(page, testInfo, "visual-reduced-motion");
|
|
});
|
|
|
|
test("audio-running state stays visually restrained and exposes its controls", async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await waitForRenderedPortfolio(page, distUrl);
|
|
await page.getByRole("button", { name: "PLAY SOUNDTRACK" }).click();
|
|
await expect(
|
|
page.getByRole("button", { name: "Mute sound" }),
|
|
).toBeVisible();
|
|
await page.getByRole("button", { name: "Sound volume" }).click();
|
|
await expect(page.getByLabel("Sound volume level")).toHaveValue("0.4");
|
|
await captureViewport(page, testInfo, "visual-audio-running");
|
|
});
|
|
|
|
test("media failure leaves one composed fallback and usable information", async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await page.addInitScript(() => {
|
|
const nativeGetContext = HTMLCanvasElement.prototype.getContext;
|
|
Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
|
|
configurable: true,
|
|
value(
|
|
this: HTMLCanvasElement,
|
|
contextId: string,
|
|
options?: unknown,
|
|
): RenderingContext | null {
|
|
if (
|
|
contextId === "webgl" ||
|
|
contextId === "webgl2" ||
|
|
contextId === "experimental-webgl"
|
|
)
|
|
return null;
|
|
return Reflect.apply(nativeGetContext, this, [
|
|
contextId,
|
|
options,
|
|
]) as RenderingContext | null;
|
|
},
|
|
});
|
|
});
|
|
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
|
await expect(page.locator(".journey-world")).toHaveAttribute(
|
|
"data-scene-state",
|
|
"failed",
|
|
);
|
|
await expect(page.locator(".journey-poster")).toHaveCount(1);
|
|
await expect(page.locator(".journey-canvas")).toHaveCount(0);
|
|
await expect(
|
|
page.getByRole("heading", { level: 1, name: "Luca Pîslaru" }),
|
|
).toBeVisible();
|
|
await captureViewport(page, testInfo, "visual-media-failure");
|
|
});
|
|
|
|
test("missing optional social profiles preserves the contact destination", async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 768, height: 1024 });
|
|
await waitForRenderedPortfolio(page, distUrl);
|
|
await page.locator('#contact a[target="_blank"]').evaluateAll((links) => {
|
|
for (const link of links) link.closest("li")?.remove();
|
|
});
|
|
await page.locator('nav a[href="#contact"]').click();
|
|
await expect(page.locator("#contact-heading")).toBeFocused();
|
|
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
|
await expect(page.locator('#contact a[target="_blank"]')).toHaveCount(0);
|
|
await captureViewport(page, testInfo, "visual-missing-social");
|
|
});
|
|
|
|
test("Contact arrival resolves the route, HUD, identity, and primary action together", async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await waitForRenderedPortfolio(page, distUrl);
|
|
await page.locator('nav a[href="#contact"]').click();
|
|
await expect(page.locator("#contact-heading")).toBeFocused();
|
|
await expect(page.locator(".journey-hud__section")).toHaveText("Contact");
|
|
await expect(page.locator(".journey-hud__arrival")).toHaveText("ARRIVED");
|
|
await expect(page.locator("#contact")).toBeInViewport();
|
|
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
|
await captureViewport(page, testInfo, "visual-contact-arrival");
|
|
});
|
|
});
|
|
|
|
test.describe("three-flash safety", () => {
|
|
test.beforeEach(async ({}, testInfo) => {
|
|
test.skip(
|
|
testInfo.project.name !== "desktop-chromium",
|
|
"Automated flash evidence runs in the available current Chromium engine.",
|
|
);
|
|
});
|
|
|
|
test("high-speed live scene remains below the conservative three-flash threshold", async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
test.setTimeout(60_000);
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await waitForRenderedPortfolio(page, distUrl);
|
|
await page.mouse.wheel(0, 900);
|
|
await page.mouse.wheel(0, 900);
|
|
const frames = await captureWorldSequence(
|
|
page.locator(".journey-world"),
|
|
20,
|
|
100,
|
|
);
|
|
const result = analyzeFlashSequence(frames, 10);
|
|
await testInfo.attach("live-scene-flash-analysis.json", {
|
|
body: Buffer.from(JSON.stringify(result, null, 2)),
|
|
contentType: "application/json",
|
|
});
|
|
expect(result.maximumFlashesPerSecond).toBeLessThanOrEqual(3);
|
|
});
|
|
|
|
test("Static and reduced-motion poster sequences contain no qualifying flash", async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await waitForRenderedPortfolio(page, distUrl);
|
|
await page.getByRole("button", { name: "Drive settings" }).click();
|
|
await page.getByLabel("Quality control").selectOption("static");
|
|
await expect(page.locator(".journey-world")).toHaveAttribute(
|
|
"data-scene-state",
|
|
"static",
|
|
);
|
|
await page.keyboard.press("Escape");
|
|
await page.evaluate(() =>
|
|
(document.activeElement as HTMLElement | null)?.blur(),
|
|
);
|
|
await page.mouse.move(1, 1);
|
|
const staticResult = analyzeFlashSequence(
|
|
await captureWorldSequence(page.locator(".journey-world"), 8, 100),
|
|
10,
|
|
);
|
|
await testInfo.attach("static-flash-analysis.json", {
|
|
body: Buffer.from(JSON.stringify(staticResult, null, 2)),
|
|
contentType: "application/json",
|
|
});
|
|
expect(staticResult.maximumFlashesPerSecond).toBe(0);
|
|
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
await expect(page.locator(".journey-canvas")).toHaveCount(0);
|
|
const reducedResult = analyzeFlashSequence(
|
|
await captureWorldSequence(page.locator(".journey-world"), 8, 100),
|
|
10,
|
|
);
|
|
await testInfo.attach("reduced-motion-flash-analysis.json", {
|
|
body: Buffer.from(JSON.stringify(reducedResult, null, 2)),
|
|
contentType: "application/json",
|
|
});
|
|
expect(reducedResult.maximumFlashesPerSecond).toBe(0);
|
|
});
|
|
});
|