fix: close final portfolio quality gaps
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "../helpers/serve-dist";
|
||||
import { clickNavigationLink, expect, test } from "../helpers/serve-dist";
|
||||
|
||||
test("forced colors preserves visible focus, controls, content, and the Contact action", async ({
|
||||
page,
|
||||
@@ -45,7 +45,7 @@ test("forced colors preserves visible focus, controls, content, and the Contact
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await page.locator('nav a[href="#contact"]').click();
|
||||
await clickNavigationLink(page, "#contact");
|
||||
await expect(page.locator("#contact-heading")).toBeFocused();
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
||||
expect(
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../helpers/serve-dist";
|
||||
import { expect, revealNavigationLinks, test } from "../helpers/serve-dist";
|
||||
|
||||
const navigationHrefs = [
|
||||
"#start",
|
||||
"#profile",
|
||||
"#experience",
|
||||
"#infrastructure",
|
||||
"#selected-builds",
|
||||
"#additional-work",
|
||||
"#toolkit",
|
||||
"#contact",
|
||||
] as const;
|
||||
|
||||
interface FocusGeometry {
|
||||
readonly description: string;
|
||||
@@ -74,6 +85,7 @@ test("every enabled author pointer target is 44 by 44 CSS pixels or a valid inli
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
await revealNavigationLinks(page);
|
||||
|
||||
const failures = await page
|
||||
.locator("a[href], button, input, select, textarea")
|
||||
@@ -131,6 +143,10 @@ test("keyboard traversal reaches Contact in document order with visible unobscur
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
const compactNavigation = await page
|
||||
.locator(".site-navigation summary")
|
||||
.isVisible();
|
||||
await revealNavigationLinks(page);
|
||||
|
||||
const visited: string[] = [];
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
@@ -154,17 +170,12 @@ test("keyboard traversal reaches Contact in document order with visible unobscur
|
||||
if (geometry.description.startsWith("mailto:")) break;
|
||||
}
|
||||
|
||||
expect(visited.at(0)).toBe("#portfolio-content");
|
||||
expect(visited.slice(1, 9)).toEqual([
|
||||
"#start",
|
||||
"#profile",
|
||||
"#experience",
|
||||
"#infrastructure",
|
||||
"#selected-builds",
|
||||
"#additional-work",
|
||||
"#toolkit",
|
||||
"#contact",
|
||||
]);
|
||||
const expectedPrefix = [
|
||||
"#portfolio-content",
|
||||
...(compactNavigation ? ["Navigate"] : []),
|
||||
...navigationHrefs,
|
||||
];
|
||||
expect(visited.slice(0, expectedPrefix.length)).toEqual(expectedPrefix);
|
||||
expect(visited.some((description) => description.startsWith("mailto:"))).toBe(
|
||||
true,
|
||||
);
|
||||
@@ -175,8 +186,14 @@ test("explicit keyboard navigation focuses a section heading without a fixed-sur
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
const compactNavigation = await page
|
||||
.locator(".site-navigation summary")
|
||||
.isVisible();
|
||||
await revealNavigationLinks(page);
|
||||
|
||||
for (let index = 0; index < 9; index += 1) await page.keyboard.press("Tab");
|
||||
const contactTabIndex = compactNavigation ? 10 : 9;
|
||||
for (let index = 0; index < contactTabIndex; index += 1)
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.locator('nav a[href="#contact"]')).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("#contact-heading")).toBeFocused();
|
||||
@@ -194,17 +211,31 @@ test("responsive navigation exposes the exact visible label as each link's acces
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByRole("link", { name: "Career", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "More work", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Experience", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "Additional work", exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Career", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("link", { name: "More work", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Experience", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Additional work", exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.setViewportSize({ width: 320, height: 800 });
|
||||
const career = page.getByRole("link", { name: "Career", exact: true });
|
||||
await expect(career).toHaveText("Career");
|
||||
await expect(page.getByRole("link", { name: "More work", exact: true })).toHaveText("More work");
|
||||
await expect(page.getByRole("link", { name: "Experience", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "Additional work", exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("link", { name: "More work", exact: true }),
|
||||
).toHaveText("More work");
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Experience", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Additional work", exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await career.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../helpers/serve-dist";
|
||||
import { clickNavigationLink, expect, test } from "../helpers/serve-dist";
|
||||
|
||||
const textSpacingOverride = `
|
||||
* {
|
||||
@@ -62,7 +62,7 @@ async function assertReflowGeometry(page: Page): Promise<void> {
|
||||
expect(result.horizontalFailures).toEqual([]);
|
||||
expect(result.clippedText).toEqual([]);
|
||||
|
||||
await page.locator('nav a[href="#contact"]').click();
|
||||
await clickNavigationLink(page, "#contact");
|
||||
await expect(page.locator("#contact-heading")).toBeFocused();
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -1,133 +1,251 @@
|
||||
import { expect, expectInsetFocusIndicator, test } from '../helpers/serve-dist';
|
||||
import { expect, expectInsetFocusIndicator, test } from "../helpers/serve-dist";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const OriginalAudioContext = window.AudioContext;
|
||||
(window as unknown as { __audioConstructed: number }).__audioConstructed = 0;
|
||||
(window as unknown as { __audioContexts: AudioContext[] }).__audioContexts = [];
|
||||
(window as unknown as { __audioConstructed: number }).__audioConstructed =
|
||||
0;
|
||||
(window as unknown as { __audioContexts: AudioContext[] }).__audioContexts =
|
||||
[];
|
||||
if (!OriginalAudioContext) return;
|
||||
class TrackingAudioContext extends OriginalAudioContext {
|
||||
constructor(options?: AudioContextOptions) {
|
||||
super(options);
|
||||
const state = window as unknown as { __audioConstructed: number; __audioContexts: AudioContext[] };
|
||||
const state = window as unknown as {
|
||||
__audioConstructed: number;
|
||||
__audioContexts: AudioContext[];
|
||||
};
|
||||
state.__audioConstructed += 1;
|
||||
state.__audioContexts.push(this);
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, 'AudioContext', { configurable: true, value: TrackingAudioContext });
|
||||
Object.defineProperty(window, "AudioContext", {
|
||||
configurable: true,
|
||||
value: TrackingAudioContext,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('starts Web Audio synchronously from the visitor gesture and exposes accessible controls', async ({ page, distUrl }) => {
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
test("starts Web Audio synchronously from the visitor gesture and exposes accessible controls", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { __constructedDuringGesture?: number }).__constructedDuringGesture = -1;
|
||||
window.addEventListener('click', (event) => {
|
||||
if ((event.target as HTMLElement | null)?.textContent?.includes('START THE DRIVE')) {
|
||||
const state = window as unknown as { __audioConstructed: number; __constructedDuringGesture: number };
|
||||
(
|
||||
window as unknown as { __constructedDuringGesture?: number }
|
||||
).__constructedDuringGesture = -1;
|
||||
window.addEventListener("click", (event) => {
|
||||
if (
|
||||
(event.target as HTMLElement | null)?.textContent?.includes(
|
||||
"START THE DRIVE",
|
||||
)
|
||||
) {
|
||||
const state = window as unknown as {
|
||||
__audioConstructed: number;
|
||||
__constructedDuringGesture: number;
|
||||
};
|
||||
state.__constructedDuringGesture = state.__audioConstructed;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Start the drive' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Mute sound' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Sound volume' })).toHaveCount(0);
|
||||
await expect.poll(() => page.evaluate(() => (window as unknown as { __audioConstructed: number }).__audioConstructed)).toBe(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Start the drive" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Mute sound" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Sound volume" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __audioConstructed: number })
|
||||
.__audioConstructed,
|
||||
),
|
||||
)
|
||||
.toBe(0);
|
||||
|
||||
await page.getByRole('button', { name: 'Start the drive' }).click();
|
||||
await page.getByRole("button", { name: "Start the drive" }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Mute sound' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Sound volume' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Pause motion' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Drive settings' })).toBeVisible();
|
||||
await expect.poll(() => page.evaluate(() => (window as unknown as { __constructedDuringGesture: number }).__constructedDuringGesture)).toBe(1);
|
||||
await expect.poll(() => page.evaluate(() => (window as unknown as { __audioConstructed: number }).__audioConstructed)).toBe(1);
|
||||
await expect(page.getByRole("button", { name: "Mute sound" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Sound volume" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Pause motion" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Drive settings" }),
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __constructedDuringGesture: number })
|
||||
.__constructedDuringGesture,
|
||||
),
|
||||
)
|
||||
.toBe(1);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __audioConstructed: number })
|
||||
.__audioConstructed,
|
||||
),
|
||||
)
|
||||
.toBe(1);
|
||||
|
||||
await page.getByRole('button', { name: 'Mute sound' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Unmute sound' })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Mute sound" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Unmute sound" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('keeps the compact controls usable and dismisses the upward panels', async ({ page, distUrl }) => {
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.getByRole('button', { name: 'Start the drive' }).click();
|
||||
test("keeps the compact controls usable and dismisses the upward panels", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "Start the drive" }).click();
|
||||
|
||||
const controls = page.getByRole('group', { name: 'Drive controls' }).getByRole('button');
|
||||
const targets = await controls.evaluateAll((buttons) => buttons.map((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return { width: rect.width, height: rect.height };
|
||||
}));
|
||||
expect(targets.every((target) => target.width >= 44 && target.height >= 44)).toBe(true);
|
||||
const controls = page
|
||||
.getByRole("group", { name: "Drive controls" })
|
||||
.getByRole("button");
|
||||
const targets = await controls.evaluateAll((buttons) =>
|
||||
buttons.map((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return { width: rect.width, height: rect.height };
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
targets.every((target) => target.width >= 44 && target.height >= 44),
|
||||
).toBe(true);
|
||||
|
||||
const settings = page.getByRole('button', { name: 'Drive settings' });
|
||||
const settings = page.getByRole("button", { name: "Drive settings" });
|
||||
await settings.focus();
|
||||
await expectInsetFocusIndicator(page, 'button[aria-label="Drive settings"]');
|
||||
await settings.click();
|
||||
await expect(page.getByRole('dialog', { name: 'Drive settings' })).toBeVisible();
|
||||
const surfaces = await page.locator('.journey-hud, .drive-controls__dock, .drive-popover').evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const alpha = Number.parseFloat(style.backgroundColor.match(/[\d.]+(?=\))/u)?.[0] ?? '1');
|
||||
return { alpha, backdropFilter: style.backdropFilter };
|
||||
}));
|
||||
expect(surfaces.every((surface) => surface.alpha >= 0.95 && surface.backdropFilter === 'none')).toBe(true);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('dialog', { name: 'Drive settings' })).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Drive settings" }),
|
||||
).toBeVisible();
|
||||
const surfaces = await page
|
||||
.locator(".journey-hud, .drive-controls__dock, .drive-popover")
|
||||
.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const alpha = Number.parseFloat(
|
||||
style.backgroundColor.match(/[\d.]+(?=\))/u)?.[0] ?? "1",
|
||||
);
|
||||
return { alpha, backdropFilter: style.backdropFilter };
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
surfaces.every(
|
||||
(surface) => surface.alpha >= 0.95 && surface.backdropFilter === "none",
|
||||
),
|
||||
).toBe(true);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Drive settings" }),
|
||||
).toHaveCount(0);
|
||||
await expect(settings).toBeFocused();
|
||||
|
||||
await settings.click();
|
||||
await page.locator('main#portfolio-content').click({ position: { x: 8, y: 8 } });
|
||||
await expect(page.getByRole('dialog', { name: 'Drive settings' })).toHaveCount(0);
|
||||
await page
|
||||
.locator(".hero-journey__content")
|
||||
.click({ position: { x: 8, y: 8 } });
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Drive settings" }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('persists native volume and keeps sound independent from Static motion', async ({ page, distUrl }) => {
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.getByRole('button', { name: 'Start the drive' }).click();
|
||||
test("persists native volume and keeps sound independent from Static motion", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "Start the drive" }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Sound volume' }).click();
|
||||
const volume = page.getByLabel('Sound volume level');
|
||||
await volume.fill('0.65');
|
||||
await expect(volume).toHaveValue('0.65');
|
||||
await expect.poll(() => page.evaluate(() => localStorage.getItem('portfolio.audio.volume'))).toBe('0.65');
|
||||
await page.getByRole("button", { name: "Sound volume" }).click();
|
||||
const volume = page.getByLabel("Sound volume level");
|
||||
await volume.fill("0.65");
|
||||
await expect(volume).toHaveValue("0.65");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => localStorage.getItem("portfolio.audio.volume")),
|
||||
)
|
||||
.toBe("0.65");
|
||||
|
||||
await page.getByRole('button', { name: 'Drive settings' }).click();
|
||||
await page.getByLabel('Quality control').selectOption('static');
|
||||
await expect(page.getByRole('button', { name: 'Unmute sound' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Mute sound' })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Drive settings" }).click();
|
||||
await page.getByLabel("Quality control").selectOption("static");
|
||||
await expect(page.getByRole("button", { name: "Unmute sound" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "Mute sound" })).toBeVisible();
|
||||
await expect(volume).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Sound volume' }).click();
|
||||
await page.getByRole("button", { name: "Sound volume" }).click();
|
||||
await expect(volume).toBeVisible();
|
||||
});
|
||||
|
||||
test('requires a direct visitor action after browser interruption without creating another graph', async ({ page, distUrl }) => {
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.getByRole('button', { name: 'Start the drive' }).click();
|
||||
test("requires a direct visitor action after browser interruption without creating another graph", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "Start the drive" }).click();
|
||||
|
||||
await page.evaluate(() => {
|
||||
const context = (window as unknown as { __audioContexts: AudioContext[] }).__audioContexts[0];
|
||||
Object.defineProperty(context, 'state', { configurable: true, value: 'interrupted' });
|
||||
context?.dispatchEvent(new Event('statechange'));
|
||||
const context = (window as unknown as { __audioContexts: AudioContext[] })
|
||||
.__audioContexts[0];
|
||||
Object.defineProperty(context, "state", {
|
||||
configurable: true,
|
||||
value: "interrupted",
|
||||
});
|
||||
context?.dispatchEvent(new Event("statechange"));
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Resume sound' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Resume sound' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Mute sound' })).toBeVisible();
|
||||
await expect.poll(() => page.evaluate(() => (window as unknown as { __audioConstructed: number }).__audioConstructed)).toBe(1);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Resume sound" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Resume sound" }).click();
|
||||
await expect(page.getByRole("button", { name: "Mute sound" })).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __audioConstructed: number })
|
||||
.__audioConstructed,
|
||||
),
|
||||
)
|
||||
.toBe(1);
|
||||
});
|
||||
|
||||
test('shows a concise non-blocking message when initial audio start is rejected', async ({ page, distUrl }) => {
|
||||
test("shows a concise non-blocking message when initial audio start is rejected", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
const OriginalAudioContext = window.AudioContext;
|
||||
if (!OriginalAudioContext) return;
|
||||
class RejectingAudioContext extends OriginalAudioContext {
|
||||
override resume(): Promise<void> { return Promise.reject(new DOMException('blocked', 'NotAllowedError')); }
|
||||
override resume(): Promise<void> {
|
||||
return Promise.reject(new DOMException("blocked", "NotAllowedError"));
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, 'AudioContext', { configurable: true, value: RejectingAudioContext });
|
||||
Object.defineProperty(window, "AudioContext", {
|
||||
configurable: true,
|
||||
value: RejectingAudioContext,
|
||||
});
|
||||
});
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.getByRole('button', { name: 'Start the drive' }).click();
|
||||
await page.getByRole("button", { name: "Start the drive" }).click();
|
||||
|
||||
await expect(page.getByText('Audio unavailable. Continue without sound.')).toBeVisible();
|
||||
await expect(page.locator('main#portfolio-content')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Audio unavailable. Continue without sound."),
|
||||
).toBeVisible();
|
||||
await expect(page.locator("main#portfolio-content")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1,57 +1,87 @@
|
||||
import { expect, test } from '../helpers/serve-dist';
|
||||
import { expect, test } from "../helpers/serve-dist";
|
||||
import {
|
||||
dispatchPersistedPageTransition,
|
||||
installSceneInstrumentation,
|
||||
readSceneInstrumentation,
|
||||
} from '../helpers/scene-instrumentation';
|
||||
} from "../helpers/scene-instrumentation";
|
||||
|
||||
test.beforeEach(async ({ page }) => installSceneInstrumentation(page));
|
||||
|
||||
test('synthetic persisted freeze and restore keeps at most one canvas, RAF loop, and audio context', async ({
|
||||
test("synthetic persisted freeze and restore keeps at most one canvas, RAF loop, and audio context", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||
await page.getByRole('button', { name: 'START THE DRIVE' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Mute sound' })).toBeVisible();
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".journey-world")).toHaveAttribute(
|
||||
"data-scene-state",
|
||||
"running",
|
||||
);
|
||||
await page.getByRole("button", { name: "START THE DRIVE" }).click();
|
||||
await expect(page.getByRole("button", { name: "Mute sound" })).toBeVisible();
|
||||
|
||||
for (let cycle = 0; cycle < 3; cycle += 1) {
|
||||
await dispatchPersistedPageTransition(page, 'pagehide');
|
||||
await expect.poll(async () => (await readSceneInstrumentation(page)).activeAnimationFrames).toBe(0);
|
||||
await dispatchPersistedPageTransition(page, 'pageshow');
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||
await expect.poll(async () => (await readSceneInstrumentation(page)).activeAnimationFrames).toBeLessThanOrEqual(1);
|
||||
await dispatchPersistedPageTransition(page, "pagehide");
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await readSceneInstrumentation(page)).activeAnimationFrames,
|
||||
)
|
||||
.toBe(0);
|
||||
await dispatchPersistedPageTransition(page, "pageshow");
|
||||
await expect(page.locator(".journey-world")).toHaveAttribute(
|
||||
"data-scene-state",
|
||||
"running",
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await readSceneInstrumentation(page)).activeAnimationFrames,
|
||||
)
|
||||
.toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
const state = await readSceneInstrumentation(page);
|
||||
expect(state.maximumCanvasCount).toBe(1);
|
||||
expect(await page.locator('.journey-canvas').count()).toBe(1);
|
||||
expect(await page.locator(".journey-canvas").count()).toBe(1);
|
||||
expect(state.audioContextsConstructed).toBe(1);
|
||||
expect(state.liveAudioContexts).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('persisted lifecycle events preserve paused and Static states without duplicate resources', async ({
|
||||
test("persisted lifecycle events preserve paused and Static states without duplicate resources", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".journey-world")).toHaveAttribute(
|
||||
"data-scene-state",
|
||||
"running",
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Pause motion' }).click();
|
||||
await dispatchPersistedPageTransition(page, 'pagehide');
|
||||
await dispatchPersistedPageTransition(page, 'pageshow');
|
||||
await expect(page.locator('.journey-canvas')).toHaveCount(1);
|
||||
await expect(page.getByRole('button', { name: 'Resume motion' })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Start the drive" }).click();
|
||||
await page.getByRole("button", { name: "Pause motion" }).click();
|
||||
await dispatchPersistedPageTransition(page, "pagehide");
|
||||
await dispatchPersistedPageTransition(page, "pageshow");
|
||||
await expect(page.locator(".journey-canvas")).toHaveCount(1);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Resume motion" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel('Quality control').selectOption('static');
|
||||
await dispatchPersistedPageTransition(page, 'pagehide');
|
||||
await dispatchPersistedPageTransition(page, 'pageshow');
|
||||
await expect(page.locator('.journey-canvas')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Enable motion' })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Drive settings" }).click();
|
||||
await page.getByLabel("Quality control").selectOption("static");
|
||||
await dispatchPersistedPageTransition(page, "pagehide");
|
||||
await dispatchPersistedPageTransition(page, "pageshow");
|
||||
await expect(page.locator(".journey-canvas")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Enable motion" }),
|
||||
).toBeVisible();
|
||||
|
||||
const state = await readSceneInstrumentation(page);
|
||||
expect(state.maximumCanvasCount).toBeLessThanOrEqual(1);
|
||||
await expect.poll(async () => (await readSceneInstrumentation(page)).activeAnimationFrames).toBe(0);
|
||||
expect(state.liveAudioContexts).toBe(0);
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await readSceneInstrumentation(page)).activeAnimationFrames,
|
||||
)
|
||||
.toBe(0);
|
||||
expect(state.audioContextsConstructed).toBe(1);
|
||||
expect(state.liveAudioContexts).toBe(1);
|
||||
});
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { expect, expectInsetFocusIndicator, test } from '../helpers/serve-dist';
|
||||
import { expect, expectInsetFocusIndicator, test } from "../helpers/serve-dist";
|
||||
|
||||
const navHrefs = [
|
||||
'#start',
|
||||
'#profile',
|
||||
'#experience',
|
||||
'#infrastructure',
|
||||
'#selected-builds',
|
||||
'#additional-work',
|
||||
'#toolkit',
|
||||
'#contact',
|
||||
"#start",
|
||||
"#profile",
|
||||
"#experience",
|
||||
"#infrastructure",
|
||||
"#selected-builds",
|
||||
"#additional-work",
|
||||
"#toolkit",
|
||||
"#contact",
|
||||
] as const;
|
||||
|
||||
const assertVisibleUnobscuredFocus = async (
|
||||
page: import('@playwright/test').Page,
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<void> => {
|
||||
await expect
|
||||
.poll(() =>
|
||||
@@ -34,7 +34,7 @@ const assertVisibleUnobscuredFocus = async (
|
||||
(active === topElement ||
|
||||
active.contains(topElement) ||
|
||||
topElement.contains(active)) &&
|
||||
style.outlineStyle !== 'none' &&
|
||||
style.outlineStyle !== "none" &&
|
||||
Number.parseFloat(style.outlineWidth) >= 2
|
||||
);
|
||||
}),
|
||||
@@ -42,119 +42,142 @@ const assertVisibleUnobscuredFocus = async (
|
||||
.toBe(true);
|
||||
};
|
||||
|
||||
test('tabs from the skip link through navigation and every contact action', async ({
|
||||
test("tabs from the skip link through navigation and every contact action", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name === "mobile-chromium",
|
||||
"The compact disclosure path is covered by the adaptive keyboard traversal suite.",
|
||||
);
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
|
||||
const expectedTargets = [
|
||||
{ selector: '.skip-link', href: '#portfolio-content' },
|
||||
{ selector: ".skip-link", href: "#portfolio-content" },
|
||||
...navHrefs.map((href) => ({ selector: `nav a[href="${href}"]`, href })),
|
||||
{ selector: ".hero-journey__action", href: "#selected-builds" },
|
||||
{
|
||||
selector: 'a[href="https://github.com/LucaPisl/footy-tracker"]',
|
||||
href: "https://github.com/LucaPisl/footy-tracker",
|
||||
},
|
||||
{
|
||||
selector:
|
||||
'a[href="https://github.com/LucaPisl/opinion-dynamics-simulation"]',
|
||||
href: "https://github.com/LucaPisl/opinion-dynamics-simulation",
|
||||
},
|
||||
{
|
||||
selector:
|
||||
'a[href="https://github.com/LucaPisl/CurrencyConverterAvalonia"]',
|
||||
href: "https://github.com/LucaPisl/CurrencyConverterAvalonia",
|
||||
},
|
||||
{
|
||||
selector: 'a[href="https://github.com/LucaPisl/LinuxModdingGuide"]',
|
||||
href: "https://github.com/LucaPisl/LinuxModdingGuide",
|
||||
},
|
||||
{
|
||||
selector: '#contact a[href^="mailto:"]',
|
||||
href: 'mailto:luca.pislaru@gmail.com',
|
||||
href: "mailto:luca.pislaru@gmail.com",
|
||||
},
|
||||
{
|
||||
selector: '#contact a[href="https://github.com/LucaPisl"]',
|
||||
href: 'https://github.com/LucaPisl',
|
||||
href: "https://github.com/LucaPisl",
|
||||
},
|
||||
{
|
||||
selector: '#contact a[href="https://gitlab.com/luca_pisl"]',
|
||||
href: 'https://gitlab.com/luca_pisl',
|
||||
href: "https://gitlab.com/luca_pisl",
|
||||
},
|
||||
{
|
||||
selector: '#contact a[href="https://linkedin.com/in/luca-pisl"]',
|
||||
href: 'https://linkedin.com/in/luca-pisl',
|
||||
href: "https://linkedin.com/in/luca-pisl",
|
||||
},
|
||||
];
|
||||
|
||||
for (const target of expectedTargets) {
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press("Tab");
|
||||
const locator = page.locator(target.selector);
|
||||
await expect(locator).toBeFocused();
|
||||
await expect(locator).toHaveAttribute('href', target.href);
|
||||
await expect(locator).toHaveAttribute("href", target.href);
|
||||
await assertVisibleUnobscuredFocus(page);
|
||||
}
|
||||
});
|
||||
|
||||
test('activating the skip link focuses the visible unobscured portfolio content', async ({
|
||||
test("activating the skip link focuses the visible unobscured portfolio content", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator('.skip-link')).toBeFocused();
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.locator(".skip-link")).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(page).toHaveURL(`${distUrl}/#portfolio-content`);
|
||||
await expect(page.locator('#portfolio-content')).toBeFocused();
|
||||
await expect(page.locator('#portfolio-content')).toHaveAttribute(
|
||||
'tabindex',
|
||||
'-1',
|
||||
await expect(page.locator("#portfolio-content")).toBeFocused();
|
||||
await expect(page.locator("#portfolio-content")).toHaveAttribute(
|
||||
"tabindex",
|
||||
"-1",
|
||||
);
|
||||
await expectInsetFocusIndicator(page, '#portfolio-content');
|
||||
await expectInsetFocusIndicator(page, "#portfolio-content");
|
||||
});
|
||||
|
||||
test('explicit section activation scrolls to and focuses its heading', async ({
|
||||
test("explicit section activation scrolls to and focuses its heading", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name === 'mobile-chromium',
|
||||
'The full keyboard path already covers the mobile project.',
|
||||
testInfo.project.name === "mobile-chromium",
|
||||
"The full keyboard path already covers the mobile project.",
|
||||
);
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
|
||||
for (let index = 0; index < 5; index += 1) await page.keyboard.press('Tab');
|
||||
for (let index = 0; index < 5; index += 1) await page.keyboard.press("Tab");
|
||||
await expect(page.locator('nav a[href="#infrastructure"]')).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(page).toHaveURL(`${distUrl}/#infrastructure`);
|
||||
await expect(page.locator('#infrastructure')).toBeInViewport();
|
||||
await expect(page.locator('#infrastructure-heading')).toHaveAttribute(
|
||||
'tabindex',
|
||||
'-1',
|
||||
await expect(page.locator("#infrastructure")).toBeInViewport();
|
||||
await expect(page.locator("#infrastructure-heading")).toHaveAttribute(
|
||||
"tabindex",
|
||||
"-1",
|
||||
);
|
||||
await expect(page.locator('#infrastructure-heading')).toBeFocused();
|
||||
await expect(page.locator("#infrastructure-heading")).toBeFocused();
|
||||
await assertVisibleUnobscuredFocus(page);
|
||||
});
|
||||
|
||||
test('manual wheel and PageDown scrolling never move focus', async ({
|
||||
test("manual wheel and PageDown scrolling never move focus", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name === 'mobile-chromium',
|
||||
'The focus invariant is engine-level rather than viewport-specific.',
|
||||
testInfo.project.name === "mobile-chromium",
|
||||
"The focus invariant is engine-level rather than viewport-specific.",
|
||||
);
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Tab");
|
||||
const startLink = page.locator('nav a[href="#start"]');
|
||||
await expect(startLink).toBeFocused();
|
||||
|
||||
await page.mouse.wheel(0, 900);
|
||||
await expect(startLink).toBeFocused();
|
||||
await page.keyboard.press('PageDown');
|
||||
await page.keyboard.press("PageDown");
|
||||
await expect(startLink).toBeFocused();
|
||||
});
|
||||
|
||||
test('direct fragments do not steal focus while reduced-motion navigation remains accessible', async ({
|
||||
test("direct fragments do not steal focus while reduced-motion navigation remains accessible", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name === 'mobile-chromium',
|
||||
'The full keyboard path already covers the mobile project.',
|
||||
testInfo.project.name === "mobile-chromium",
|
||||
"The full keyboard path already covers the mobile project.",
|
||||
);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`${distUrl}/#toolkit`, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('#toolkit-heading')).not.toBeFocused();
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.goto(`${distUrl}/#toolkit`, { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator("#toolkit-heading")).not.toBeFocused();
|
||||
|
||||
await page.locator('nav a[href="#contact"]').click();
|
||||
await expect(page).toHaveURL(`${distUrl}/#contact`);
|
||||
await expect(page.locator('#contact-heading')).toBeFocused();
|
||||
await expect(page.locator('#contact')).toBeInViewport();
|
||||
await expect(page.locator("#contact-heading")).toBeFocused();
|
||||
await expect(page.locator("#contact")).toBeInViewport();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { expect, test } from "../helpers/serve-dist";
|
||||
import {
|
||||
clickNavigationLink,
|
||||
expect,
|
||||
revealNavigationLinks,
|
||||
test,
|
||||
} from "../helpers/serve-dist";
|
||||
|
||||
test("keeps canonical hrefs while creating exactly one or zero explicit history entries", async ({
|
||||
page,
|
||||
@@ -6,20 +11,18 @@ test("keeps canonical hrefs while creating exactly one or zero explicit history
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
const initialLength = await page.evaluate(() => history.length);
|
||||
const start = page.locator('nav a[href="#start"]');
|
||||
const profile = page.locator('nav a[href="#profile"]');
|
||||
|
||||
await start.click();
|
||||
await clickNavigationLink(page, "#start");
|
||||
await expect(page).toHaveURL(`${distUrl}/`);
|
||||
expect(await page.evaluate(() => history.length)).toBe(initialLength);
|
||||
await expect(page.locator("#start-heading")).toBeFocused();
|
||||
|
||||
await profile.click();
|
||||
await clickNavigationLink(page, "#profile");
|
||||
await expect(page).toHaveURL(`${distUrl}/#profile`);
|
||||
expect(await page.evaluate(() => history.length)).toBe(initialLength + 1);
|
||||
await expect(page.locator("#profile-heading")).toBeFocused();
|
||||
|
||||
await profile.click();
|
||||
await clickNavigationLink(page, "#profile");
|
||||
expect(await page.evaluate(() => history.length)).toBe(initialLength + 1);
|
||||
await expect(page.locator("#profile-heading")).toBeFocused();
|
||||
});
|
||||
@@ -27,7 +30,11 @@ test("keeps canonical hrefs while creating exactly one or zero explicit history
|
||||
test("cancels pending explicit focus when wheel intent intervenes", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name === "mobile-chromium",
|
||||
"Hidden-focus cancellation is covered once in desktop Chromium.",
|
||||
);
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
const link = page.locator('nav a[href="#infrastructure"]');
|
||||
await link.focus();
|
||||
@@ -56,6 +63,7 @@ test("passive scrolling replaces a stable fragment without stealing focus", asyn
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
await revealNavigationLinks(page);
|
||||
const link = page.locator('nav a[href="#start"]');
|
||||
await link.focus();
|
||||
|
||||
@@ -80,8 +88,8 @@ test("back and forward restore sections without another history mutation or focu
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.locator('nav a[href="#profile"]').click();
|
||||
await page.locator('nav a[href="#experience"]').click();
|
||||
await clickNavigationLink(page, "#profile");
|
||||
await clickNavigationLink(page, "#experience");
|
||||
const historyLength = await page.evaluate(() => history.length);
|
||||
await expect(page.locator("#experience-heading")).toBeFocused();
|
||||
|
||||
|
||||
@@ -1,49 +1,50 @@
|
||||
import type { PortfolioContent } from '@/content/types';
|
||||
import { expect, expectInsetFocusIndicator, test } from '../helpers/serve-dist';
|
||||
import type { PortfolioContent } from "@/content/types";
|
||||
import { expect, expectInsetFocusIndicator, test } from "../helpers/serve-dist";
|
||||
|
||||
test.use({ javaScriptEnabled: false });
|
||||
|
||||
const sectionIds = [
|
||||
'start',
|
||||
'profile',
|
||||
'experience',
|
||||
'infrastructure',
|
||||
'selected-builds',
|
||||
'additional-work',
|
||||
'toolkit',
|
||||
'contact',
|
||||
"start",
|
||||
"profile",
|
||||
"experience",
|
||||
"infrastructure",
|
||||
"selected-builds",
|
||||
"additional-work",
|
||||
"toolkit",
|
||||
"contact",
|
||||
] as const;
|
||||
|
||||
const sectionHeadings = [
|
||||
'Profile',
|
||||
'Experience',
|
||||
'Infrastructure',
|
||||
'Selected builds',
|
||||
'Additional work',
|
||||
'Toolkit',
|
||||
'Contact',
|
||||
"Profile",
|
||||
"Experience",
|
||||
"Infrastructure",
|
||||
"Selected builds",
|
||||
"Additional work",
|
||||
"Toolkit",
|
||||
"Contact",
|
||||
] as const;
|
||||
|
||||
const navigationLabels: Readonly<Record<(typeof sectionIds)[number], string>> = {
|
||||
start: 'Start',
|
||||
profile: 'Profile',
|
||||
experience: 'Career',
|
||||
infrastructure: 'Infra',
|
||||
'selected-builds': 'Builds',
|
||||
'additional-work': 'More work',
|
||||
toolkit: 'Toolkit',
|
||||
contact: 'Contact',
|
||||
};
|
||||
const navigationLabels: Readonly<Record<(typeof sectionIds)[number], string>> =
|
||||
{
|
||||
start: "Start",
|
||||
profile: "Profile",
|
||||
experience: "Career",
|
||||
infrastructure: "Infra",
|
||||
"selected-builds": "Builds",
|
||||
"additional-work": "More work",
|
||||
toolkit: "Toolkit",
|
||||
contact: "Contact",
|
||||
};
|
||||
|
||||
const visibleTextLeafSelector = 'h1,h2,h3,p,dt,dd,li,figcaption,a,svg text';
|
||||
const visibleTextLeafSelector = "h1,h2,h3,p,dt,dd,li,figcaption,a,svg text";
|
||||
const visibleTextSelector = [
|
||||
'.site-header :is(h1,h2,h3,p,dt,dd,li,figcaption,a)',
|
||||
'.portfolio-main :is(h1,h2,h3,p,dt,dd,li,figcaption,a)',
|
||||
'.portfolio-main svg text',
|
||||
].join(',');
|
||||
".site-header :is(h1,h2,h3,p,dt,dd,li,figcaption,a)",
|
||||
".portfolio-main :is(h1,h2,h3,p,dt,dd,li,figcaption,a)",
|
||||
".portfolio-main svg text",
|
||||
].join(",");
|
||||
|
||||
const normalizedText = (value: string): string =>
|
||||
value.replace(/\s+/gu, ' ').trim();
|
||||
value.replace(/\s+/gu, " ").trim();
|
||||
|
||||
type TextMultiset = Array<[text: string, count: number]>;
|
||||
|
||||
@@ -69,14 +70,14 @@ const missingText = (
|
||||
};
|
||||
|
||||
const portfolioContentFromSnapshot = async (
|
||||
page: import('@playwright/test').Page,
|
||||
page: import("@playwright/test").Page,
|
||||
rawHtml: string,
|
||||
): Promise<PortfolioContent> =>
|
||||
page.evaluate((html) => {
|
||||
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||
const snapshot = document.querySelector('#portfolio-inputs');
|
||||
const document = new DOMParser().parseFromString(html, "text/html");
|
||||
const snapshot = document.querySelector("#portfolio-inputs");
|
||||
if (snapshot?.textContent === null || snapshot?.textContent === undefined) {
|
||||
throw new Error('Missing #portfolio-inputs snapshot');
|
||||
throw new Error("Missing #portfolio-inputs snapshot");
|
||||
}
|
||||
return (JSON.parse(snapshot.textContent) as { content: PortfolioContent })
|
||||
.content;
|
||||
@@ -89,7 +90,7 @@ const expectedVisibleTextSegments = (
|
||||
content.navigation.map((item) => [item.id, item.label]),
|
||||
);
|
||||
const section = (
|
||||
id: Exclude<(typeof sectionIds)[number], 'start'>,
|
||||
id: Exclude<(typeof sectionIds)[number], "start">,
|
||||
body: readonly string[],
|
||||
eyebrow?: string,
|
||||
): string[] => {
|
||||
@@ -100,60 +101,65 @@ const expectedVisibleTextSegments = (
|
||||
);
|
||||
};
|
||||
const projectLinkText = (
|
||||
project: PortfolioContent['projects']['selected'][number],
|
||||
project: PortfolioContent["projects"]["selected"][number],
|
||||
): string[] =>
|
||||
project.visibility === 'public' && project.url ? [project.title] : [];
|
||||
project.visibility === "public" && project.url ? [project.title] : [];
|
||||
|
||||
return [
|
||||
...sectionIds.map((id) => navigationLabels[id]),
|
||||
content.identity.name,
|
||||
content.identity.role,
|
||||
content.identity.valueProposition,
|
||||
'Explore work',
|
||||
...section('profile', [
|
||||
"Explore work",
|
||||
...section("profile", [
|
||||
content.profile.introduction,
|
||||
...content.profile.screeningFacts.flatMap((fact) => [
|
||||
fact.label,
|
||||
fact.value,
|
||||
]),
|
||||
]),
|
||||
...section('experience', [
|
||||
...section("experience", [
|
||||
...content.experience.flatMap((record) => [
|
||||
record.role,
|
||||
`${record.organization} · ${record.dates}${record.location === undefined ? '' : ` · ${record.location}`}`,
|
||||
`${record.organization} · ${record.dates}${record.location === undefined ? "" : ` · ${record.location}`}`,
|
||||
...record.highlights,
|
||||
]),
|
||||
content.systemsOperations.title,
|
||||
content.systemsOperations.summary,
|
||||
...content.systemsOperations.evidence,
|
||||
]),
|
||||
...section('infrastructure', [
|
||||
...section("infrastructure", [
|
||||
content.infrastructure.title,
|
||||
content.infrastructure.summary,
|
||||
content.infrastructure.title,
|
||||
"Trust boundary",
|
||||
"Where trust or access rules change",
|
||||
"Request flow",
|
||||
"The direction traffic moves through the system",
|
||||
"Legend: Boundary labels mark changes in trust or access rules. Connectors show request flow direction.",
|
||||
...content.infrastructure.bands.map(
|
||||
(band) => `${band.title}: ${band.summary}`,
|
||||
),
|
||||
]),
|
||||
...section(
|
||||
'selected-builds',
|
||||
"selected-builds",
|
||||
content.projects.selected.flatMap((project) => [
|
||||
project.title,
|
||||
project.summary,
|
||||
'Problem',
|
||||
"Problem",
|
||||
project.problem,
|
||||
'Contribution',
|
||||
"Contribution",
|
||||
project.contribution,
|
||||
'Technical decisions',
|
||||
"Technical decisions",
|
||||
...project.technicalDecisions,
|
||||
'Outcome',
|
||||
"Outcome",
|
||||
project.outcome,
|
||||
...projectLinkText(project),
|
||||
]),
|
||||
'Selected work',
|
||||
"Selected work",
|
||||
),
|
||||
...section(
|
||||
'additional-work',
|
||||
"additional-work",
|
||||
content.projects.additional.flatMap((project) => [
|
||||
project.title,
|
||||
project.summary,
|
||||
@@ -163,29 +169,33 @@ const expectedVisibleTextSegments = (
|
||||
]),
|
||||
),
|
||||
...section(
|
||||
'toolkit',
|
||||
"toolkit",
|
||||
content.toolkit.flatMap((group) => [
|
||||
group.title,
|
||||
...group.technologies.map((technology) => technology.name),
|
||||
]),
|
||||
),
|
||||
...section('contact', [
|
||||
"Thanks for making the trip. The road's open if you'd like to build something together.",
|
||||
'Start a conversation',
|
||||
content.contact.email,
|
||||
content.contact.cvMessage,
|
||||
...content.contact.social.map((link) => link.service),
|
||||
], 'Destination'),
|
||||
...section(
|
||||
"contact",
|
||||
[
|
||||
"Thanks for making the trip. The road's open if you'd like to build something together.",
|
||||
"Start a conversation",
|
||||
content.contact.email,
|
||||
content.contact.cvMessage,
|
||||
...content.contact.social.map((link) => link.service),
|
||||
],
|
||||
"Destination",
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
const rawVisibleTextSegments = async (
|
||||
page: import('@playwright/test').Page,
|
||||
page: import("@playwright/test").Page,
|
||||
rawHtml: string,
|
||||
): Promise<string[]> =>
|
||||
page.evaluate(
|
||||
({ html, leafSelector, rootSelector }) => {
|
||||
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||
const document = new DOMParser().parseFromString(html, "text/html");
|
||||
const isHidden = (element: Element): boolean => {
|
||||
for (
|
||||
let current: Element | null = element;
|
||||
@@ -193,21 +203,21 @@ const rawVisibleTextSegments = async (
|
||||
current = current.parentElement
|
||||
) {
|
||||
if (
|
||||
current.matches('template,script,style,noscript') ||
|
||||
current.hasAttribute('hidden') ||
|
||||
current.hasAttribute('inert')
|
||||
current.matches("template,script,style,noscript") ||
|
||||
current.hasAttribute("hidden") ||
|
||||
current.hasAttribute("inert")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const ariaHidden = current.getAttribute('aria-hidden');
|
||||
const ariaHidden = current.getAttribute("aria-hidden");
|
||||
if (
|
||||
ariaHidden !== null &&
|
||||
ariaHidden.trim().toLowerCase() !== 'false'
|
||||
ariaHidden.trim().toLowerCase() !== "false"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const hiddenClass =
|
||||
current.getAttribute('class')?.toLowerCase() ?? '';
|
||||
current.getAttribute("class")?.toLowerCase() ?? "";
|
||||
if (
|
||||
/(?:^|\s|-)print-only(?:\s|-|$)|print-portfolio-label|visually-hidden|sr-only|screen-reader-only/u.test(
|
||||
hiddenClass,
|
||||
@@ -216,7 +226,7 @@ const rawVisibleTextSegments = async (
|
||||
return true;
|
||||
}
|
||||
const inlineStyle =
|
||||
current.getAttribute('style')?.toLowerCase() ?? '';
|
||||
current.getAttribute("style")?.toLowerCase() ?? "";
|
||||
if (
|
||||
/(?:^|;)\s*display\s*:\s*none\b/u.test(inlineStyle) ||
|
||||
/(?:^|;)\s*visibility\s*:\s*hidden\b/u.test(inlineStyle) ||
|
||||
@@ -233,7 +243,7 @@ const rawVisibleTextSegments = async (
|
||||
!isHidden(element) && element.querySelector(leafSelector) === null,
|
||||
)
|
||||
.map((element) =>
|
||||
(element.textContent ?? '').replace(/\s+/gu, ' ').trim(),
|
||||
(element.textContent ?? "").replace(/\s+/gu, " ").trim(),
|
||||
)
|
||||
.filter((text) => text.length > 0);
|
||||
},
|
||||
@@ -245,7 +255,7 @@ const rawVisibleTextSegments = async (
|
||||
);
|
||||
|
||||
const browserVisibleTextSegments = async (
|
||||
page: import('@playwright/test').Page,
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<string[]> =>
|
||||
page.locator(visibleTextSelector).evaluateAll(
|
||||
(elements, leafSelector) =>
|
||||
@@ -257,21 +267,21 @@ const browserVisibleTextSegments = async (
|
||||
current = current.parentElement
|
||||
) {
|
||||
if (
|
||||
current.matches('template,script,style,noscript') ||
|
||||
current.hasAttribute('hidden') ||
|
||||
current.hasAttribute('inert')
|
||||
current.matches("template,script,style,noscript") ||
|
||||
current.hasAttribute("hidden") ||
|
||||
current.hasAttribute("inert")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const ariaHidden = current.getAttribute('aria-hidden');
|
||||
const ariaHidden = current.getAttribute("aria-hidden");
|
||||
if (
|
||||
ariaHidden !== null &&
|
||||
ariaHidden.trim().toLowerCase() !== 'false'
|
||||
ariaHidden.trim().toLowerCase() !== "false"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const hiddenClass =
|
||||
current.getAttribute('class')?.toLowerCase() ?? '';
|
||||
current.getAttribute("class")?.toLowerCase() ?? "";
|
||||
if (
|
||||
/(?:^|\s|-)print-only(?:\s|-|$)|print-portfolio-label|visually-hidden|sr-only|screen-reader-only/u.test(
|
||||
hiddenClass,
|
||||
@@ -281,9 +291,9 @@ const browserVisibleTextSegments = async (
|
||||
}
|
||||
const style = getComputedStyle(current);
|
||||
if (
|
||||
style.display === 'none' ||
|
||||
style.visibility === 'hidden' ||
|
||||
style.opacity === '0'
|
||||
style.display === "none" ||
|
||||
style.visibility === "hidden" ||
|
||||
style.opacity === "0"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -294,13 +304,13 @@ const browserVisibleTextSegments = async (
|
||||
);
|
||||
})
|
||||
.map((element) =>
|
||||
(element.textContent ?? '').replace(/\s+/gu, ' ').trim(),
|
||||
(element.textContent ?? "").replace(/\s+/gu, " ").trim(),
|
||||
)
|
||||
.filter((text) => text.length > 0),
|
||||
visibleTextLeafSelector,
|
||||
);
|
||||
|
||||
test('keeps every raw SSR reading-path segment visible without JavaScript', async ({
|
||||
test("keeps every raw SSR reading-path segment visible without JavaScript", async ({
|
||||
page,
|
||||
distUrl,
|
||||
request,
|
||||
@@ -308,14 +318,14 @@ test('keeps every raw SSR reading-path segment visible without JavaScript', asyn
|
||||
const pageErrors: string[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
const failedRequests: string[] = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text());
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
page.on('requestfailed', (request) => {
|
||||
if (request.resourceType() !== 'script') {
|
||||
page.on("requestfailed", (request) => {
|
||||
if (request.resourceType() !== "script") {
|
||||
failedRequests.push(
|
||||
`${request.url()}: ${request.failure()?.errorText ?? 'failed'}`,
|
||||
`${request.url()}: ${request.failure()?.errorText ?? "failed"}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -331,7 +341,7 @@ test('keeps every raw SSR reading-path segment visible without JavaScript', asyn
|
||||
await rawVisibleTextSegments(page, rawHtml),
|
||||
);
|
||||
|
||||
await page.goto(distUrl, { waitUntil: 'load' });
|
||||
await page.goto(distUrl, { waitUntil: "load" });
|
||||
const actualVisibleText = textMultiset(
|
||||
await browserVisibleTextSegments(page),
|
||||
);
|
||||
@@ -340,37 +350,37 @@ test('keeps every raw SSR reading-path segment visible without JavaScript', asyn
|
||||
expect(rawVisibleText).toEqual(expectedVisibleText);
|
||||
expect(actualVisibleText).toEqual(expectedVisibleText);
|
||||
expect(normalizedText(JSON.stringify(actualVisibleText))).not.toContain(
|
||||
'preview-semantic-v1',
|
||||
"preview-semantic-v1",
|
||||
);
|
||||
|
||||
await expect(page.locator('h1:visible')).toHaveCount(1);
|
||||
await expect(page.locator('h1')).toHaveText('Luca Pîslaru');
|
||||
await expect(page.locator('.hero-journey__role')).toHaveText(
|
||||
'System Administrator & Full-Stack Developer',
|
||||
await expect(page.locator("h1:visible")).toHaveCount(1);
|
||||
await expect(page.locator("h1")).toHaveText("Luca Pîslaru");
|
||||
await expect(page.locator(".hero-journey__role")).toHaveText(
|
||||
"System Administrator & Full-Stack Developer",
|
||||
);
|
||||
await expect(page.locator('main')).toBeVisible();
|
||||
await expect(page.locator("main")).toBeVisible();
|
||||
await expect(
|
||||
page.locator('nav[aria-label="Portfolio sections"]'),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('footer')).toBeVisible();
|
||||
await expect(page.locator("footer")).toBeVisible();
|
||||
expect(
|
||||
await page
|
||||
.locator('main > section')
|
||||
.locator("main > section")
|
||||
.evaluateAll((sections) => sections.map((section) => section.id)),
|
||||
).toEqual(sectionIds);
|
||||
expect(await page.locator('main > section > h2').allTextContents()).toEqual(
|
||||
expect(await page.locator("main > section > h2").allTextContents()).toEqual(
|
||||
sectionHeadings,
|
||||
);
|
||||
|
||||
for (const evidence of [
|
||||
'Professional systems operations',
|
||||
'Secure self-hosted platform and lifecycle',
|
||||
'Private self-hosted medication tracker',
|
||||
'Footy Tracker',
|
||||
'Linux',
|
||||
'Docker',
|
||||
'Python',
|
||||
'Vue.js',
|
||||
"Professional systems operations",
|
||||
"Secure self-hosted platform and lifecycle",
|
||||
"Private self-hosted medication tracker",
|
||||
"Footy Tracker",
|
||||
"Linux",
|
||||
"Docker",
|
||||
"Python",
|
||||
"Vue.js",
|
||||
]) {
|
||||
await expect(
|
||||
page.getByText(evidence, { exact: true }).first(),
|
||||
@@ -379,30 +389,30 @@ test('keeps every raw SSR reading-path segment visible without JavaScript', asyn
|
||||
|
||||
const email = page.locator('a[href="mailto:luca.pislaru@gmail.com"]');
|
||||
await expect(email).toBeVisible();
|
||||
await expect(email).toHaveText('Start a conversation');
|
||||
await expect(email).toHaveText("Start a conversation");
|
||||
await expect(
|
||||
page.getByText(
|
||||
'For a current CV or role-specific information, contact me directly.',
|
||||
"For a current CV or role-specific information, contact me directly.",
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
for (const title of [
|
||||
'Private self-hosted medication tracker',
|
||||
'Private healthcare platform',
|
||||
'Private game prototype',
|
||||
"Private self-hosted medication tracker",
|
||||
"Private healthcare platform",
|
||||
"Private game prototype",
|
||||
]) {
|
||||
await expect(
|
||||
page.locator('article').filter({ hasText: title }).locator('a'),
|
||||
page.locator("article").filter({ hasText: title }).locator("a"),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
await expect(page.locator('a[download]')).toHaveCount(0);
|
||||
await expect(page.locator("a[download]")).toHaveCount(0);
|
||||
expect(pageErrors).toEqual([]);
|
||||
expect(consoleErrors).toEqual([]);
|
||||
expect(failedRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test('content oracle detects a removed highlight and an inert reading path', async ({
|
||||
test("content oracle detects a removed highlight and an inert reading path", async ({
|
||||
page,
|
||||
distUrl,
|
||||
request,
|
||||
@@ -417,31 +427,31 @@ test('content oracle detects a removed highlight and an inert reading path', asy
|
||||
|
||||
const withoutHighlight = await page.evaluate(
|
||||
({ html, highlight }) => {
|
||||
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||
const target = [...document.querySelectorAll('li')].find(
|
||||
const document = new DOMParser().parseFromString(html, "text/html");
|
||||
const target = [...document.querySelectorAll("li")].find(
|
||||
(element) =>
|
||||
(element.textContent ?? '').replace(/\s+/gu, ' ').trim() ===
|
||||
(element.textContent ?? "").replace(/\s+/gu, " ").trim() ===
|
||||
highlight,
|
||||
);
|
||||
if (target === undefined) throw new Error('Expected highlight is absent');
|
||||
if (target === undefined) throw new Error("Expected highlight is absent");
|
||||
target.remove();
|
||||
return `<!doctype html>${document.documentElement.outerHTML}`;
|
||||
},
|
||||
{ html: rawHtml, highlight: firstHighlight ?? '' },
|
||||
{ html: rawHtml, highlight: firstHighlight ?? "" },
|
||||
);
|
||||
const withoutHighlightText = textMultiset(
|
||||
await rawVisibleTextSegments(page, withoutHighlight),
|
||||
);
|
||||
expect(withoutHighlightText).not.toEqual(expected);
|
||||
const missingHighlight = missingText(expected, withoutHighlightText);
|
||||
expect(missingHighlight).toEqual([[normalizedText(firstHighlight ?? ''), 1]]);
|
||||
expect(missingHighlight).toEqual([[normalizedText(firstHighlight ?? ""), 1]]);
|
||||
expect(missingText(withoutHighlightText, expected)).toEqual([]);
|
||||
|
||||
const inertMain = await page.evaluate((html) => {
|
||||
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||
const main = document.querySelector('main');
|
||||
if (main === null) throw new Error('Expected main is absent');
|
||||
main.setAttribute('inert', '');
|
||||
const document = new DOMParser().parseFromString(html, "text/html");
|
||||
const main = document.querySelector("main");
|
||||
if (main === null) throw new Error("Expected main is absent");
|
||||
main.setAttribute("inert", "");
|
||||
return `<!doctype html>${document.documentElement.outerHTML}`;
|
||||
}, rawHtml);
|
||||
const inertMainText = textMultiset(
|
||||
@@ -457,33 +467,33 @@ test('content oracle detects a removed highlight and an inert reading path', asy
|
||||
expect(missingText(inertMainText, expected)).toEqual([]);
|
||||
});
|
||||
|
||||
test('activating the skip link focuses visible portfolio content without JavaScript', async ({
|
||||
test("activating the skip link focuses visible portfolio content without JavaScript", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: 'load' });
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator('.skip-link')).toBeFocused();
|
||||
await page.goto(distUrl, { waitUntil: "load" });
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.locator(".skip-link")).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(page).toHaveURL(`${distUrl}/#portfolio-content`);
|
||||
await expect(page.locator('#portfolio-content')).toBeFocused();
|
||||
await expect(page.locator('#portfolio-content')).toHaveAttribute(
|
||||
'tabindex',
|
||||
'-1',
|
||||
await expect(page.locator("#portfolio-content")).toBeFocused();
|
||||
await expect(page.locator("#portfolio-content")).toHaveAttribute(
|
||||
"tabindex",
|
||||
"-1",
|
||||
);
|
||||
await expectInsetFocusIndicator(page, '#portfolio-content');
|
||||
await expectInsetFocusIndicator(page, "#portfolio-content");
|
||||
});
|
||||
|
||||
test('retains native section navigation when scripts are disabled', async ({
|
||||
test("retains native section navigation when scripts are disabled", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: 'load' });
|
||||
await page.goto(distUrl, { waitUntil: "load" });
|
||||
await page.locator('nav a[href="#contact"]').click();
|
||||
|
||||
await expect(page).toHaveURL(`${distUrl}/#contact`);
|
||||
await expect(page.locator('#contact')).toBeInViewport();
|
||||
await expect(page.locator("#contact")).toBeInViewport();
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1,68 +1,111 @@
|
||||
import { expect, test } from '../helpers/serve-dist';
|
||||
import { expect, revealNavigationLinks, test } from "../helpers/serve-dist";
|
||||
import {
|
||||
environmentChunkPattern,
|
||||
installSceneInstrumentation,
|
||||
readSceneInstrumentation,
|
||||
} from '../helpers/scene-instrumentation';
|
||||
} from "../helpers/scene-instrumentation";
|
||||
|
||||
test.beforeEach(async ({ page }) => installSceneInstrumentation(page));
|
||||
|
||||
test('falls back to one poster and no scene loop when the deferred scene chunk is unavailable', async ({
|
||||
test("falls back to one poster and no scene loop when the deferred scene chunk is unavailable", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
const pageErrors: string[] = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
await page.route(environmentChunkPattern, (route) => route.abort('failed'));
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
await page.route(environmentChunkPattern, (route) => route.abort("failed"));
|
||||
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.locator('main > section')).toHaveCount(8);
|
||||
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.locator("main > section")).toHaveCount(8);
|
||||
await revealNavigationLinks(page);
|
||||
await expect(page.locator('nav a[href="#contact"]')).toBeVisible();
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
||||
await expect.poll(async () => (await readSceneInstrumentation(page)).activeAnimationFrames).toBe(0);
|
||||
expect((await readSceneInstrumentation(page)).unhandledRejections).toEqual([]);
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await readSceneInstrumentation(page)).activeAnimationFrames,
|
||||
)
|
||||
.toBe(0);
|
||||
expect((await readSceneInstrumentation(page)).unhandledRejections).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('contains a WebGL initialization failure without hiding the portfolio', async ({ page, distUrl }) => {
|
||||
test("contains a WebGL initialization failure without hiding the portfolio", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
const pageErrors: string[] = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
await page.addInitScript(() => {
|
||||
const nativeGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
Object.defineProperty(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;
|
||||
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 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.locator('#portfolio-content')).toBeVisible();
|
||||
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.locator("#portfolio-content")).toBeVisible();
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
||||
await expect.poll(async () => (await readSceneInstrumentation(page)).activeAnimationFrames).toBe(0);
|
||||
expect((await readSceneInstrumentation(page)).unhandledRejections).toEqual([]);
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await readSceneInstrumentation(page)).activeAnimationFrames,
|
||||
)
|
||||
.toBe(0);
|
||||
expect((await readSceneInstrumentation(page)).unhandledRejections).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('allows an explicit Static visitor to re-enable motion when the system permits it', async ({ page, distUrl }) => {
|
||||
test("allows an explicit Static visitor to re-enable motion when the system permits it", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
const requests: string[] = [];
|
||||
page.on('request', (request) => requests.push(request.url()));
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.getByLabel('Quality control').selectOption('static');
|
||||
page.on("request", (request) => requests.push(request.url()));
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "Drive settings" }).click();
|
||||
await page.getByLabel("Quality control").selectOption("static");
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Enable motion' })).toBeVisible();
|
||||
await expect(page.locator('.journey-canvas')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Enable motion' }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Enable motion" }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".journey-canvas")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Enable motion" }).click();
|
||||
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||
await expect(page.locator('.journey-canvas')).toHaveCount(1);
|
||||
await expect(page.locator(".journey-world")).toHaveAttribute(
|
||||
"data-scene-state",
|
||||
"running",
|
||||
);
|
||||
await expect(page.locator(".journey-canvas")).toHaveCount(1);
|
||||
expect(requests.some((url) => environmentChunkPattern.test(url))).toBe(true);
|
||||
});
|
||||
|
||||
@@ -116,7 +116,9 @@ const captureWorldSequence = async (
|
||||
for (let index = 0; index < frameCount; index += 1) {
|
||||
frames.push(
|
||||
await sampleFrame(
|
||||
await world.page().screenshot({ animations: "allow", clip, scale: "css" }),
|
||||
await world
|
||||
.page()
|
||||
.screenshot({ animations: "allow", clip, scale: "css" }),
|
||||
),
|
||||
);
|
||||
if (index + 1 < frameCount) await world.page().waitForTimeout(intervalMs);
|
||||
@@ -352,7 +354,9 @@ test.describe("production visual composition", () => {
|
||||
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.evaluate(() =>
|
||||
(document.activeElement as HTMLElement | null)?.blur(),
|
||||
);
|
||||
await page.mouse.move(1, 1);
|
||||
|
||||
const first = await captureViewport(page, testInfo, "visual-static-a");
|
||||
@@ -378,7 +382,9 @@ test.describe("production visual composition", () => {
|
||||
"reduced motion requested",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
|
||||
await page.evaluate(() =>
|
||||
(document.activeElement as HTMLElement | null)?.blur(),
|
||||
);
|
||||
await page.mouse.move(1, 1);
|
||||
await captureViewport(page, testInfo, "visual-reduced-motion");
|
||||
});
|
||||
@@ -482,7 +488,7 @@ test.describe("three-flash safety", () => {
|
||||
page,
|
||||
distUrl,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(30_000);
|
||||
test.setTimeout(60_000);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await waitForRenderedPortfolio(page, distUrl);
|
||||
await page.mouse.wheel(0, 900);
|
||||
@@ -513,7 +519,9 @@ test.describe("three-flash safety", () => {
|
||||
"static",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
|
||||
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),
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { readFile, realpath, stat } from 'node:fs/promises';
|
||||
import { createServer, type ServerResponse } from 'node:http';
|
||||
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
import { expect, test as base, type Page } from '@playwright/test';
|
||||
import { readFile, realpath, stat } from "node:fs/promises";
|
||||
import { createServer, type ServerResponse } from "node:http";
|
||||
import { extname, isAbsolute, relative, resolve, sep } from "node:path";
|
||||
import { expect, test as base, type Page } from "@playwright/test";
|
||||
|
||||
const LOOPBACK_HOST = '127.0.0.1';
|
||||
const LOOPBACK_HOST = "127.0.0.1";
|
||||
|
||||
const contentTypes: Readonly<Record<string, string>> = {
|
||||
'.avif': 'image/avif',
|
||||
'.br': 'application/octet-stream',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.gif': 'image/gif',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
".avif": "image/avif",
|
||||
".br": "application/octet-stream",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".gif": "image/gif",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".ico": "image/x-icon",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml; charset=utf-8",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".webp": "image/webp",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".xml": "application/xml; charset=utf-8",
|
||||
};
|
||||
|
||||
const sendText = (
|
||||
@@ -32,10 +32,10 @@ const sendText = (
|
||||
body: string,
|
||||
): void => {
|
||||
response.writeHead(status, {
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
response.end(body);
|
||||
};
|
||||
@@ -43,15 +43,15 @@ const sendText = (
|
||||
const isInside = (root: string, candidate: string): boolean => {
|
||||
const pathFromRoot = relative(root, candidate);
|
||||
return (
|
||||
pathFromRoot === '' ||
|
||||
pathFromRoot === "" ||
|
||||
(!isAbsolute(pathFromRoot) &&
|
||||
pathFromRoot !== '..' &&
|
||||
pathFromRoot !== ".." &&
|
||||
!pathFromRoot.startsWith(`..${sep}`))
|
||||
);
|
||||
};
|
||||
|
||||
const decodedRequestPath = (requestTarget: string): string | null => {
|
||||
const rawPath = requestTarget.split(/[?#]/u, 1)[0] ?? '/';
|
||||
const rawPath = requestTarget.split(/[?#]/u, 1)[0] ?? "/";
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(rawPath);
|
||||
@@ -60,19 +60,19 @@ const decodedRequestPath = (requestTarget: string): string | null => {
|
||||
}
|
||||
|
||||
if (
|
||||
!decoded.startsWith('/') ||
|
||||
decoded.includes('\0') ||
|
||||
decoded.includes('\\')
|
||||
!decoded.startsWith("/") ||
|
||||
decoded.includes("\0") ||
|
||||
decoded.includes("\\")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = decoded.split('/');
|
||||
if (segments.some((segment) => segment === '.' || segment === '..')) {
|
||||
const segments = decoded.split("/");
|
||||
if (segments.some((segment) => segment === "." || segment === "..")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decoded === '/' || decoded.endsWith('/')
|
||||
return decoded === "/" || decoded.endsWith("/")
|
||||
? `${decoded}index.html`
|
||||
: decoded;
|
||||
};
|
||||
@@ -116,8 +116,24 @@ export async function expectInsetFocusIndicator(
|
||||
});
|
||||
}
|
||||
|
||||
export async function revealNavigationLinks(page: Page): Promise<void> {
|
||||
const summary = page.locator(".site-navigation summary");
|
||||
if (!(await summary.isVisible())) return;
|
||||
await page.locator(".site-navigation details").evaluate((details) => {
|
||||
if (details instanceof HTMLDetailsElement) details.open = true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function clickNavigationLink(
|
||||
page: Page,
|
||||
href: string,
|
||||
): Promise<void> {
|
||||
await revealNavigationLinks(page);
|
||||
await page.locator(`nav a[href="${href}"]`).click();
|
||||
}
|
||||
|
||||
export async function startDistServer(
|
||||
distRoot = resolve(process.cwd(), 'dist'),
|
||||
distRoot = resolve(process.cwd(), "dist"),
|
||||
): Promise<DistPreviewServer> {
|
||||
const root = await realpath(distRoot);
|
||||
if (!(await stat(root)).isDirectory()) {
|
||||
@@ -126,21 +142,21 @@ export async function startDistServer(
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
void (async () => {
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
response.setHeader('Allow', 'GET, HEAD');
|
||||
sendText(response, 405, 'Method not allowed.');
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
response.setHeader("Allow", "GET, HEAD");
|
||||
sendText(response, 405, "Method not allowed.");
|
||||
return;
|
||||
}
|
||||
|
||||
const requestPath = decodedRequestPath(request.url ?? '/');
|
||||
const requestPath = decodedRequestPath(request.url ?? "/");
|
||||
if (requestPath === null) {
|
||||
sendText(response, 403, 'Forbidden.');
|
||||
sendText(response, 403, "Forbidden.");
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = resolve(root, `.${requestPath}`);
|
||||
if (!isInside(root, candidate)) {
|
||||
sendText(response, 403, 'Forbidden.');
|
||||
sendText(response, 403, "Forbidden.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -148,34 +164,34 @@ export async function startDistServer(
|
||||
try {
|
||||
filePath = await realpath(candidate);
|
||||
} catch {
|
||||
sendText(response, 404, 'Not found.');
|
||||
sendText(response, 404, "Not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInside(root, filePath)) {
|
||||
sendText(response, 403, 'Forbidden.');
|
||||
sendText(response, 403, "Forbidden.");
|
||||
return;
|
||||
}
|
||||
|
||||
const file = await stat(filePath);
|
||||
if (!file.isFile()) {
|
||||
sendText(response, 404, 'Not found.');
|
||||
sendText(response, 404, "Not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await readFile(filePath);
|
||||
response.writeHead(200, {
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Length': body.byteLength,
|
||||
'Content-Type':
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Length": body.byteLength,
|
||||
"Content-Type":
|
||||
contentTypes[extname(filePath).toLowerCase()] ??
|
||||
'application/octet-stream',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
"application/octet-stream",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
response.end(request.method === 'HEAD' ? undefined : body);
|
||||
response.end(request.method === "HEAD" ? undefined : body);
|
||||
})().catch(() => {
|
||||
if (!response.headersSent) {
|
||||
sendText(response, 500, 'Internal server error.');
|
||||
sendText(response, 500, "Internal server error.");
|
||||
} else {
|
||||
response.destroy();
|
||||
}
|
||||
@@ -184,9 +200,9 @@ export async function startDistServer(
|
||||
|
||||
await new Promise<void>((resolveListening, reject) => {
|
||||
const onError = (error: Error): void => reject(error);
|
||||
server.once('error', onError);
|
||||
server.once("error", onError);
|
||||
server.listen({ host: LOOPBACK_HOST, port: 0, exclusive: true }, () => {
|
||||
server.off('error', onError);
|
||||
server.off("error", onError);
|
||||
resolveListening();
|
||||
});
|
||||
});
|
||||
@@ -194,7 +210,7 @@ export async function startDistServer(
|
||||
const address = server.address();
|
||||
if (
|
||||
address === null ||
|
||||
typeof address === 'string' ||
|
||||
typeof address === "string" ||
|
||||
address.address !== LOOPBACK_HOST
|
||||
) {
|
||||
server.closeAllConnections();
|
||||
@@ -202,7 +218,7 @@ export async function startDistServer(
|
||||
server.close(() => resolveClosed()),
|
||||
);
|
||||
throw new Error(
|
||||
'The preview server did not bind the owned loopback listener.',
|
||||
"The preview server did not bind the owned loopback listener.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -233,7 +249,7 @@ export const test = base.extend<{}, { distUrl: string }>({
|
||||
await preview.close();
|
||||
}
|
||||
},
|
||||
{ scope: 'worker' },
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import SceneCanvas from '@/components/SceneCanvas.vue';
|
||||
import DriveControls from '@/components/DriveControls.vue';
|
||||
import JourneyHud from '@/components/JourneyHud.vue';
|
||||
import type { AudioUiState } from '@/audio/types';
|
||||
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
|
||||
import type { JourneySnapshot } from '@/journey/types';
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { nextTick } from "vue";
|
||||
import SceneCanvas from "@/components/SceneCanvas.vue";
|
||||
import DriveControls from "@/components/DriveControls.vue";
|
||||
import JourneyHud from "@/components/JourneyHud.vue";
|
||||
import type { AudioUiState } from "@/audio/types";
|
||||
import type { MotionPolicy, QualityTier } from "@/journey/motion-policy";
|
||||
import type { JourneySnapshot } from "@/journey/types";
|
||||
|
||||
const sceneMocks = vi.hoisted(() => ({
|
||||
build: vi.fn(async () => ({ render: vi.fn(), dispose: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock('@/scene/world/environment', () => ({
|
||||
vi.mock("@/scene/world/environment", () => ({
|
||||
createProceduralSceneGeneration: sceneMocks.build,
|
||||
}));
|
||||
|
||||
const journey = (overrides: Partial<JourneySnapshot> = {}): JourneySnapshot => ({
|
||||
const journey = (
|
||||
overrides: Partial<JourneySnapshot> = {},
|
||||
): JourneySnapshot => ({
|
||||
scrollY: 0,
|
||||
routeProgress: 0.42,
|
||||
direction: 1,
|
||||
acceleration: 0.3,
|
||||
driveSpeed: 0.6,
|
||||
activeSectionId: 'infrastructure',
|
||||
chapter: 'infrastructure',
|
||||
activeSectionId: "infrastructure",
|
||||
chapter: "infrastructure",
|
||||
sign: null,
|
||||
arrived: false,
|
||||
...overrides,
|
||||
@@ -32,12 +34,12 @@ const journey = (overrides: Partial<JourneySnapshot> = {}): JourneySnapshot => (
|
||||
const policy = (overrides: Partial<MotionPolicy> = {}): MotionPolicy => ({
|
||||
allowSceneRequest: true,
|
||||
allowAutomaticVideo: true,
|
||||
maximumTier: 'high',
|
||||
reason: 'full',
|
||||
maximumTier: "high",
|
||||
reason: "full",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('SceneCanvas', () => {
|
||||
describe("SceneCanvas", () => {
|
||||
beforeEach(() => sceneMocks.build.mockClear());
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -45,73 +47,111 @@ describe('SceneCanvas', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
['reduced motion', policy({ allowSceneRequest: false, maximumTier: 'static', reason: 'system-reduce' })],
|
||||
['saved Static mode', policy({ allowSceneRequest: false, maximumTier: 'static', reason: 'saved-static' })],
|
||||
])('does not request or render the scene for %s', async (_name, blockedPolicy) => {
|
||||
const wrapper = mount(SceneCanvas, {
|
||||
props: {
|
||||
journey: journey(),
|
||||
policy: blockedPolicy,
|
||||
quality: 'static',
|
||||
sceneDescription: 'A decorative night road behind the portfolio content.',
|
||||
},
|
||||
});
|
||||
[
|
||||
"reduced motion",
|
||||
policy({
|
||||
allowSceneRequest: false,
|
||||
maximumTier: "static",
|
||||
reason: "system-reduce",
|
||||
}),
|
||||
],
|
||||
[
|
||||
"saved Static mode",
|
||||
policy({
|
||||
allowSceneRequest: false,
|
||||
maximumTier: "static",
|
||||
reason: "saved-static",
|
||||
}),
|
||||
],
|
||||
])(
|
||||
"does not request or render the scene for %s",
|
||||
async (_name, blockedPolicy) => {
|
||||
const wrapper = mount(SceneCanvas, {
|
||||
props: {
|
||||
journey: journey(),
|
||||
policy: blockedPolicy,
|
||||
quality: "static",
|
||||
sceneDescription:
|
||||
"A decorative night road behind the portfolio content.",
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
expect(sceneMocks.build).not.toHaveBeenCalled();
|
||||
expect(wrapper.findAll('canvas')).toHaveLength(0);
|
||||
expect(wrapper.get('img').attributes()).toMatchObject({ alt: '', width: '1600', height: '900' });
|
||||
expect(wrapper.findAll('.journey-poster-composition > span')).toHaveLength(7);
|
||||
expect(wrapper.find('.journey-poster__sun').exists()).toBe(true);
|
||||
expect(wrapper.find('.journey-poster__road').exists()).toBe(true);
|
||||
expect(wrapper.find('.journey-poster__car').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('A decorative night road behind the portfolio content.');
|
||||
wrapper.unmount();
|
||||
});
|
||||
expect(sceneMocks.build).not.toHaveBeenCalled();
|
||||
expect(wrapper.findAll("canvas")).toHaveLength(0);
|
||||
expect(wrapper.get("img").attributes()).toMatchObject({
|
||||
alt: "",
|
||||
width: "1600",
|
||||
height: "900",
|
||||
});
|
||||
expect(
|
||||
wrapper.findAll(".journey-poster-composition > span"),
|
||||
).toHaveLength(7);
|
||||
expect(wrapper.find(".journey-poster__sun").exists()).toBe(true);
|
||||
expect(wrapper.find(".journey-poster__road").exists()).toBe(true);
|
||||
expect(wrapper.find(".journey-poster__car").exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain(
|
||||
"A decorative night road behind the portfolio content.",
|
||||
);
|
||||
wrapper.unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('owns exactly one decorative canvas when full motion is allowed', async () => {
|
||||
it("owns exactly one decorative canvas when full motion is allowed", async () => {
|
||||
const wrapper = mount(SceneCanvas, {
|
||||
props: {
|
||||
journey: journey(),
|
||||
policy: policy(),
|
||||
quality: 'high',
|
||||
sceneDescription: 'A decorative night road behind the portfolio content.',
|
||||
quality: "high",
|
||||
sceneDescription:
|
||||
"A decorative night road behind the portfolio content.",
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(sceneMocks.build).toHaveBeenCalledTimes(1);
|
||||
expect(wrapper.findAll('canvas')).toHaveLength(1);
|
||||
expect(wrapper.get('canvas').attributes('aria-hidden')).toBe('true');
|
||||
expect(wrapper.get('.journey-world').attributes('aria-hidden')).toBe('true');
|
||||
expect(wrapper.findAll("canvas")).toHaveLength(1);
|
||||
expect(wrapper.get("canvas").attributes("aria-hidden")).toBe("true");
|
||||
expect(wrapper.get(".journey-world").attributes("aria-hidden")).toBe(
|
||||
"true",
|
||||
);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('forwards each complete scene frame interval to adaptive quality', async () => {
|
||||
it("forwards each complete scene frame interval to adaptive quality", async () => {
|
||||
const callbacks = new Map<number, FrameRequestCallback>();
|
||||
let nextFrameId = 1;
|
||||
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
|
||||
const id = nextFrameId++;
|
||||
callbacks.set(id, callback);
|
||||
return id;
|
||||
}));
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn((id: number) => callbacks.delete(id)));
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
const id = nextFrameId++;
|
||||
callbacks.set(id, callback);
|
||||
return id;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal(
|
||||
"cancelAnimationFrame",
|
||||
vi.fn((id: number) => callbacks.delete(id)),
|
||||
);
|
||||
const sampleFrame = vi.fn();
|
||||
const wrapper = mount(SceneCanvas, {
|
||||
props: {
|
||||
journey: journey(),
|
||||
policy: policy(),
|
||||
quality: 'high',
|
||||
sceneDescription: 'A decorative night road behind the portfolio content.',
|
||||
quality: "high",
|
||||
sceneDescription:
|
||||
"A decorative night road behind the portfolio content.",
|
||||
sampleFrame,
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
const frame = [...callbacks.entries()][0]!;
|
||||
callbacks.delete(frame[0]);
|
||||
vi.spyOn(performance, 'now').mockReturnValueOnce(2_000).mockReturnValueOnce(2_301);
|
||||
vi.spyOn(performance, "now")
|
||||
.mockReturnValueOnce(2_000)
|
||||
.mockReturnValueOnce(2_301);
|
||||
|
||||
frame[1](2_000);
|
||||
|
||||
@@ -119,162 +159,232 @@ describe('SceneCanvas', () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('falls back to the poster without obscuring semantic content after scene failure', async () => {
|
||||
sceneMocks.build.mockRejectedValueOnce(new Error('WebGL unavailable'));
|
||||
it("falls back to the poster without obscuring semantic content after scene failure", async () => {
|
||||
sceneMocks.build.mockRejectedValueOnce(new Error("WebGL unavailable"));
|
||||
const wrapper = mount(SceneCanvas, {
|
||||
props: {
|
||||
journey: journey(),
|
||||
policy: policy(),
|
||||
quality: 'medium',
|
||||
sceneDescription: 'A decorative night road behind the portfolio content.',
|
||||
quality: "medium",
|
||||
sceneDescription:
|
||||
"A decorative night road behind the portfolio content.",
|
||||
},
|
||||
slots: { default: '<main><h1>Semantic portfolio</h1></main>' },
|
||||
slots: { default: "<main><h1>Semantic portfolio</h1></main>" },
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('img').isVisible()).toBe(true);
|
||||
expect(wrapper.findAll('canvas')).toHaveLength(0);
|
||||
expect(wrapper.text()).toContain('Semantic portfolio');
|
||||
expect(wrapper.get("img").isVisible()).toBe(true);
|
||||
expect(wrapper.findAll("canvas")).toHaveLength(0);
|
||||
expect(wrapper.text()).toContain("Semantic portfolio");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('aborts and removes the canvas on a Static transition', async () => {
|
||||
it("aborts and removes the canvas on a Static transition", async () => {
|
||||
const wrapper = mount(SceneCanvas, {
|
||||
props: {
|
||||
journey: journey(),
|
||||
policy: policy(),
|
||||
quality: 'high',
|
||||
sceneDescription: 'A decorative night road behind the portfolio content.',
|
||||
quality: "high",
|
||||
sceneDescription:
|
||||
"A decorative night road behind the portfolio content.",
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
expect(wrapper.findAll('canvas')).toHaveLength(1);
|
||||
expect(wrapper.findAll("canvas")).toHaveLength(1);
|
||||
|
||||
await wrapper.setProps({
|
||||
policy: policy({ allowSceneRequest: false, maximumTier: 'static', reason: 'saved-static' }),
|
||||
quality: 'static',
|
||||
policy: policy({
|
||||
allowSceneRequest: false,
|
||||
maximumTier: "static",
|
||||
reason: "saved-static",
|
||||
}),
|
||||
quality: "static",
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.findAll('canvas')).toHaveLength(0);
|
||||
expect(wrapper.get('img').isVisible()).toBe(true);
|
||||
expect(wrapper.findAll("canvas")).toHaveLength(0);
|
||||
expect(wrapper.get("img").isVisible()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('retains the same canvas and scene generation for a visitor pause and resume', async () => {
|
||||
it("retains the same canvas and scene generation for a visitor pause and resume", async () => {
|
||||
const wrapper = mount(SceneCanvas, {
|
||||
props: {
|
||||
journey: journey(),
|
||||
policy: policy(),
|
||||
quality: 'high',
|
||||
sceneDescription: 'A decorative night road behind the portfolio content.',
|
||||
quality: "high",
|
||||
sceneDescription:
|
||||
"A decorative night road behind the portfolio content.",
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
const canvas = wrapper.get('canvas').element;
|
||||
const canvas = wrapper.get("canvas").element;
|
||||
const runtime = await sceneMocks.build.mock.results[0]!.value;
|
||||
|
||||
await wrapper.setProps({
|
||||
policy: policy({ allowSceneRequest: false, maximumTier: 'static', reason: 'visitor-pause' }),
|
||||
quality: 'high',
|
||||
policy: policy({
|
||||
allowSceneRequest: false,
|
||||
maximumTier: "static",
|
||||
reason: "visitor-pause",
|
||||
}),
|
||||
quality: "high",
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('canvas').element).toBe(canvas);
|
||||
expect(wrapper.get("canvas").element).toBe(canvas);
|
||||
expect(runtime.dispose).not.toHaveBeenCalled();
|
||||
expect(sceneMocks.build).toHaveBeenCalledTimes(1);
|
||||
|
||||
await wrapper.setProps({ policy: policy() });
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('canvas').element).toBe(canvas);
|
||||
expect(wrapper.get("canvas").element).toBe(canvas);
|
||||
expect(runtime.dispose).not.toHaveBeenCalled();
|
||||
expect(sceneMocks.build).toHaveBeenCalledTimes(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DriveControls', () => {
|
||||
describe("DriveControls", () => {
|
||||
const mountControls = (
|
||||
motionPolicy: MotionPolicy,
|
||||
maximum: QualityTier = 'high',
|
||||
audioState: AudioUiState = { kind: 'idle' },
|
||||
) => mount(DriveControls, {
|
||||
attachTo: document.body,
|
||||
props: { policy: motionPolicy, maximumQuality: maximum, sceneStatus: 'running', audioState },
|
||||
});
|
||||
maximum: QualityTier = "high",
|
||||
audioState: AudioUiState = { kind: "idle" },
|
||||
) =>
|
||||
mount(DriveControls, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
policy: motionPolicy,
|
||||
maximumQuality: maximum,
|
||||
sceneStatus: "running",
|
||||
audioState,
|
||||
},
|
||||
});
|
||||
|
||||
it('presents one explicit drive action before audio activation', async () => {
|
||||
it("presents one explicit drive action before audio activation", async () => {
|
||||
const wrapper = mountControls(policy());
|
||||
|
||||
expect(wrapper.get('[data-drive-state="idle"]').text()).toContain('START THE DRIVE');
|
||||
expect(wrapper.get('[data-drive-state="idle"]').text()).toContain(
|
||||
"START THE DRIVE",
|
||||
);
|
||||
expect(wrapper.find('[aria-label="Mute sound"]').exists()).toBe(false);
|
||||
await wrapper.get('[aria-label="Start the drive"]').trigger('click');
|
||||
expect(wrapper.emitted('audioStart')).toHaveLength(1);
|
||||
await wrapper.get('[aria-label="Start the drive"]').trigger("click");
|
||||
expect(wrapper.emitted("audioStart")).toHaveLength(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('collapses the running state to named vehicle controls and an upward settings panel', async () => {
|
||||
const wrapper = mountControls(policy(), 'high', { kind: 'running', muted: false, volume: 0.4 });
|
||||
it("collapses the running state to named vehicle controls and an upward settings panel", async () => {
|
||||
const wrapper = mountControls(policy(), "high", {
|
||||
kind: "running",
|
||||
muted: false,
|
||||
volume: 0.4,
|
||||
});
|
||||
|
||||
expect(wrapper.get('[aria-label="Mute sound"]')).toBeTruthy();
|
||||
expect(wrapper.get('[aria-label="Sound volume"]')).toBeTruthy();
|
||||
expect(wrapper.get('[aria-label="Pause motion"]')).toBeTruthy();
|
||||
const settings = wrapper.get('[aria-label="Drive settings"]');
|
||||
await settings.trigger('click');
|
||||
await settings.trigger("click");
|
||||
|
||||
const panel = wrapper.get('[role="dialog"][aria-label="Drive settings"]');
|
||||
expect(panel.text()).not.toContain('Motion: active');
|
||||
expect(panel.text()).not.toContain("Motion: active");
|
||||
expect(panel.find('[role="status"]').exists()).toBe(false);
|
||||
expect(panel.get('select').attributes('aria-label')).toBe('Quality control');
|
||||
expect(settings.attributes('aria-expanded')).toBe('true');
|
||||
expect(panel.get("select").attributes("aria-label")).toBe(
|
||||
"Quality control",
|
||||
);
|
||||
expect(settings.attributes("aria-expanded")).toBe("true");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('dismisses settings with Escape and outside interaction', async () => {
|
||||
const wrapper = mountControls(policy(), 'high', { kind: 'running', muted: false, volume: 0.4 });
|
||||
it("dismisses settings with Escape and outside interaction", async () => {
|
||||
const wrapper = mountControls(policy(), "high", {
|
||||
kind: "running",
|
||||
muted: false,
|
||||
volume: 0.4,
|
||||
});
|
||||
const settings = wrapper.get('[aria-label="Drive settings"]');
|
||||
await settings.trigger('click');
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
await settings.trigger("click");
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
||||
);
|
||||
await nextTick();
|
||||
expect(wrapper.find('[role="dialog"][aria-label="Drive settings"]').exists()).toBe(false);
|
||||
expect(
|
||||
wrapper.find('[role="dialog"][aria-label="Drive settings"]').exists(),
|
||||
).toBe(false);
|
||||
expect(document.activeElement).toBe(settings.element);
|
||||
|
||||
await settings.trigger('click');
|
||||
document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
|
||||
await settings.trigger("click");
|
||||
document.body.dispatchEvent(
|
||||
new MouseEvent("pointerdown", { bubbles: true }),
|
||||
);
|
||||
await nextTick();
|
||||
expect(wrapper.find('[role="dialog"][aria-label="Drive settings"]').exists()).toBe(false);
|
||||
expect(
|
||||
wrapper.find('[role="dialog"][aria-label="Drive settings"]').exists(),
|
||||
).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['visitor-pause', 'Resume motion'],
|
||||
['saved-static', 'Enable motion'],
|
||||
] as const)('offers the correct recovery action for %s', (reason, label) => {
|
||||
["visitor-pause", "Resume motion"],
|
||||
["saved-static", "Enable motion"],
|
||||
] as const)("offers the correct recovery action for %s", (reason, label) => {
|
||||
const wrapper = mountControls(
|
||||
policy({ allowSceneRequest: false, maximumTier: 'static', reason }),
|
||||
'static',
|
||||
{ kind: 'running', muted: false, volume: 0.4 },
|
||||
policy({ allowSceneRequest: false, maximumTier: "static", reason }),
|
||||
"static",
|
||||
{ kind: "running", muted: false, volume: 0.4 },
|
||||
);
|
||||
expect(wrapper.get(`[aria-label="${label}"]`)).toBeTruthy();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyHud', () => {
|
||||
it('renders only route percent, current section, and arrival from one snapshot', () => {
|
||||
const wrapper = mount(JourneyHud, {
|
||||
it.each([
|
||||
["visitor-pause", "Resume motion"],
|
||||
["saved-static", "Enable motion"],
|
||||
] as const)(
|
||||
"keeps the %s recovery action available before audio starts",
|
||||
(reason, label) => {
|
||||
const wrapper = mountControls(
|
||||
policy({ allowSceneRequest: false, maximumTier: "static", reason }),
|
||||
"static",
|
||||
);
|
||||
expect(wrapper.get(`[aria-label="${label}"]`)).toBeTruthy();
|
||||
wrapper.unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it("announces a nondefault scene state without requiring the settings panel", () => {
|
||||
const wrapper = mount(DriveControls, {
|
||||
props: {
|
||||
journey: journey({ routeProgress: 1, activeSectionId: 'contact', arrived: true }),
|
||||
sectionLabel: 'Contact',
|
||||
policy: policy(),
|
||||
maximumQuality: "high",
|
||||
sceneStatus: "failed",
|
||||
audioState: { kind: "idle" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('100%');
|
||||
expect(wrapper.text()).toContain('Contact');
|
||||
expect(wrapper.text()).toContain('ARRIVED');
|
||||
expect(wrapper.text()).not.toContain('Low');
|
||||
expect(wrapper.attributes('aria-hidden')).toBe('true');
|
||||
expect(wrapper.find('[role="dialog"]').exists()).toBe(false);
|
||||
expect(wrapper.get('[role="status"]').text()).toBe("Visuals unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("JourneyHud", () => {
|
||||
it("renders only route percent, current section, and arrival from one snapshot", () => {
|
||||
const wrapper = mount(JourneyHud, {
|
||||
props: {
|
||||
journey: journey({
|
||||
routeProgress: 1,
|
||||
activeSectionId: "contact",
|
||||
arrived: true,
|
||||
}),
|
||||
sectionLabel: "Contact",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("100%");
|
||||
expect(wrapper.text()).toContain("Contact");
|
||||
expect(wrapper.text()).toContain("ARRIVED");
|
||||
expect(wrapper.text()).not.toContain("Low");
|
||||
expect(wrapper.attributes("aria-hidden")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,105 +1,168 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import ContactDestination from '@/components/ContactDestination.vue';
|
||||
import InfrastructureDiagram from '@/components/InfrastructureDiagram.vue';
|
||||
import ProjectCaseStudy from '@/components/ProjectCaseStudy.vue';
|
||||
import ToolkitGrid from '@/components/ToolkitGrid.vue';
|
||||
import type { ContactContent, InfrastructureCaseStudy, PublicProject, ToolkitGroup } from '@/content/types';
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ContactDestination from "@/components/ContactDestination.vue";
|
||||
import InfrastructureDiagram from "@/components/InfrastructureDiagram.vue";
|
||||
import ProjectCaseStudy from "@/components/ProjectCaseStudy.vue";
|
||||
import ToolkitGrid from "@/components/ToolkitGrid.vue";
|
||||
import type {
|
||||
ContactContent,
|
||||
InfrastructureCaseStudy,
|
||||
PublicProject,
|
||||
ToolkitGroup,
|
||||
} from "@/content/types";
|
||||
|
||||
const infrastructure: InfrastructureCaseStudy = {
|
||||
id: 'safe-infrastructure',
|
||||
title: 'Secure self-hosted platform and lifecycle',
|
||||
summary: 'A compact infrastructure case study.',
|
||||
id: "safe-infrastructure",
|
||||
title: "Secure self-hosted platform and lifecycle",
|
||||
summary: "A compact infrastructure case study.",
|
||||
bands: [
|
||||
{ id: 'ingress-tls', title: 'Ingress and TLS', summary: 'Managed encrypted ingress.' },
|
||||
{ id: 'identity-access', title: 'Identity and access', summary: 'Protected service access.' },
|
||||
{ id: 'service-lifecycle', title: 'Service lifecycle', summary: 'Managed container lifecycle.' },
|
||||
{ id: 'operations', title: 'Operations', summary: 'Routine maintenance and investigation.' },
|
||||
{
|
||||
id: "ingress-tls",
|
||||
title: "Ingress and TLS",
|
||||
summary: "Managed encrypted ingress.",
|
||||
},
|
||||
{
|
||||
id: "identity-access",
|
||||
title: "Identity and access",
|
||||
summary: "Protected service access.",
|
||||
},
|
||||
{
|
||||
id: "service-lifecycle",
|
||||
title: "Service lifecycle",
|
||||
summary: "Managed container lifecycle.",
|
||||
},
|
||||
{
|
||||
id: "operations",
|
||||
title: "Operations",
|
||||
summary: "Routine maintenance and investigation.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const privateProject: PublicProject = {
|
||||
id: 'private-work',
|
||||
title: 'Private self-hosted medication tracker',
|
||||
visibility: 'private',
|
||||
maturity: 'active',
|
||||
summary: 'A private application.',
|
||||
problem: 'A recurring workflow needs reliable tracking.',
|
||||
contribution: 'Designed and maintained the application.',
|
||||
technicalDecisions: ['Used a synchronised client and server design.'],
|
||||
outcome: 'The work remains private.',
|
||||
id: "private-work",
|
||||
title: "Private self-hosted medication tracker",
|
||||
visibility: "private",
|
||||
maturity: "active",
|
||||
summary: "A private application.",
|
||||
problem: "A recurring workflow needs reliable tracking.",
|
||||
contribution: "Designed and maintained the application.",
|
||||
technicalDecisions: ["Used a synchronised client and server design."],
|
||||
outcome: "The work remains private.",
|
||||
assetIds: [],
|
||||
};
|
||||
|
||||
const publicProject: PublicProject = {
|
||||
...privateProject,
|
||||
id: 'public-work',
|
||||
title: 'Footy Tracker',
|
||||
visibility: 'public',
|
||||
url: 'https://github.com/example/footy-tracker',
|
||||
id: "public-work",
|
||||
title: "Footy Tracker",
|
||||
visibility: "public",
|
||||
url: "https://github.com/example/footy-tracker",
|
||||
};
|
||||
|
||||
describe('semantic section components', () => {
|
||||
it('gives the infrastructure graphic an accessible name, description, caption, and visible text equivalent', () => {
|
||||
describe("semantic section components", () => {
|
||||
it("gives the infrastructure graphic an accessible name, description, caption, and visible text equivalent", () => {
|
||||
const wrapper = mount(InfrastructureDiagram, { props: { infrastructure } });
|
||||
const flow = wrapper.get('[data-testid="infrastructure-flow"]');
|
||||
|
||||
expect(flow.attributes('role')).toBe('img');
|
||||
expect(flow.attributes('aria-labelledby')).toBe('safe-infrastructure-diagram-title');
|
||||
expect(flow.attributes('aria-describedby')).toBe('safe-infrastructure-diagram-description');
|
||||
expect(wrapper.get('figcaption').text()).toContain('Secure self-hosted platform and lifecycle');
|
||||
expect(wrapper.get('[data-testid="infrastructure-text-equivalent"]').text()).toContain('Ingress and TLS');
|
||||
expect(wrapper.get('[data-testid="infrastructure-text-equivalent"]').text()).toContain('Identity and access');
|
||||
expect(flow.attributes("role")).toBe("img");
|
||||
expect(flow.attributes("aria-labelledby")).toBe(
|
||||
"safe-infrastructure-diagram-title",
|
||||
);
|
||||
expect(flow.attributes("aria-describedby")).toBe(
|
||||
"safe-infrastructure-diagram-description",
|
||||
);
|
||||
expect(wrapper.get("figcaption").text()).toContain(
|
||||
"Secure self-hosted platform and lifecycle",
|
||||
);
|
||||
expect(
|
||||
wrapper.get('[data-testid="infrastructure-text-equivalent"]').text(),
|
||||
).toContain("Ingress and TLS");
|
||||
expect(
|
||||
wrapper.get('[data-testid="infrastructure-text-equivalent"]').text(),
|
||||
).toContain("Identity and access");
|
||||
});
|
||||
|
||||
it('renders the real infrastructure route with explicit trust boundaries and operational support', () => {
|
||||
it("renders the real infrastructure route with explicit trust boundaries and operational support", () => {
|
||||
const wrapper = mount(InfrastructureDiagram, { props: { infrastructure } });
|
||||
const flow = wrapper.get('[data-testid="infrastructure-flow"]');
|
||||
|
||||
expect(flow.findAll('[data-infrastructure-node]').map((node) => node.attributes('data-infrastructure-node'))).toEqual([
|
||||
'visitor', 'nginx-tls', 'authentication', 'containerized-services', 'operations',
|
||||
expect(
|
||||
flow
|
||||
.findAll("[data-infrastructure-node]")
|
||||
.map((node) => node.attributes("data-infrastructure-node")),
|
||||
).toEqual([
|
||||
"visitor",
|
||||
"nginx-tls",
|
||||
"authentication",
|
||||
"containerized-services",
|
||||
"operations",
|
||||
]);
|
||||
expect(flow.findAll('[data-trust-boundary]').map((boundary) => boundary.text())).toEqual([
|
||||
'Public edge', 'Access boundary', 'Service boundary',
|
||||
]);
|
||||
expect(flow.text()).toContain('fail2ban guard');
|
||||
expect(
|
||||
flow.findAll("[data-trust-boundary]").map((boundary) => boundary.text()),
|
||||
).toEqual(["Public edge", "Access boundary", "Service boundary"]);
|
||||
expect(
|
||||
wrapper.get('[aria-label="Infrastructure diagram legend"]').text(),
|
||||
).toContain("Trust boundary");
|
||||
expect(
|
||||
wrapper.get('[aria-label="Infrastructure diagram legend"]').text(),
|
||||
).toContain("Request flow");
|
||||
expect(
|
||||
wrapper.get('[data-testid="infrastructure-text-equivalent"]').text(),
|
||||
).toContain("Boundary labels mark changes in trust or access rules");
|
||||
expect(flow.text()).toContain("fail2ban guard");
|
||||
});
|
||||
|
||||
it('does not turn private project content into a link container', () => {
|
||||
const wrapper = mount(ProjectCaseStudy, { props: { project: privateProject, priority: 'primary' } });
|
||||
it("does not turn private project content into a link container", () => {
|
||||
const wrapper = mount(ProjectCaseStudy, {
|
||||
props: { project: privateProject, priority: "primary" },
|
||||
});
|
||||
|
||||
expect(wrapper.get('article').findAll('a')).toHaveLength(0);
|
||||
expect(wrapper.get('h3').text()).toBe('Private self-hosted medication tracker');
|
||||
expect(wrapper.get('article').attributes('data-project-priority')).toBe('primary');
|
||||
expect(wrapper.get("article").findAll("a")).toHaveLength(0);
|
||||
expect(wrapper.get("h3").text()).toBe(
|
||||
"Private self-hosted medication tracker",
|
||||
);
|
||||
expect(wrapper.get("article").attributes("data-project-priority")).toBe(
|
||||
"primary",
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a safe external link only for a public project with an approved URL', () => {
|
||||
const wrapper = mount(ProjectCaseStudy, { props: { project: publicProject, priority: 'supporting' } });
|
||||
const link = wrapper.get('article a');
|
||||
it("uses a safe external link only for a public project with an approved URL", () => {
|
||||
const wrapper = mount(ProjectCaseStudy, {
|
||||
props: { project: publicProject, priority: "supporting" },
|
||||
});
|
||||
const link = wrapper.get("article a");
|
||||
|
||||
expect(link.attributes('href')).toBe('https://github.com/example/footy-tracker');
|
||||
expect(link.attributes('target')).toBe('_blank');
|
||||
expect(link.attributes('rel')).toBe('noopener noreferrer');
|
||||
expect(link.attributes("href")).toBe(
|
||||
"https://github.com/example/footy-tracker",
|
||||
);
|
||||
expect(link.attributes("target")).toBe("_blank");
|
||||
expect(link.attributes("rel")).toBe("noopener noreferrer");
|
||||
});
|
||||
|
||||
it('announces every toolkit technology once while hiding the decorative icon placeholder', () => {
|
||||
const groups: readonly ToolkitGroup[] = [{
|
||||
title: 'Systems & delivery',
|
||||
technologies: [{ name: 'Linux' }],
|
||||
}];
|
||||
it("announces every toolkit technology once while hiding the decorative icon placeholder", () => {
|
||||
const groups: readonly ToolkitGroup[] = [
|
||||
{
|
||||
title: "Systems & delivery",
|
||||
technologies: [{ name: "Linux" }],
|
||||
},
|
||||
];
|
||||
const wrapper = mount(ToolkitGrid, { props: { groups } });
|
||||
const tile = wrapper.get('[data-testid="toolkit-technology"]');
|
||||
|
||||
expect(tile.text()).toBe('Linux');
|
||||
expect(tile.get('[aria-hidden="true"]').attributes('aria-hidden')).toBe('true');
|
||||
expect(tile.text()).toBe("Linux");
|
||||
expect(tile.get('[aria-hidden="true"]').attributes("aria-hidden")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(tile.findAll('[aria-label="Linux"]').length).toBe(0);
|
||||
});
|
||||
|
||||
it('renders a visible mailto contact and safe verified social links', () => {
|
||||
it("renders a visible mailto contact and safe verified social links", () => {
|
||||
const contact: ContactContent = {
|
||||
email: 'hello@example.test',
|
||||
cvMessage: 'For a current CV or role-specific information, contact me directly.',
|
||||
social: [{ service: 'GitHub', url: 'https://github.com/example' }],
|
||||
email: "hello@example.test",
|
||||
cvMessage:
|
||||
"For a current CV or role-specific information, contact me directly.",
|
||||
social: [{ service: "GitHub", url: "https://github.com/example" }],
|
||||
};
|
||||
const wrapper = mount(ContactDestination, {
|
||||
props: {
|
||||
@@ -107,10 +170,14 @@ describe('semantic section components', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.get('a[href="mailto:hello@example.test"]').text()).toBe('Start a conversation');
|
||||
expect(wrapper.text()).toContain("Thanks for making the trip. The road's open if you'd like to build something together.");
|
||||
expect(wrapper.get('a[href="mailto:hello@example.test"]').text()).toBe(
|
||||
"Start a conversation",
|
||||
);
|
||||
expect(wrapper.text()).toContain(
|
||||
"Thanks for making the trip. The road's open if you'd like to build something together.",
|
||||
);
|
||||
const social = wrapper.get('a[href="https://github.com/example"]');
|
||||
expect(social.text()).toBe('GitHub');
|
||||
expect(social.attributes('rel')).toBe('noopener noreferrer');
|
||||
expect(social.text()).toBe("GitHub");
|
||||
expect(social.attributes("rel")).toBe("noopener noreferrer");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,6 +152,25 @@ describe("canonical fragment validation", () => {
|
||||
anchor.href = "#unknown";
|
||||
expect(validateSectionAnchor(anchor, sectionIds)).toBeNull();
|
||||
});
|
||||
|
||||
it("validates the nested hero H1 as the Start navigation target", () => {
|
||||
const section = document.createElement("section");
|
||||
section.id = "start";
|
||||
const content = document.createElement("div");
|
||||
const heading = document.createElement("h1");
|
||||
heading.id = "start-heading";
|
||||
heading.tabIndex = -1;
|
||||
content.append(heading);
|
||||
section.append(content);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = "#start";
|
||||
document.body.append(section, anchor);
|
||||
|
||||
expect(validateSectionAnchor(anchor, sectionIds)).toEqual({
|
||||
id: "start",
|
||||
target: section,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("navigation epochs and effects", () => {
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as THREE from 'three';
|
||||
import type { EnvironmentChapter } from '@/journey/types';
|
||||
import { renderQualityFor } from '@/scene/render-quality';
|
||||
import { SceneResourceBag } from '@/scene/resources';
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as THREE from "three";
|
||||
import type { EnvironmentChapter } from "@/journey/types";
|
||||
import { renderQualityFor } from "@/scene/render-quality";
|
||||
import { SceneResourceBag } from "@/scene/resources";
|
||||
import {
|
||||
CHAPTER_PALETTES,
|
||||
createMutableChapterPalette,
|
||||
writeChapterPalette,
|
||||
} from '@/scene/world/palette';
|
||||
import { createRoad } from '@/scene/world/road';
|
||||
import { createTerrain } from '@/scene/world/terrain';
|
||||
import { createSun } from '@/scene/world/sun';
|
||||
import { createAtmosphere } from '@/scene/world/atmosphere';
|
||||
import { createPostProcessor } from '@/scene/post-processing';
|
||||
import { createCar } from '@/scene/world/car';
|
||||
import { createChaseCamera } from '@/scene/world/environment';
|
||||
import { createSign, routeLabelFor } from '@/scene/world/sign';
|
||||
} from "@/scene/world/palette";
|
||||
import { createRoad } from "@/scene/world/road";
|
||||
import { createTerrain } from "@/scene/world/terrain";
|
||||
import { createSun } from "@/scene/world/sun";
|
||||
import { createAtmosphere } from "@/scene/world/atmosphere";
|
||||
import { createPostProcessor } from "@/scene/post-processing";
|
||||
import { createCar } from "@/scene/world/car";
|
||||
import { createChaseCamera } from "@/scene/world/environment";
|
||||
import { createSign, routeLabelFor } from "@/scene/world/sign";
|
||||
|
||||
describe('procedural world models', () => {
|
||||
it('scales geometry and atmosphere work monotonically by live quality tier', () => {
|
||||
const high = renderQualityFor('high', 3, false);
|
||||
const medium = renderQualityFor('medium', 3, false);
|
||||
const low = renderQualityFor('low', 3, false);
|
||||
describe("procedural world models", () => {
|
||||
it("scales geometry and atmosphere work monotonically by live quality tier", () => {
|
||||
const high = renderQualityFor("high", 3, false);
|
||||
const medium = renderQualityFor("medium", 3, false);
|
||||
const low = renderQualityFor("low", 3, false);
|
||||
|
||||
expect(high.roadSegments).toBeGreaterThan(medium.roadSegments);
|
||||
expect(medium.roadSegments).toBeGreaterThan(low.roadSegments);
|
||||
@@ -31,50 +31,60 @@ describe('procedural world models', () => {
|
||||
expect(low.postEffects).toBe(false);
|
||||
});
|
||||
|
||||
it('builds a shader road whose motion is entirely uniform-driven', () => {
|
||||
it("builds a shader road whose motion is entirely uniform-driven", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const road = createRoad(resources, renderQualityFor('high', 1, false));
|
||||
const surface = road.group.getObjectByName('road-surface') as ShaderRenderable;
|
||||
const road = createRoad(resources, renderQualityFor("high", 1, false));
|
||||
const surface = road.group.getObjectByName(
|
||||
"road-surface",
|
||||
) as ShaderRenderable;
|
||||
|
||||
expect(surface).toBeInstanceOf(THREE.Mesh);
|
||||
expect(surface.geometry.index).not.toBeNull();
|
||||
expect(surface.geometry.getAttribute('position').count).toBeGreaterThan(1_000);
|
||||
expect(surface.geometry.getAttribute("position").count).toBeGreaterThan(
|
||||
1_000,
|
||||
);
|
||||
expect(surface.material.uniforms).toMatchObject({
|
||||
uDistance: { value: 0 },
|
||||
uSpeed: { value: 0 },
|
||||
uAcceleration: { value: 0 },
|
||||
});
|
||||
expect(surface.material.fragmentShader).toContain('centerLane');
|
||||
expect(surface.material.fragmentShader).toContain('outerEdge');
|
||||
expect(surface.material.fragmentShader).toContain("centerLane");
|
||||
expect(surface.material.fragmentShader).toContain("outerEdge");
|
||||
|
||||
const positionArray = surface.geometry.getAttribute('position').array;
|
||||
const positionArray = surface.geometry.getAttribute("position").array;
|
||||
const before = resources.snapshot();
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
road.update(index * 0.5, 0.65, 0.4);
|
||||
}
|
||||
|
||||
expect(resources.snapshot()).toEqual(before);
|
||||
expect(surface.geometry.getAttribute('position').array).toBe(positionArray);
|
||||
expect(surface.geometry.getAttribute("position").array).toBe(positionArray);
|
||||
expect(surface.material.uniforms.uDistance?.value).toBe(149.5);
|
||||
expect(surface.material.uniforms.uSpeed?.value).toBe(0.65);
|
||||
expect(surface.material.uniforms.uAcceleration?.value).toBe(0.4);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it('builds one wide indexed line terrain with a flat road-center mask', () => {
|
||||
it("builds one wide indexed line terrain with a flat road-center mask", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const terrain = createTerrain(resources, renderQualityFor('medium', 1, false));
|
||||
const lines = terrain.group.getObjectByName('terrain-lines') as ShaderRenderable;
|
||||
const terrain = createTerrain(
|
||||
resources,
|
||||
renderQualityFor("medium", 1, false),
|
||||
);
|
||||
const lines = terrain.group.getObjectByName(
|
||||
"terrain-lines",
|
||||
) as ShaderRenderable;
|
||||
|
||||
expect(lines).toBeInstanceOf(THREE.LineSegments);
|
||||
expect(lines.geometry.index).not.toBeNull();
|
||||
lines.geometry.computeBoundingBox();
|
||||
expect(lines.geometry.boundingBox).not.toBeNull();
|
||||
expect(
|
||||
(lines.geometry.boundingBox?.max.x ?? 0) - (lines.geometry.boundingBox?.min.x ?? 0),
|
||||
(lines.geometry.boundingBox?.max.x ?? 0) -
|
||||
(lines.geometry.boundingBox?.min.x ?? 0),
|
||||
).toBeGreaterThan(120);
|
||||
|
||||
const roadMask = lines.geometry.getAttribute('roadMask');
|
||||
const roadMask = lines.geometry.getAttribute("roadMask");
|
||||
expect(roadMask).toBeDefined();
|
||||
const maskValues = Array.from(roadMask.array as ArrayLike<number>);
|
||||
expect(Math.min(...maskValues)).toBe(0);
|
||||
@@ -84,28 +94,28 @@ describe('procedural world models', () => {
|
||||
uSpeed: { value: 0 },
|
||||
uRouteProgress: { value: 0 },
|
||||
});
|
||||
expect(lines.material.vertexShader).toContain('multiOctaveNoise');
|
||||
expect(lines.material.vertexShader).toContain("multiOctaveNoise");
|
||||
|
||||
const indexArray = lines.geometry.index?.array;
|
||||
const positionArray = lines.geometry.getAttribute('position').array;
|
||||
const positionArray = lines.geometry.getAttribute("position").array;
|
||||
const lowColor = lines.material.uniforms.uLowColor?.value;
|
||||
const highColor = lines.material.uniforms.uHighColor?.value;
|
||||
const before = resources.snapshot();
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
terrain.update(index * 0.4, 0.7, 'builds', index / 299);
|
||||
terrain.update(index * 0.4, 0.7, "builds", index / 299);
|
||||
}
|
||||
|
||||
expect(resources.snapshot()).toEqual(before);
|
||||
expect(lines.geometry.index?.array).toBe(indexArray);
|
||||
expect(lines.geometry.getAttribute('position').array).toBe(positionArray);
|
||||
expect(lines.geometry.getAttribute("position").array).toBe(positionArray);
|
||||
expect(lines.material.uniforms.uLowColor?.value).toBe(lowColor);
|
||||
expect(lines.material.uniforms.uHighColor?.value).toBe(highColor);
|
||||
expect(lines.material.uniforms.uRouteProgress?.value).toBe(1);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it('writes deterministic chapter blends into stable color allocations', () => {
|
||||
const target = createMutableChapterPalette('deep-night');
|
||||
it("writes deterministic chapter blends into stable color allocations", () => {
|
||||
const target = createMutableChapterPalette("deep-night");
|
||||
const colorReferences = [
|
||||
target.sky,
|
||||
target.fog,
|
||||
@@ -116,16 +126,20 @@ describe('procedural world models', () => {
|
||||
];
|
||||
|
||||
expect(Object.keys(CHAPTER_PALETTES)).toEqual([
|
||||
'deep-night',
|
||||
'systems',
|
||||
'infrastructure',
|
||||
'builds',
|
||||
'pre-dawn',
|
||||
'destination',
|
||||
"deep-night",
|
||||
"systems",
|
||||
"infrastructure",
|
||||
"builds",
|
||||
"pre-dawn",
|
||||
"destination",
|
||||
] satisfies EnvironmentChapter[]);
|
||||
expect(writeChapterPalette(target, 'deep-night', 'destination', 0.5)).toBe(target);
|
||||
expect(writeChapterPalette(target, "deep-night", "destination", 0.5)).toBe(
|
||||
target,
|
||||
);
|
||||
const first = paletteSnapshot(target);
|
||||
expect(writeChapterPalette(target, 'deep-night', 'destination', 0.5)).toBe(target);
|
||||
expect(writeChapterPalette(target, "deep-night", "destination", 0.5)).toBe(
|
||||
target,
|
||||
);
|
||||
expect(paletteSnapshot(target)).toEqual(first);
|
||||
expect([
|
||||
target.sky,
|
||||
@@ -135,62 +149,90 @@ describe('procedural world models', () => {
|
||||
target.sun,
|
||||
target.haze,
|
||||
]).toEqual(colorReferences);
|
||||
expect(target.warmth).toBeGreaterThan(CHAPTER_PALETTES['deep-night'].warmth);
|
||||
expect(target.warmth).toBeGreaterThan(
|
||||
CHAPTER_PALETTES["deep-night"].warmth,
|
||||
);
|
||||
expect(target.warmth).toBeLessThan(CHAPTER_PALETTES.destination.warmth);
|
||||
});
|
||||
|
||||
it('renders the sun as a scan-banded disc with a separately owned halo', () => {
|
||||
it("renders the sun as a scan-banded disc with a separately owned halo", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sun = createSun(resources);
|
||||
const disc = sun.group.getObjectByName('sun-disc') as ShaderRenderable;
|
||||
const halo = sun.group.getObjectByName('sun-halo');
|
||||
const disc = sun.group.getObjectByName("sun-disc") as ShaderRenderable;
|
||||
const halo = sun.group.getObjectByName("sun-halo");
|
||||
|
||||
expect(disc).toBeInstanceOf(THREE.Mesh);
|
||||
expect(halo).toBeInstanceOf(THREE.Sprite);
|
||||
expect(disc.material.uniforms).toMatchObject({
|
||||
uScanBands: { value: 14 },
|
||||
uWarmth: { value: CHAPTER_PALETTES['deep-night'].warmth },
|
||||
uWarmth: { value: CHAPTER_PALETTES["deep-night"].warmth },
|
||||
});
|
||||
expect(disc.material.fragmentShader).toContain('scanBand');
|
||||
expect(disc.material.fragmentShader).toContain("scanBand");
|
||||
expect(resources.snapshot().owned.texture).toBe(1);
|
||||
|
||||
const palette = createMutableChapterPalette('destination');
|
||||
const palette = createMutableChapterPalette("destination");
|
||||
sun.update(palette, 1);
|
||||
expect(disc.material.uniforms.uWarmth?.value).toBe(1);
|
||||
expect(disc.material.uniforms.uRouteProgress?.value).toBe(1);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it('builds deterministic stars, haze layers, and quality-capped speed streaks', () => {
|
||||
it("builds deterministic stars, haze layers, and quality-capped speed streaks", () => {
|
||||
const highResources = new SceneResourceBag();
|
||||
const lowResources = new SceneResourceBag();
|
||||
const highQuality = renderQualityFor('high', 1, false);
|
||||
const lowQuality = renderQualityFor('low', 1, true);
|
||||
const highQuality = renderQualityFor("high", 1, false);
|
||||
const lowQuality = renderQualityFor("low", 1, true);
|
||||
const high = createAtmosphere(highResources, highQuality);
|
||||
const low = createAtmosphere(lowResources, lowQuality);
|
||||
const highStars = high.group.getObjectByName('atmosphere-stars') as ShaderRenderable;
|
||||
const lowStars = low.group.getObjectByName('atmosphere-stars') as ShaderRenderable;
|
||||
const highStreaks = high.group.getObjectByName('atmosphere-streaks') as ShaderRenderable;
|
||||
const lowStreaks = low.group.getObjectByName('atmosphere-streaks') as ShaderRenderable;
|
||||
const highStars = high.group.getObjectByName(
|
||||
"atmosphere-stars",
|
||||
) as ShaderRenderable;
|
||||
const lowStars = low.group.getObjectByName(
|
||||
"atmosphere-stars",
|
||||
) as ShaderRenderable;
|
||||
const highStreaks = high.group.getObjectByName(
|
||||
"atmosphere-streaks",
|
||||
) as ShaderRenderable;
|
||||
const lowStreaks = low.group.getObjectByName(
|
||||
"atmosphere-streaks",
|
||||
) as ShaderRenderable;
|
||||
|
||||
expect(highStars.geometry.getAttribute('position').count).toBe(highQuality.starCount);
|
||||
expect(lowStars.geometry.getAttribute('position').count).toBe(lowQuality.starCount);
|
||||
expect(highStars.geometry.getAttribute("position").count).toBe(
|
||||
highQuality.starCount,
|
||||
);
|
||||
expect(lowStars.geometry.getAttribute("position").count).toBe(
|
||||
lowQuality.starCount,
|
||||
);
|
||||
expect(highStars.material.uniforms).toMatchObject({
|
||||
uPointScale: { value: 18 },
|
||||
uMaxPointSize: { value: 4.5 },
|
||||
});
|
||||
expect(highStreaks.geometry.getAttribute('position').count).toBe(highQuality.streakCount * 2);
|
||||
expect(lowStreaks.geometry.getAttribute('position').count).toBe(lowQuality.streakCount * 2);
|
||||
expect(high.group.getObjectByName('horizon-haze-near')).toBeInstanceOf(THREE.Mesh);
|
||||
expect(high.group.getObjectByName('horizon-haze-far')).toBeInstanceOf(THREE.Mesh);
|
||||
expect(highStreaks.geometry.getAttribute("position").count).toBe(
|
||||
highQuality.streakCount * 2,
|
||||
);
|
||||
expect(lowStreaks.geometry.getAttribute("position").count).toBe(
|
||||
lowQuality.streakCount * 2,
|
||||
);
|
||||
expect(high.group.getObjectByName("horizon-haze-near")).toBeInstanceOf(
|
||||
THREE.Mesh,
|
||||
);
|
||||
expect(high.group.getObjectByName("horizon-haze-far")).toBeInstanceOf(
|
||||
THREE.Mesh,
|
||||
);
|
||||
|
||||
const highStarPositions = Array.from(highStars.geometry.getAttribute('position').array);
|
||||
const highStarPositions = Array.from(
|
||||
highStars.geometry.getAttribute("position").array,
|
||||
);
|
||||
const secondResources = new SceneResourceBag();
|
||||
const secondHigh = createAtmosphere(secondResources, highQuality);
|
||||
const secondStars = secondHigh.group.getObjectByName('atmosphere-stars') as ShaderRenderable;
|
||||
expect(Array.from(secondStars.geometry.getAttribute('position').array)).toEqual(highStarPositions);
|
||||
const secondStars = secondHigh.group.getObjectByName(
|
||||
"atmosphere-stars",
|
||||
) as ShaderRenderable;
|
||||
expect(
|
||||
Array.from(secondStars.geometry.getAttribute("position").array),
|
||||
).toEqual(highStarPositions);
|
||||
|
||||
const palette = createMutableChapterPalette('builds');
|
||||
const palette = createMutableChapterPalette("builds");
|
||||
const before = highResources.snapshot();
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
high.update(index, 0.7, 0.8, palette);
|
||||
@@ -204,25 +246,70 @@ describe('procedural world models', () => {
|
||||
secondResources.dispose();
|
||||
});
|
||||
|
||||
it('disposes a composer and its passes exactly once', () => {
|
||||
it("disposes a composer and its passes exactly once", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const target = (width: number, height: number, depthBuffer: boolean) => ({
|
||||
width,
|
||||
height,
|
||||
depthBuffer,
|
||||
samples: 1,
|
||||
dispose: vi.fn(),
|
||||
});
|
||||
const composer = {
|
||||
addPass: vi.fn(),
|
||||
setPixelRatio: vi.fn(),
|
||||
setSize: vi.fn(),
|
||||
render: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
renderTarget1: target(1_500, 1_000, true),
|
||||
renderTarget2: target(1_500, 1_000, true),
|
||||
};
|
||||
const renderPass = { scene: null, camera: null, setSize: vi.fn(), dispose: vi.fn() };
|
||||
const bloomPass = { setSize: vi.fn(), dispose: vi.fn() };
|
||||
const processor = createPostProcessor(resources, {}, {
|
||||
createComposer: () => composer,
|
||||
createRenderPass: () => renderPass,
|
||||
createBloomPass: () => bloomPass,
|
||||
});
|
||||
const renderPass = {
|
||||
scene: null,
|
||||
camera: null,
|
||||
setSize: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
const bloomPass = {
|
||||
setSize: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
renderTargetBright: target(750, 500, false),
|
||||
renderTargetsHorizontal: [target(375, 250, false)],
|
||||
renderTargetsVertical: [target(375, 250, false)],
|
||||
};
|
||||
const processor = createPostProcessor(
|
||||
resources,
|
||||
{},
|
||||
{
|
||||
createComposer: () => composer,
|
||||
createRenderPass: () => renderPass,
|
||||
createBloomPass: () => bloomPass,
|
||||
},
|
||||
);
|
||||
|
||||
processor.resize(1_200, 800, 1.25);
|
||||
processor.render({}, {});
|
||||
const allocations = resources.gpuBudgetSnapshot({
|
||||
tier: "high",
|
||||
defaultFramebuffer: {
|
||||
width: 1_500,
|
||||
height: 1_000,
|
||||
bytesPerPixel: 4,
|
||||
attachments: 2,
|
||||
samples: 4,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
allocations.renderTargets.map((allocation) => allocation.id),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
"composer-target-1",
|
||||
"composer-target-2",
|
||||
"bloom-bright",
|
||||
"bloom-horizontal-0",
|
||||
"bloom-vertical-0",
|
||||
]),
|
||||
);
|
||||
processor.dispose();
|
||||
processor.dispose();
|
||||
resources.dispose();
|
||||
@@ -236,19 +323,19 @@ describe('procedural world models', () => {
|
||||
expect(composer.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps the grand tourer anatomy readable in desktop and portrait chase views', () => {
|
||||
it("keeps the grand tourer anatomy readable in desktop and portrait chase views", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const car = createCar(resources);
|
||||
for (const name of [
|
||||
'rear-silhouette',
|
||||
'rear-body-face',
|
||||
'rear-light-bar',
|
||||
'rear-glass',
|
||||
'rear-glass-panel',
|
||||
'rear-fascia',
|
||||
'rear-wheel-left',
|
||||
'rear-wheel-right',
|
||||
'car-underglow',
|
||||
"rear-silhouette",
|
||||
"rear-body-face",
|
||||
"rear-light-bar",
|
||||
"rear-glass",
|
||||
"rear-glass-panel",
|
||||
"rear-fascia",
|
||||
"rear-wheel-left",
|
||||
"rear-wheel-right",
|
||||
"car-underglow",
|
||||
]) {
|
||||
expect(car.group.getObjectByName(name), name).toBeDefined();
|
||||
}
|
||||
@@ -262,23 +349,26 @@ describe('procedural world models', () => {
|
||||
expect(desktop.minY).toBeGreaterThan(-0.99);
|
||||
expect(desktop.maxY).toBeLessThan(-0.05);
|
||||
|
||||
car.setDetail('reduced');
|
||||
car.setDetail("reduced");
|
||||
const portrait = projectedBounds(car.group, createChaseCamera(390 / 844));
|
||||
expect(portrait.minX).toBeGreaterThan(-0.99);
|
||||
expect(portrait.maxX).toBeLessThan(0.99);
|
||||
expect(portrait.minY).toBeGreaterThan(-0.99);
|
||||
expect(portrait.maxY).toBeLessThan(0.16);
|
||||
expect((car.group.getObjectByName('secondary-detail') as { visible: boolean }).visible).toBe(false);
|
||||
expect(
|
||||
(car.group.getObjectByName("secondary-detail") as { visible: boolean })
|
||||
.visible,
|
||||
).toBe(false);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it('damps squat and light response without allocating or changing the scene graph', () => {
|
||||
it("damps squat and light response without allocating or changing the scene graph", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const car = createCar(resources);
|
||||
const light = car.group.getObjectByName('rear-light-bar') as {
|
||||
const light = car.group.getObjectByName("rear-light-bar") as {
|
||||
material: { emissiveIntensity: number };
|
||||
};
|
||||
const underglow = car.group.getObjectByName('car-underglow') as {
|
||||
const underglow = car.group.getObjectByName("car-underglow") as {
|
||||
material: { uniforms: Record<string, { value: number }> };
|
||||
};
|
||||
car.update(0.3, 0, 0);
|
||||
@@ -292,28 +382,36 @@ describe('procedural world models', () => {
|
||||
car.update(0.92, 1, frame / 60);
|
||||
}
|
||||
|
||||
expect((car.group as { position: { y: number } }).position.y).toBeLessThan(restingY);
|
||||
expect((car.group as { position: { y: number } }).position.y).toBeLessThan(
|
||||
restingY,
|
||||
);
|
||||
expect(light.material.emissiveIntensity).toBeGreaterThan(restingLight);
|
||||
expect(underglow.material.uniforms.uOpacity?.value).toBeGreaterThan(restingGlow);
|
||||
expect(Math.abs((car.group as { rotation: { z: number } }).rotation.z)).toBeLessThanOrEqual(0.006);
|
||||
expect((car.group as { children: unknown[] }).children).toHaveLength(childCount);
|
||||
expect(underglow.material.uniforms.uOpacity?.value).toBeGreaterThan(
|
||||
restingGlow,
|
||||
);
|
||||
expect(
|
||||
Math.abs((car.group as { rotation: { z: number } }).rotation.z),
|
||||
).toBeLessThanOrEqual(0.006);
|
||||
expect((car.group as { children: unknown[] }).children).toHaveLength(
|
||||
childCount,
|
||||
);
|
||||
expect(resources.snapshot()).toEqual(allocation);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it('renders one physical roadside sign and updates its plain label once per target', () => {
|
||||
it("renders one physical roadside sign and updates its plain label once per target", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sign = createSign(resources);
|
||||
for (const name of [
|
||||
'sign-backplate',
|
||||
'sign-face',
|
||||
'sign-frame',
|
||||
'sign-post-left',
|
||||
'sign-post-right',
|
||||
"sign-backplate",
|
||||
"sign-face",
|
||||
"sign-frame",
|
||||
"sign-post-left",
|
||||
"sign-post-right",
|
||||
]) {
|
||||
expect(sign.group.getObjectByName(name), name).toBeDefined();
|
||||
}
|
||||
const face = sign.group.getObjectByName('sign-face') as {
|
||||
const face = sign.group.getObjectByName("sign-face") as {
|
||||
userData: { textureUpdateCount: number };
|
||||
material: { opacity: number };
|
||||
};
|
||||
@@ -322,18 +420,18 @@ describe('procedural world models', () => {
|
||||
sign.update(null);
|
||||
expect((sign.group as { visible: boolean }).visible).toBe(false);
|
||||
sign.update({
|
||||
targetId: 'infrastructure',
|
||||
phase: 'anticipate',
|
||||
targetId: "infrastructure",
|
||||
phase: "anticipate",
|
||||
progress: 0,
|
||||
opacity: 0,
|
||||
});
|
||||
expect((sign.group as { visible: boolean }).visible).toBe(false);
|
||||
expect(face.userData.textureUpdateCount).toBe(1);
|
||||
expect(routeLabelFor('infrastructure')).toBe('Infrastructure');
|
||||
expect(routeLabelFor("infrastructure")).toBe("Infrastructure");
|
||||
|
||||
sign.update({
|
||||
targetId: 'infrastructure',
|
||||
phase: 'approach',
|
||||
targetId: "infrastructure",
|
||||
phase: "approach",
|
||||
progress: 0.5,
|
||||
opacity: 0.7,
|
||||
});
|
||||
@@ -344,20 +442,24 @@ describe('procedural world models', () => {
|
||||
const desktopX = (sign.group as { position: { x: number } }).position.x;
|
||||
sign.setViewportAspect(0.6);
|
||||
sign.update({
|
||||
targetId: 'selected-builds',
|
||||
phase: 'dwell',
|
||||
targetId: "selected-builds",
|
||||
phase: "dwell",
|
||||
progress: 1,
|
||||
opacity: 1,
|
||||
});
|
||||
expect(face.userData.textureUpdateCount).toBe(2);
|
||||
expect(routeLabelFor('selected-builds')).toBe('Selected builds');
|
||||
expect((sign.group as { position: { x: number } }).position.x).toBeLessThan(desktopX);
|
||||
expect(routeLabelFor("selected-builds")).toBe("Selected builds");
|
||||
expect((sign.group as { position: { x: number } }).position.x).toBeLessThan(
|
||||
desktopX,
|
||||
);
|
||||
expect(resources.snapshot()).toEqual(allocation);
|
||||
resources.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
function paletteSnapshot(palette: ReturnType<typeof createMutableChapterPalette>) {
|
||||
function paletteSnapshot(
|
||||
palette: ReturnType<typeof createMutableChapterPalette>,
|
||||
) {
|
||||
return {
|
||||
sky: palette.sky.getHex(),
|
||||
fog: palette.fog.getHex(),
|
||||
|
||||
Reference in New Issue
Block a user