929 lines
42 KiB
TypeScript
929 lines
42 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));
|
|
|
|
|
|
/**
|
|
* The gantry window pre-draws its hover twin for the exit it rests on, which
|
|
* is Start at load. That is a window texture, not a roadside sign, so it is
|
|
* left out of the roadside label checks; Start never has a roadside sign.
|
|
*/
|
|
function roadsideLabels(labels: readonly string[]): string[] {
|
|
return labels.filter((label) => label !== 'Start');
|
|
}
|
|
|
|
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.6 + 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 viewport = page.viewportSize();
|
|
if (viewport === null) throw new Error('Missing browser viewport');
|
|
const oracle = await page.evaluate((candidateDistanceVh) => {
|
|
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.6 + candidateDistanceVh),
|
|
);
|
|
return { destination, viewportHeight, expectedLabel: 'Profile' };
|
|
}, triggerProbeDistanceVh(viewport.width, viewport.height));
|
|
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 = roadsideLabels((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.6 + 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.waitForTimeout(500);
|
|
const resizedFrame = (await readSceneInstrumentation(page)).signFrames.findLast((frame) => (
|
|
frame.targetId === 'profile'
|
|
&& frame.viewport.width === 844
|
|
&& frame.viewport.height === 390
|
|
));
|
|
if (resizedFrame === undefined) throw new Error('Missing post-resize Profile frame');
|
|
expect(resizedFrame.phase).toBe('dwell');
|
|
expect(resizedFrame.viewportProfile).toBe('short');
|
|
expect(resizedFrame.bounds.left).toBeGreaterThanOrEqual(8);
|
|
expect(resizedFrame.bounds.right).toBeLessThanOrEqual(288);
|
|
expect(countSignPresentations(
|
|
(await readSceneInstrumentation(page)).signFrames,
|
|
'profile',
|
|
)).toBe(1);
|
|
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);
|
|
});
|
|
|
|
/** The gantry window's box in CSS pixels, read from the live layout. */
|
|
async function gantryWindowBox(page: import('@playwright/test').Page) {
|
|
const box = await page.locator('.gantry-window').boundingBox();
|
|
if (box === null) throw new Error('Missing gantry window');
|
|
return { left: box.x, top: box.y, right: box.x + box.width, bottom: box.y + box.height, width: box.width };
|
|
}
|
|
|
|
/**
|
|
* Round 6 replaced the roadside pass-by with window docking: the sign arcs
|
|
* in large over the road, docks into the gantry window, holds there while it
|
|
* is read, then departs in place as the gantry reports it passing.
|
|
*/
|
|
test('docks a passing sign in the gantry window after its approach arc and lets it go in place', async ({
|
|
page,
|
|
distUrl,
|
|
}) => {
|
|
await page.setViewportSize({ width: 1_440, height: 900 });
|
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
|
await page.evaluate(() => {
|
|
const states: string[] = [];
|
|
(window as unknown as { __gantryStates: string[] }).__gantryStates = states;
|
|
new MutationObserver(() => states.push(document.documentElement.dataset.gantry ?? ''))
|
|
.observe(document.documentElement, { attributes: true, attributeFilter: ['data-gantry'] });
|
|
});
|
|
|
|
const viewport = page.viewportSize();
|
|
if (viewport === null) throw new Error('Missing browser viewport');
|
|
const destination = await page.evaluate((candidateDistanceVh) => {
|
|
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.6 + candidateDistanceVh),
|
|
);
|
|
}, triggerProbeDistanceVh(viewport.width, viewport.height));
|
|
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'), { timeout: 10_000 }).toBe(true);
|
|
await expect.poll(async () => page.evaluate(() => (
|
|
(window as unknown as { __gantryStates: string[] }).__gantryStates.includes('passing')
|
|
)), { timeout: 5_000 }).toBe(true);
|
|
await expect.poll(async () => (
|
|
await readSceneInstrumentation(page)
|
|
).signFrames.some((frame) => frame.targetId === 'profile' && frame.phase === 'depart')).toBe(true);
|
|
|
|
const windowBox = await gantryWindowBox(page);
|
|
const frames = (await readSceneInstrumentation(page)).signFrames
|
|
.filter((frame) => frame.targetId === 'profile');
|
|
const approachPeak = Math.max(...frames
|
|
.filter((frame) => frame.phase === 'approach')
|
|
.map((frame) => frame.bounds.width));
|
|
const dwell = frames.findLast((frame) => frame.phase === 'dwell');
|
|
const departs = frames.filter((frame) => frame.phase === 'depart');
|
|
if (dwell === undefined || departs.length === 0) {
|
|
throw new Error('Missing comparable Profile dwell and depart frames');
|
|
}
|
|
// The arc: the sign is read large over the road before it docks.
|
|
expect(approachPeak).toBeGreaterThan(dwell.bounds.width * 1.5);
|
|
// Docked: wholly inside the window, never across the reading column.
|
|
expect(dwell.opacity).toBeGreaterThanOrEqual(0.98);
|
|
expect(dwell.bounds.left).toBeGreaterThanOrEqual(windowBox.left - 1);
|
|
expect(dwell.bounds.right).toBeLessThanOrEqual(windowBox.right + 1);
|
|
expect(dwell.bounds.top).toBeGreaterThanOrEqual(windowBox.top - 1);
|
|
expect(dwell.bounds.bottom).toBeLessThanOrEqual(windowBox.bottom + 1);
|
|
// Departing: it fades where it docked instead of sweeping past the car.
|
|
for (const depart of departs) {
|
|
expect(Math.abs(depart.bounds.left - dwell.bounds.left)).toBeLessThanOrEqual(1);
|
|
expect(Math.abs(depart.bounds.width - dwell.bounds.width)).toBeLessThanOrEqual(1);
|
|
}
|
|
const states = await page.evaluate(() => (window as unknown as { __gantryStates: string[] }).__gantryStates);
|
|
const order = ['incoming', 'docked', 'passing'].map((state) => states.indexOf(state));
|
|
expect(order.every((index) => index >= 0)).toBe(true);
|
|
expect(order).toEqual([...order].sort((a, b) => a - b));
|
|
});
|
|
|
|
test('shows exactly one destination sign for a navigator click and suppresses 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 expect.poll(async () => (
|
|
await readSceneInstrumentation(page)
|
|
).signFrames.findLast((frame) => (
|
|
frame.targetId === 'selected-builds'
|
|
))?.phase).toBe('dwell');
|
|
|
|
await clickNavigationLink(page, '#selected-builds');
|
|
await expect(page.locator('#selected-builds-heading')).toBeFocused();
|
|
await page.waitForTimeout(2_400);
|
|
const afterRepeat = await readSceneInstrumentation(page);
|
|
expect(roadsideLabels(afterRepeat.routeLabelsDrawn)).toEqual(['Selected builds']);
|
|
expect(countSignPresentations(afterRepeat.signFrames, 'selected-builds')).toBe(1);
|
|
|
|
await page.goBack({ waitUntil: 'domcontentloaded' });
|
|
await page.waitForTimeout(400);
|
|
expect(roadsideLabels((await readSceneInstrumentation(page)).routeLabelsDrawn)).toEqual(['Selected builds']);
|
|
});
|
|
|
|
test('keeps the non-navigator Explore work section jump sign-suppressed', async ({
|
|
page,
|
|
distUrl,
|
|
}) => {
|
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
|
|
|
await page.locator('.hero-journey__action').click();
|
|
await expect(page.locator('#selected-builds-heading')).toBeFocused();
|
|
await page.waitForTimeout(400);
|
|
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual([]);
|
|
|
|
const viewport = page.viewportSize();
|
|
if (viewport === null) throw new Error('Missing browser viewport');
|
|
const destination = await page.evaluate((candidateDistanceVh) => {
|
|
const heading = document.getElementById('additional-work-heading');
|
|
if (!(heading instanceof HTMLElement)) throw new Error('Missing Additional work heading');
|
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
|
return Math.max(
|
|
1,
|
|
heading.getBoundingClientRect().top + window.scrollY
|
|
- viewportHeight * (0.6 + candidateDistanceVh),
|
|
);
|
|
}, triggerProbeDistanceVh(viewport.width, viewport.height));
|
|
await page.mouse.wheel(0, 1);
|
|
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
|
await expect.poll(async () => (
|
|
await readSceneInstrumentation(page)
|
|
).signFrames.findLast((frame) => frame.targetId === 'additional-work')?.phase).toBe('dwell');
|
|
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual(['Additional work']);
|
|
});
|
|
|
|
test('keeps a navigator-triggered Profile sign fully beside its visible destination panel', async ({
|
|
page,
|
|
distUrl,
|
|
}) => {
|
|
await page.setViewportSize({ width: 780, height: 493 });
|
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
|
|
|
await clickNavigationLink(page, '#profile');
|
|
await expect(page.locator('#profile-heading')).toBeFocused();
|
|
await expect.poll(async () => (
|
|
await readSceneInstrumentation(page)
|
|
).signFrames.some((frame) => (
|
|
frame.targetId === 'profile' && frame.phase === 'dwell'
|
|
))).toBe(true);
|
|
|
|
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
|
|
candidate.targetId === 'profile' && candidate.phase === 'dwell'
|
|
));
|
|
if (frame === undefined) throw new Error('Missing navigator-triggered Profile dwell 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.bounds.left).toBeGreaterThanOrEqual(8);
|
|
});
|
|
|
|
test('keeps a portrait navigator sign clear of its full-width destination panel', async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
test.skip(testInfo.project.name !== 'desktop-chromium', 'One exact portrait composition is sufficient.');
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
|
|
|
await clickNavigationLink(page, '#profile');
|
|
await expect(page.locator('#profile-heading')).toBeFocused();
|
|
await expect.poll(async () => (
|
|
await readSceneInstrumentation(page)
|
|
).signFrames.some((frame) => frame.targetId === 'profile' && frame.phase === 'dwell')).toBe(true);
|
|
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
|
|
candidate.targetId === 'profile' && candidate.phase === 'dwell'
|
|
));
|
|
if (frame === undefined) throw new Error('Missing portrait navigator Profile dwell frame');
|
|
const panels = await page.locator('#profile, #experience').evaluateAll((elements) => (
|
|
elements.map((element) => {
|
|
const bounds = element.getBoundingClientRect();
|
|
return {
|
|
id: element.id,
|
|
left: bounds.left,
|
|
top: bounds.top,
|
|
right: bounds.right,
|
|
bottom: bounds.bottom,
|
|
};
|
|
})
|
|
));
|
|
for (const panel of panels) {
|
|
expect(
|
|
rectanglesOverlap(frame.bounds, panel, 16),
|
|
JSON.stringify({ frame: frame.bounds, panel }),
|
|
).toBe(false);
|
|
}
|
|
});
|
|
|
|
test('scales a medium portrait navigator sign without changing the phone composition', async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
test.skip(testInfo.project.name !== 'desktop-chromium', 'One exact portrait breakpoint comparison is sufficient.');
|
|
const widths: number[] = [];
|
|
|
|
for (const viewport of [
|
|
{ width: 390, height: 844, minimumWidth: 85, maximumWidth: 110 },
|
|
{ width: 692, height: 793, minimumWidth: 145, maximumWidth: 210 },
|
|
] as const) {
|
|
await page.setViewportSize(viewport);
|
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
|
|
|
await clickNavigationLink(page, '#profile');
|
|
await expect(page.locator('#profile-heading')).toBeFocused();
|
|
await expect.poll(async () => (
|
|
await readSceneInstrumentation(page)
|
|
).signFrames.some((frame) => frame.targetId === 'profile' && frame.phase === 'dwell')).toBe(true);
|
|
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
|
|
candidate.targetId === 'profile' && candidate.phase === 'dwell'
|
|
));
|
|
if (frame === undefined) throw new Error(`Missing navigator Profile dwell at ${viewport.width}x${viewport.height}`);
|
|
|
|
expect(frame.navigationArrival).toBe(true);
|
|
expect(frame.viewportProfile).toBe('tall');
|
|
expect(frame.bounds.width).toBeGreaterThanOrEqual(viewport.minimumWidth);
|
|
expect(frame.bounds.width).toBeLessThanOrEqual(viewport.maximumWidth);
|
|
expect(frame.bounds.left).toBeGreaterThanOrEqual(8);
|
|
expect(frame.bounds.right).toBeLessThanOrEqual(viewport.width - 8);
|
|
const occupied = await page.locator('#profile, #experience').evaluateAll((elements) => (
|
|
elements.map((element) => {
|
|
const bounds = element.getBoundingClientRect();
|
|
return { id: element.id, left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom };
|
|
}).filter((bounds) => bounds.bottom > 0 && bounds.top < window.innerHeight)
|
|
));
|
|
for (const panel of occupied) {
|
|
expect(rectanglesOverlap(frame.bounds, panel, 16), JSON.stringify({ viewport, frame: frame.bounds, panel }))
|
|
.toBe(false);
|
|
}
|
|
widths.push(frame.bounds.width);
|
|
}
|
|
|
|
expect(widths[1]).toBeGreaterThanOrEqual((widths[0] ?? 0) * 1.45);
|
|
});
|
|
|
|
test('moves an already-readable portrait sign into navigator-safe placement without replaying it', async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
test.skip(testInfo.project.name !== 'desktop-chromium', 'One exact portrait composition is sufficient.');
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
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.6 + 0.22),
|
|
);
|
|
});
|
|
await page.mouse.wheel(0, 1);
|
|
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
|
await expect.poll(async () => (
|
|
await readSceneInstrumentation(page)
|
|
).signFrames.findLast((frame) => frame.targetId === 'profile')?.phase).toBe('dwell');
|
|
|
|
await clickNavigationLink(page, '#profile');
|
|
await expect(page.locator('#profile-heading')).toBeFocused();
|
|
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 };
|
|
});
|
|
await expect.poll(async () => {
|
|
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
|
|
candidate.targetId === 'profile'
|
|
&& candidate.phase === 'dwell'
|
|
&& candidate.navigationArrival === true
|
|
));
|
|
return frame === undefined ? true : rectanglesOverlap(frame.bounds, panel, 16);
|
|
}, { intervals: [16], timeout: 5_000 }).toBe(false);
|
|
|
|
const instrumentation = await readSceneInstrumentation(page);
|
|
expect(countSignPresentations(instrumentation.signFrames, 'profile')).toBe(1);
|
|
expect(roadsideLabels(instrumentation.routeLabelsDrawn)).toEqual(['Profile']);
|
|
});
|
|
|
|
test('re-arms passive signage after a navigator round trip to Start', async ({
|
|
page,
|
|
distUrl,
|
|
}) => {
|
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
|
|
|
await clickNavigationLink(page, '#profile');
|
|
await expect(page.locator('#profile-heading')).toBeFocused();
|
|
await expect.poll(async () => (
|
|
await readSceneInstrumentation(page)
|
|
).signFrames.some((frame) => frame.targetId === 'profile' && frame.phase === 'dwell')).toBe(true);
|
|
await page.waitForTimeout(2_500);
|
|
await clickNavigationLink(page, '#start');
|
|
await expect(page.locator('#start-heading')).toBeFocused();
|
|
|
|
const viewport = page.viewportSize();
|
|
if (viewport === null) throw new Error('Missing browser viewport');
|
|
const destination = await page.evaluate((candidateDistanceVh) => {
|
|
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.6 + candidateDistanceVh),
|
|
);
|
|
}, triggerProbeDistanceVh(viewport.width, viewport.height));
|
|
await page.mouse.wheel(0, 1);
|
|
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
|
|
|
await expect.poll(async () => countSignPresentations(
|
|
(await readSceneInstrumentation(page)).signFrames,
|
|
'profile',
|
|
)).toBe(2);
|
|
});
|
|
|
|
test('reconciles an idle rapid jump to the latest active route without flashing intermediates', async ({
|
|
page,
|
|
distUrl,
|
|
}) => {
|
|
await page.setViewportSize({ width: 1_440, height: 900 });
|
|
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.6 + 1));
|
|
});
|
|
|
|
await expect.poll(async () => (await readSceneInstrumentation(page)).routeLabelsDrawn)
|
|
.toContain('Selected builds');
|
|
await expect.poll(async () => (
|
|
await readSceneInstrumentation(page)
|
|
).signFrames.findLast((frame) => (
|
|
frame.targetId === 'selected-builds'
|
|
))?.phase, { intervals: [16], timeout: 5_000 }).toBe('dwell');
|
|
const instrumentation = await readSceneInstrumentation(page);
|
|
expect([...new Set(roadsideLabels(instrumentation.routeLabelsDrawn))])
|
|
.toEqual(['Selected builds']);
|
|
const dwell = instrumentation.signFrames.findLast((frame) => (
|
|
frame.targetId === 'selected-builds' && frame.phase === 'dwell'
|
|
));
|
|
if (dwell === undefined) throw new Error('Missing reconciled Selected builds dwell frame');
|
|
expect(dwell.bounds.left).toBeGreaterThanOrEqual(8);
|
|
expect(dwell.bounds.right).toBeLessThanOrEqual(1_432);
|
|
});
|
|
|
|
test('docks the readable Profile face in the gantry window, clear of its 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.6 + 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');
|
|
// The face is read in the gantry window, which sits above the reading
|
|
// column, so it can never overlap the Profile panel below it.
|
|
const windowBox = await gantryWindowBox(page);
|
|
expect(frame.bounds.left).toBeGreaterThanOrEqual(windowBox.left - 1);
|
|
expect(frame.bounds.right).toBeLessThanOrEqual(windowBox.right + 1);
|
|
expect(frame.bounds.bottom).toBeLessThanOrEqual(windowBox.bottom + 1);
|
|
const panelTop = await page.locator('#profile .chapter-body').evaluate((element) => (
|
|
element.getBoundingClientRect().top
|
|
));
|
|
expect(frame.bounds.bottom).toBeLessThanOrEqual(panelTop);
|
|
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: 780, height: 493, profile: 'short', 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((candidateDistanceVh) => {
|
|
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.6 + candidateDistanceVh),
|
|
);
|
|
}, triggerProbeDistanceVh(viewport.width, viewport.height));
|
|
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);
|
|
}
|
|
});
|
|
|
|
test('keeps the upcoming Experience sign clear of both outgoing and incoming panels', async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
test.skip(testInfo.project.name !== 'desktop-chromium', 'One exact desktop composition is sufficient.');
|
|
await page.setViewportSize({ width: 1_440, height: 900 });
|
|
await page.goto(`${distUrl}/#profile`, { waitUntil: 'domcontentloaded' });
|
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
|
await expect(page.locator('nav a[href="#profile"]')).toHaveAttribute('aria-current', 'page');
|
|
const destination = await page.evaluate(() => {
|
|
const heading = document.getElementById('experience-heading');
|
|
if (!(heading instanceof HTMLElement)) throw new Error('Missing Experience heading');
|
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
|
return Math.max(
|
|
1,
|
|
heading.getBoundingClientRect().top + window.scrollY
|
|
- viewportHeight * (0.6 + 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 === 'experience' && candidate.phase === 'dwell'
|
|
));
|
|
if (frame === undefined) throw new Error('Missing readable Experience sign frame');
|
|
expect(frame.corridor).toBe('right');
|
|
expect(frame.bounds.left).toBeGreaterThan(720);
|
|
const panels = await page.locator('#profile, #experience').evaluateAll((elements) => (
|
|
elements.map((element) => {
|
|
const bounds = element.getBoundingClientRect();
|
|
return {
|
|
id: element.id,
|
|
left: bounds.left,
|
|
top: bounds.top,
|
|
right: bounds.right,
|
|
bottom: bounds.bottom,
|
|
};
|
|
}).filter((bounds) => bounds.bottom > 0 && bounds.top < window.innerHeight)
|
|
));
|
|
expect(panels.map(({ id }) => id)).toEqual(['profile', 'experience']);
|
|
for (const panel of panels) {
|
|
expect(rectanglesOverlap(frame.bounds, panel, 16), panel.id).toBe(false);
|
|
}
|
|
});
|
|
|
|
test('keeps the short-landscape Experience sign in the right roadside gap', async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
test.skip(testInfo.project.name !== 'desktop-chromium', 'One exact short-landscape composition is sufficient.');
|
|
await page.setViewportSize({ width: 844, height: 390 });
|
|
await page.goto(`${distUrl}/#profile`, { waitUntil: 'domcontentloaded' });
|
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
|
await expect(page.locator('nav a[href="#profile"]')).toHaveAttribute('aria-current', 'page');
|
|
const destination = await page.evaluate(() => {
|
|
const heading = document.getElementById('experience-heading');
|
|
if (!(heading instanceof HTMLElement)) throw new Error('Missing Experience heading');
|
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
|
return Math.max(
|
|
1,
|
|
heading.getBoundingClientRect().top + window.scrollY
|
|
- viewportHeight * (0.6 + 0.38),
|
|
);
|
|
});
|
|
await page.mouse.wheel(0, 1);
|
|
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
|
await expect.poll(async () => (
|
|
await readSceneInstrumentation(page)
|
|
).signFrames.findLast((frame) => frame.targetId === 'experience')?.phase, {
|
|
intervals: [16],
|
|
timeout: 5_000,
|
|
}).toBe('dwell');
|
|
|
|
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
|
|
candidate.targetId === 'experience' && candidate.phase === 'dwell'
|
|
));
|
|
if (frame === undefined) throw new Error('Missing readable short-landscape Experience sign frame');
|
|
expect(frame.corridor).toBe('right');
|
|
expect(frame.viewportProfile).toBe('short');
|
|
expect(frame.bounds.left).toBeGreaterThan(422);
|
|
expect(frame.bounds.right).toBeLessThanOrEqual(836);
|
|
expect(frame.bounds.top).toBeGreaterThanOrEqual(210);
|
|
expect(frame.bounds.bottom).toBeLessThanOrEqual(300);
|
|
expect(frame.bounds.width).toBeGreaterThanOrEqual(120);
|
|
|
|
const occupied = await page.locator('#profile, #experience, .site-navigation, .drive-controls, .journey-hud')
|
|
.evaluateAll((elements) => elements.map((element) => {
|
|
const bounds = element.getBoundingClientRect();
|
|
return {
|
|
id: element.id || element.className,
|
|
fixed: element.matches('.site-navigation, .drive-controls, .journey-hud'),
|
|
visible: bounds.bottom > 0 && bounds.top < window.innerHeight,
|
|
bounds: {
|
|
left: bounds.left,
|
|
top: bounds.top,
|
|
right: bounds.right,
|
|
bottom: bounds.bottom,
|
|
},
|
|
};
|
|
}).filter(({ visible }) => visible));
|
|
for (const surface of occupied) {
|
|
expect(
|
|
rectanglesOverlap(frame.bounds, surface.bounds, surface.fixed ? 8 : 16),
|
|
String(surface.id),
|
|
).toBe(false);
|
|
}
|
|
expect(frame.texture.fontReadyWhenDrawn).toBe(true);
|
|
});
|
|
|
|
test('keeps every passive transition sign clear of visible opaque panels and fixed controls', async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
test.skip(testInfo.project.name !== 'desktop-chromium', 'One exact responsive transition matrix is sufficient.');
|
|
test.setTimeout(180_000);
|
|
|
|
for (const viewport of [
|
|
{ width: 1_440, height: 900 },
|
|
{ width: 390, height: 844 },
|
|
{ width: 844, height: 390 },
|
|
] as const) {
|
|
await page.setViewportSize(viewport);
|
|
for (let index = 1; index < sections.length; index += 1) {
|
|
const previous = sections[index - 1];
|
|
const next = sections[index];
|
|
if (previous === undefined || next === undefined) throw new Error('Incomplete route transition');
|
|
const startUrl = previous.id === 'start' ? distUrl : `${distUrl}/#${previous.id}`;
|
|
await page.goto(startUrl, { waitUntil: 'domcontentloaded' });
|
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
|
await expect(page.locator(`nav a[href="#${previous.id}"]`)).toHaveAttribute('aria-current', 'page');
|
|
const transition = `${viewport.width}x${viewport.height} ${previous.id} -> ${next.id}`;
|
|
|
|
const destination = await page.evaluate(({ targetId, candidateDistanceVh }) => {
|
|
const heading = document.getElementById(`${targetId}-heading`);
|
|
if (!(heading instanceof HTMLElement)) throw new Error(`Missing ${targetId} heading`);
|
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
|
return Math.max(
|
|
1,
|
|
heading.getBoundingClientRect().top + window.scrollY
|
|
- viewportHeight * (0.6 + candidateDistanceVh),
|
|
);
|
|
}, {
|
|
targetId: next.id,
|
|
candidateDistanceVh: viewport.width < viewport.height
|
|
? 0.225
|
|
: viewport.height < 560 ? 0.38 : 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.findLast((frame) => frame.targetId === next.id)?.phase, {
|
|
intervals: [16],
|
|
message: `${transition} should reach readable dwell`,
|
|
timeout: 5_000,
|
|
}).toBe('dwell');
|
|
|
|
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
|
|
candidate.targetId === next.id && candidate.phase === 'dwell'
|
|
));
|
|
if (frame === undefined) throw new Error(`Missing readable ${next.id} sign frame`);
|
|
const occupied = await page.evaluate(() => {
|
|
const alpha = (color: string): number => {
|
|
const match = color.match(/rgba?\([^)]*(?:,|\s\/\s)([\d.]+)\)$/u);
|
|
return match?.[1] === undefined ? 1 : Number.parseFloat(match[1]);
|
|
};
|
|
const boundsFor = (element: Element) => {
|
|
const bounds = element.getBoundingClientRect();
|
|
return {
|
|
id: element.id || element.closest('[id]')?.id || element.className,
|
|
left: bounds.left,
|
|
top: bounds.top,
|
|
right: bounds.right,
|
|
bottom: bounds.bottom,
|
|
};
|
|
};
|
|
const panels = [...document.querySelectorAll(
|
|
[
|
|
'.hero-journey__content',
|
|
'.portfolio-section:not([data-layout="full-diagram"]):not([data-layout="project-stage"])',
|
|
'.chapter-body',
|
|
'.portfolio-section[data-layout="full-diagram"] > h2',
|
|
'.infrastructure-case > h3',
|
|
'.infrastructure-case > p',
|
|
'.infrastructure-flow-figure',
|
|
'.portfolio-section[data-layout="project-stage"] > h2',
|
|
'.portfolio-section[data-layout="project-stage"] > .section-eyebrow',
|
|
'.project-case-study',
|
|
'.project-card',
|
|
].join(', '),
|
|
)].filter((element) => {
|
|
const bounds = element.getBoundingClientRect();
|
|
const style = getComputedStyle(element);
|
|
const painted = style.backgroundImage !== 'none' || alpha(style.backgroundColor) > 0.08;
|
|
return painted && bounds.bottom > 0 && bounds.top < window.innerHeight;
|
|
}).map(boundsFor);
|
|
const fixed = [...document.querySelectorAll(
|
|
'.site-navigation, .drive-controls, .journey-hud',
|
|
)].map(boundsFor);
|
|
return { panels, fixed };
|
|
});
|
|
const panelOverlaps = occupied.panels
|
|
.filter((panel) => rectanglesOverlap(frame.bounds, panel, 16))
|
|
.map((panel) => ({ panel, sign: frame.bounds }));
|
|
const fixedOverlaps = occupied.fixed
|
|
.filter((zone) => rectanglesOverlap(frame.bounds, zone, 8));
|
|
expect(frame.bounds.left, `${next.id} left edge`).toBeGreaterThanOrEqual(8);
|
|
expect(frame.bounds.right, `${next.id} right edge`).toBeLessThanOrEqual(viewport.width - 8);
|
|
expect(frame.bounds.top, `${next.id} top edge`).toBeGreaterThanOrEqual(8);
|
|
expect(frame.bounds.bottom, `${next.id} bottom edge`).toBeLessThanOrEqual(viewport.height - 8);
|
|
expect(panelOverlaps, transition).toEqual([]);
|
|
expect(fixedOverlaps, transition).toEqual([]);
|
|
}
|
|
}
|
|
});
|
|
|
|
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
|
|
);
|
|
}
|
|
|
|
function countSignPresentations(
|
|
frames: ReadonlyArray<{ targetId: string; phase: string }>,
|
|
targetId: string,
|
|
): number {
|
|
let presentations = 0;
|
|
let previousPhase: string | null = null;
|
|
for (const frame of frames) {
|
|
if (frame.targetId !== targetId) continue;
|
|
if (frame.phase === 'anticipate' && previousPhase !== 'anticipate') presentations += 1;
|
|
previousPhase = frame.phase;
|
|
}
|
|
return presentations;
|
|
}
|
|
|
|
function triggerProbeDistanceVh(width: number, height: number): number {
|
|
const aspect = width / height;
|
|
if (height < 560 && aspect >= 1) return 0.37;
|
|
if (aspect < 1.05) return 0.22;
|
|
return 0.45;
|
|
}
|