Files
Portfolio/tests/browser/journey-integration.spec.ts

322 lines
14 KiB
TypeScript

import { clickNavigationLink, expect, test } from '../helpers/serve-dist';
import {
installSceneInstrumentation,
readSceneInstrumentation,
waitForOneAnimationFrame,
} from '../helpers/scene-instrumentation';
const sections = [
{ id: 'start', label: 'Start' },
{ id: 'profile', label: 'Profile' },
{ id: 'experience', label: 'Experience' },
{ id: 'infrastructure', label: 'Infrastructure' },
{ id: 'selected-builds', label: 'Selected builds' },
{ id: 'additional-work', label: 'Additional work' },
{ id: 'toolkit', label: 'Toolkit' },
{ id: 'contact', label: 'Contact' },
] as const;
test.beforeEach(async ({ page }) => installSceneInstrumentation(page));
test('keeps navigation, HUD, progress, chapter, and arrival on the same next-frame section snapshot', async ({
page,
distUrl,
}) => {
const graphicsErrors: string[] = [];
page.on('console', (message) => {
if (
message.type() === 'error'
&& /THREE\.WebGLProgram|Shader Error|VALIDATE_STATUS/iu.test(message.text())
) graphicsErrors.push(message.text());
});
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', /running|failed|static/u);
const chapterColors: string[] = [];
for (const section of sections) {
await page.evaluate((id) => {
const heading = document.getElementById(`${id}-heading`);
if (!(heading instanceof HTMLElement)) throw new Error(`Missing heading: ${id}`);
const top = heading.getBoundingClientRect().top + window.scrollY;
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
const destination = id === 'contact'
? document.documentElement.scrollHeight
: Math.max(0, top - viewportHeight * 0.35 + 1);
window.scrollTo({ top: destination, behavior: 'instant' });
}, section.id);
await waitForOneAnimationFrame(page);
await expect(page.locator(`nav a[href="#${section.id}"]`)).toHaveAttribute('aria-current', 'page');
await expect(page.locator('.journey-hud__section')).toHaveText(section.label);
const routeText = await page.locator('.journey-hud__route').textContent();
expect(routeText).toMatch(/^ROUTE (?:100|[0-9]{1,2})%$/u);
if (section.id === 'contact') {
await expect(page.locator('.journey-hud__arrival')).toHaveText('ARRIVED');
await expect(page.locator('.journey-hud__route')).toHaveText('ROUTE 100%');
} else {
await expect(page.locator('.journey-hud__arrival')).toHaveCount(0);
}
// The section snapshot is emitted in the scroll RAF above; the scene's
// already-running loop must consume it no later than its next RAF.
await waitForOneAnimationFrame(page);
const clearColors = (await readSceneInstrumentation(page)).clearColors.filter((color) => color !== '#000000');
const latestChapterColor = clearColors.at(-1);
if (latestChapterColor === undefined) throw new Error(`Missing rendered chapter color for ${section.id}`);
chapterColors.push(latestChapterColor);
}
const instrumentation = await readSceneInstrumentation(page);
expect(instrumentation.maximumCanvasCount).toBeLessThanOrEqual(1);
const chapterEnergy = chapterColors.map(colorEnergy);
for (let index = 1; index < chapterEnergy.length; index += 1) {
expect(chapterEnergy[index]).toBeGreaterThanOrEqual(chapterEnergy[index - 1] ?? 0);
}
expect(chapterEnergy.at(-1)).toBeGreaterThan(chapterEnergy[0] ?? 0);
expect(new Set(chapterColors).size).toBeGreaterThanOrEqual(4);
expect(graphicsErrors).toEqual([]);
});
test('draws only the independently eligible next roadside sign during passive forward travel', async ({
page,
distUrl,
}) => {
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
const oracle = await page.evaluate(() => {
const profile = document.getElementById('profile-heading');
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
const headingTop = profile.getBoundingClientRect().top + window.scrollY;
const destination = Math.max(1, headingTop - viewportHeight * (0.35 + 0.45));
return { destination, viewportHeight, expectedLabel: 'Profile' };
});
await page.evaluate(() => window.dispatchEvent(new WheelEvent('wheel', { deltaY: 1 })));
for (let y = 1; y < oracle.destination; y += oracle.viewportHeight * 0.5) {
await page.evaluate((nextY) => window.scrollTo(0, nextY), y);
await waitForOneAnimationFrame(page);
}
await page.evaluate((destination) => window.scrollTo(0, destination), oracle.destination);
await waitForOneAnimationFrame(page);
await expect.poll(async () => (await readSceneInstrumentation(page)).routeLabelsDrawn).toContain(oracle.expectedLabel);
const labels = (await readSceneInstrumentation(page)).routeLabelsDrawn;
expect([...new Set(labels)]).toEqual([oracle.expectedLabel]);
});
test('keeps a readable sign immutable through reversal, resize, and a multi-section jump', async ({
page,
distUrl,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
const oracle = await page.evaluate(() => {
const profile = document.getElementById('profile-heading');
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
const headingTop = profile.getBoundingClientRect().top + window.scrollY;
return {
destination: Math.max(1, headingTop - viewportHeight * (0.35 + 0.45)),
viewportHeight,
};
});
await page.mouse.wheel(0, 1);
for (let y = 1; y < oracle.destination; y += oracle.viewportHeight * 0.5) {
await page.evaluate((nextY) => window.scrollTo(0, nextY), y);
await waitForOneAnimationFrame(page);
}
await page.evaluate((destination) => window.scrollTo(0, destination), oracle.destination);
await expect.poll(async () => (await readSceneInstrumentation(page)).routeLabelsDrawn)
.toContain('Profile');
await page.waitForTimeout(1_250);
await page.mouse.wheel(0, -120);
await page.setViewportSize({ width: 844, height: 390 });
await page.evaluate(() => {
const target = document.getElementById('selected-builds-heading');
if (!(target instanceof HTMLElement)) throw new Error('Missing selected builds heading');
window.scrollTo(0, target.getBoundingClientRect().top + window.scrollY);
});
await page.waitForTimeout(450);
const labels = (await readSceneInstrumentation(page)).routeLabelsDrawn;
expect([...new Set(labels)]).toEqual(['Profile']);
expect((await readSceneInstrumentation(page)).maximumCanvasCount).toBeLessThanOrEqual(1);
});
test('suppresses roadside signs for explicit navigation and history restoration', async ({
page,
distUrl,
}) => {
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
await clickNavigationLink(page, '#selected-builds');
await expect(page.locator('#selected-builds-heading')).toBeFocused();
await page.waitForTimeout(400);
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual([]);
await page.goBack({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(400);
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual([]);
});
test('reconciles an idle rapid jump to the latest active route without flashing intermediates', async ({
page,
distUrl,
}) => {
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
await page.mouse.wheel(0, 1);
await page.evaluate(() => {
const target = document.getElementById('selected-builds-heading');
if (!(target instanceof HTMLElement)) throw new Error('Missing selected builds heading');
const top = target.getBoundingClientRect().top + window.scrollY;
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
window.scrollTo(0, Math.max(1, top - viewportHeight * 0.35 + 1));
});
await expect.poll(async () => (await readSceneInstrumentation(page)).routeLabelsDrawn)
.toContain('Selected builds');
expect([...new Set((await readSceneInstrumentation(page)).routeLabelsDrawn)])
.toEqual(['Selected builds']);
});
test('keeps the readable Profile face outside its destination panel with verified texel density', async ({
page,
distUrl,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
const destination = await page.evaluate(() => {
const profile = document.getElementById('profile-heading');
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
return Math.max(
1,
profile.getBoundingClientRect().top + window.scrollY
- viewportHeight * (0.35 + 0.45),
);
});
await page.mouse.wheel(0, 1);
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
await expect.poll(async () => (
await readSceneInstrumentation(page)
).signFrames.some((frame) => frame.targetId === 'profile' && frame.phase === 'dwell')).toBe(true);
const instrumentation = await readSceneInstrumentation(page);
const frame = instrumentation.signFrames.findLast((candidate) => (
candidate.targetId === 'profile' && candidate.phase === 'dwell'
));
if (frame === undefined) throw new Error('Missing readable Profile sign frame');
const panel = await page.locator('#profile').evaluate((element) => {
const bounds = element.getBoundingClientRect();
return { left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom };
});
expect(frame.bounds.right).toBeLessThanOrEqual(panel.left - 16);
expect(frame.texture).toMatchObject({
width: 2_048,
height: 512,
fontReadyWhenDrawn: true,
});
expect(frame.texture.width / (frame.bounds.width * frame.rendererPixelRatio))
.toBeGreaterThanOrEqual(4);
});
test('keeps a fresh readable sign inside responsive world-safe corridors', async ({
page,
distUrl,
}, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'One exact responsive matrix is sufficient.');
for (const viewport of [
{ width: 390, height: 844, profile: 'tall', minimumFaceWidth: 120 },
{ width: 844, height: 390, profile: 'short', minimumFaceWidth: 130 },
{ width: 768, height: 1_024, profile: 'tall', minimumFaceWidth: 150 },
{ width: 1_440, height: 900, profile: 'wide', minimumFaceWidth: 180 },
{ width: 2_560, height: 1_440, profile: 'wide', minimumFaceWidth: 240 },
] as const) {
await page.setViewportSize(viewport);
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
const destination = await page.evaluate(() => {
const profile = document.getElementById('profile-heading');
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
return Math.max(
1,
profile.getBoundingClientRect().top + window.scrollY
- viewportHeight * (0.35 + 0.45),
);
});
await page.mouse.wheel(0, 1);
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
await expect.poll(async () => (
await readSceneInstrumentation(page)
).signFrames.at(-1)?.phase, { intervals: [16], timeout: 5_000 }).toBe('dwell');
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
candidate.targetId === 'profile' && candidate.phase === 'dwell'
));
if (frame === undefined) throw new Error(`Missing Profile dwell at ${viewport.width}x${viewport.height}`);
const geometry = await page.evaluate(() => {
const boundsFor = (selector: string) => {
const element = document.querySelector(selector);
if (!(element instanceof HTMLElement)) return null;
const bounds = element.getBoundingClientRect();
return { left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom };
};
return {
panel: boundsFor('#profile'),
adjacentPanels: [boundsFor('.hero-journey__content')].filter((bounds) => bounds !== null),
fixedZones: [
boundsFor('.site-navigation'),
boundsFor('.drive-controls'),
boundsFor('.journey-hud'),
].filter((bounds) => bounds !== null),
};
});
if (geometry.panel === null) throw new Error('Missing Profile panel bounds');
expect(frame.viewportProfile).toBe(viewport.profile);
expect(frame.bounds.left).toBeGreaterThanOrEqual(8);
expect(frame.bounds.right).toBeLessThanOrEqual(viewport.width - 8);
expect(frame.bounds.top).toBeGreaterThanOrEqual(8);
expect(frame.bounds.bottom).toBeLessThanOrEqual(viewport.height - 8);
expect(frame.bounds.width).toBeGreaterThanOrEqual(viewport.minimumFaceWidth);
expect(rectanglesOverlap(frame.bounds, geometry.panel, 16)).toBe(false);
for (const adjacentPanel of geometry.adjacentPanels) {
expect(rectanglesOverlap(frame.bounds, adjacentPanel, 16)).toBe(false);
}
for (const fixedZone of geometry.fixedZones) {
expect(rectanglesOverlap(frame.bounds, fixedZone, 8)).toBe(false);
}
expect(frame.texture.fontReadyWhenDrawn).toBe(true);
expect(frame.texture.width / (frame.bounds.width * frame.rendererPixelRatio))
.toBeGreaterThanOrEqual(4);
}
});
function colorEnergy(color: string): number {
const value = Number.parseInt(color.slice(1), 16);
return ((value >> 16) & 0xff) + ((value >> 8) & 0xff) + (value & 0xff);
}
function rectanglesOverlap(
first: { left: number; top: number; right: number; bottom: number },
second: { left: number; top: number; right: number; bottom: number },
gap: number,
): boolean {
return !(
first.right <= second.left - gap
|| first.left >= second.right + gap
|| first.bottom <= second.top - gap
|| first.top >= second.bottom + gap
);
}