fix: close phase 2 journey blockers

This commit is contained in:
2026-09-02 06:04:20 +03:00
parent a86767cdc6
commit 77895c01ff
19 changed files with 378 additions and 26 deletions

View File

@@ -41,7 +41,7 @@ test('persisted lifecycle events preserve paused and Static states without dupli
await page.getByRole('button', { name: 'Pause motion' }).click();
await dispatchPersistedPageTransition(page, 'pagehide');
await dispatchPersistedPageTransition(page, 'pageshow');
await expect(page.locator('.journey-canvas')).toHaveCount(0);
await expect(page.locator('.journey-canvas')).toHaveCount(1);
await expect(page.getByRole('button', { name: 'Resume motion' })).toBeVisible();
await page.getByLabel('Quality control').selectOption('static');

View File

@@ -10,6 +10,37 @@ test('keeps the decorative world separate from the semantic reading path', async
await expect(page.locator('main#portfolio-content')).toBeVisible();
});
test('keeps the desktop identity and navigation in a paint layer above the world', async ({ page, distUrl }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
const layers = await page.evaluate(() => {
const world = document.querySelector<HTMLElement>('.journey-world');
const header = document.querySelector<HTMLElement>('.site-header');
const heading = document.querySelector<HTMLElement>('.site-header h1');
const navigation = document.querySelector<HTMLElement>('.site-navigation');
if (!world || !header || !heading || !navigation) throw new Error('Missing top-screen structure');
const worldStyle = getComputedStyle(world);
const headerStyle = getComputedStyle(header);
return {
worldZ: Number(worldStyle.zIndex),
headerZ: Number(headerStyle.zIndex),
headerPosition: headerStyle.position,
headingVisible: heading.getBoundingClientRect().width > 0 && heading.getBoundingClientRect().height > 0,
navigationVisible: navigation.getBoundingClientRect().width > 0 && navigation.getBoundingClientRect().height > 0,
headerOpacity: Number.parseFloat(headerStyle.backgroundColor.match(/[\d.]+(?=\))/gu)?.at(-1) ?? '1'),
};
});
expect(layers).toMatchObject({
headerPosition: 'relative',
headingVisible: true,
navigationVisible: true,
});
expect(layers.headerZ).toBeGreaterThan(layers.worldZ);
expect(layers.headerOpacity).toBeGreaterThanOrEqual(0.94);
});
test('provides native motion and quality controls with visible state', async ({ page, distUrl }) => {
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
@@ -17,13 +48,21 @@ test('provides native motion and quality controls with visible state', async ({
await expect(quality).toBeVisible();
await expect(page.getByRole('button', { name: 'Pause motion' })).toBeVisible();
await expect(page.getByRole('status')).toContainText('Motion:');
const canvas = page.locator('.journey-canvas');
const originalCanvas = await canvas.elementHandle();
const originalGeneration = await page.locator('.journey-world').getAttribute('data-scene-generation');
await page.getByRole('button', { name: 'Pause motion' }).click();
await expect(page.getByRole('button', { name: 'Resume motion' })).toBeVisible();
await expect(page.locator('.journey-canvas')).toHaveCount(0);
await expect(canvas).toHaveCount(1);
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-generation', originalGeneration ?? '');
expect(await originalCanvas?.evaluate((element) => element.isConnected)).toBe(true);
await page.getByRole('button', { name: 'Resume motion' }).click();
await expect(page.getByRole('button', { name: 'Pause motion' })).toBeVisible();
await expect(canvas).toHaveCount(1);
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-generation', originalGeneration ?? '');
expect(await originalCanvas?.evaluate((element) => element.isConnected)).toBe(true);
await quality.selectOption('static');
await expect(page.getByRole('button', { name: 'Enable motion' })).toBeVisible();

View File

@@ -38,7 +38,10 @@ const policy = (overrides: Partial<MotionPolicy> = {}): MotionPolicy => ({
describe('SceneCanvas', () => {
beforeEach(() => sceneMocks.build.mockClear());
afterEach(() => vi.restoreAllMocks());
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it.each([
['reduced motion', policy({ allowSceneRequest: false, maximumTier: 'static', reason: 'system-reduce' })],
@@ -81,6 +84,36 @@ describe('SceneCanvas', () => {
wrapper.unmount();
});
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)));
const sampleFrame = vi.fn();
const wrapper = mount(SceneCanvas, {
props: {
journey: journey(),
policy: policy(),
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);
frame[1](2_000);
expect(sampleFrame).toHaveBeenCalledWith(2_000, 2_301);
wrapper.unmount();
});
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, {
@@ -123,6 +156,38 @@ describe('SceneCanvas', () => {
expect(wrapper.get('img').isVisible()).toBe(true);
wrapper.unmount();
});
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.',
},
});
await flushPromises();
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',
});
await flushPromises();
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(runtime.dispose).not.toHaveBeenCalled();
expect(sceneMocks.build).toHaveBeenCalledTimes(1);
wrapper.unmount();
});
});
describe('DriveControls', () => {

View File

@@ -7,6 +7,7 @@ import {
type AdaptiveQualityController,
type QualityDecision,
} from '@/journey/adaptive-quality';
import type { QualityTier } from '@/journey/motion-policy';
afterEach(() => {
vi.restoreAllMocks();
@@ -265,6 +266,26 @@ describe('useAdaptiveQuality', () => {
expect(wrapper.vm.snapshot.epoch).toBeNull();
expect(documentRemove).toHaveBeenCalledWith('visibilitychange', expect.any(Function), { passive: true });
});
it('never lets a visitor request bypass the device-resolved maximum', async () => {
const { useAdaptiveQuality } = await import('@/composables/useAdaptiveQuality');
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
});
const maximum = ref<QualityTier>('low');
const sceneState = ref('running');
const pageLifecycle = ref<'active'>('active');
const wrapper = mount(defineComponent({
setup: () => useAdaptiveQuality({ maximum, sceneState, pageLifecycle, now: () => 0 }),
template: '<output />',
}));
expect(wrapper.vm.setMaximum('high', 'manual')).toBeNull();
expect(wrapper.vm.snapshot).toMatchObject({ maximum: 'low', actual: 'low' });
wrapper.unmount();
});
});
function feedWindow(

View File

@@ -89,6 +89,51 @@ describe('scene controller lifecycle', () => {
expect(harness.generations[0]?.render).toHaveBeenCalledOnce();
});
it('samples the complete scene update and render interval', async () => {
const harness = createHarness();
const renderedFrames: Array<{ start: number; end: number }> = [];
const controller = createSceneController({
...harness.options,
onFrameRendered: (start, end) => renderedFrames.push({ start, end }),
});
await controller.start();
harness.generations[0]?.render.mockImplementation(() => {
harness.clock.now += 300;
});
harness.scheduler.flushFrame(2_000);
expect(renderedFrames).toEqual([{ start: 2_000, end: 2_300 }]);
});
it('retains the same complete generation and frame across a visitor pause', async () => {
const harness = createHarness();
const controller = createSceneController(harness.options);
await controller.start();
const running = controller.snapshot();
controller.pause();
expect(controller.snapshot()).toMatchObject({
state: { kind: 'paused', generation: running.currentGeneration, resumeNeedsLoad: false },
currentGeneration: running.currentGeneration,
canvasCount: 1,
rafCount: 0,
});
expect(harness.generations).toHaveLength(1);
expect(harness.generations[0]?.dispose).not.toHaveBeenCalled();
await controller.resume();
expect(controller.snapshot()).toMatchObject({
state: { kind: 'running', generation: running.currentGeneration },
currentGeneration: running.currentGeneration,
canvasCount: 1,
rafCount: 1,
});
expect(harness.generations).toHaveLength(1);
});
it('copies serializable journey snapshots instead of retaining caller objects', async () => {
const harness = createHarness();
const controller = createSceneController(harness.options);
@@ -266,7 +311,15 @@ describe('scene controller lifecycle', () => {
await controller.whenSettled();
expect(controller.snapshot().overlay).toBe('active');
if (source === 'running' || source === 'paused') expect(controller.snapshot().state.kind).toBe('running');
if (source === 'running') expect(controller.snapshot().state.kind).toBe('running');
if (source === 'paused') {
expect(controller.snapshot()).toMatchObject({
state: { kind: 'paused', generation: 1, resumeNeedsLoad: false },
currentGeneration: 1,
rafCount: 0,
});
expect(harness.generations[0]?.dispose).not.toHaveBeenCalled();
}
if (source === 'loading') expect(harness.buildContexts.length).toBe(beforeShow + 1);
if (source === 'static') expect(controller.snapshot().state.kind).toBe('static');
if (start && source === 'loading') {

View File

@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from 'vitest';
import * as THREE from 'three';
import { SceneResourceBag } from '@/scene/resources';
import { renderQualityFor } from '@/scene/render-quality';
import { createChaseCamera } from '@/scene/world/environment';
import { createCar } from '@/scene/world/car';
import { routeLabelFor } from '@/scene/world/sign';
describe('SceneResourceBag', () => {
@@ -109,4 +112,48 @@ describe('scene render quality', () => {
expect(routeLabelFor('infrastructure')).toBe('03 / INFRASTRUCTURE');
expect(routeLabelFor('contact')).toBe('07 / DESTINATION');
});
it('keeps the chase car recognizably wide and fully inside the desktop view', () => {
const resources = new SceneResourceBag();
const camera = createChaseCamera(16 / 9);
const car = createCar(resources);
car.group.updateMatrixWorld(true);
camera.updateMatrixWorld(true);
const bounds = new THREE.Box3().setFromObject(car.group);
const projected = boxCorners(bounds).map((point) => point.project(camera));
const minX = Math.min(...projected.map((point) => point.x));
const maxX = Math.max(...projected.map((point) => point.x));
const minY = Math.min(...projected.map((point) => point.y));
const maxY = Math.max(...projected.map((point) => point.y));
expect(maxX - minX).toBeGreaterThan(0.22);
expect(minY).toBeGreaterThan(-0.98);
expect(maxY).toBeLessThan(-0.15);
const silhouette = car.group.getObjectByName('rear-silhouette');
expect(silhouette).toBeDefined();
const silhouetteBounds = new THREE.Box3().setFromObject(silhouette);
expect(silhouetteBounds.max.x - silhouetteBounds.min.x).toBeGreaterThan(4);
expect(Math.max(
silhouette.material.color.r,
silhouette.material.color.g,
silhouette.material.color.b,
)).toBeGreaterThan(0.12);
resources.dispose();
});
});
function boxCorners(bounds: {
min: { x: number; y: number; z: number };
max: { x: number; y: number; z: number };
}) {
return [
new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.min.z),
new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.max.z),
new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.min.z),
new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.max.z),
new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.min.z),
new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.max.z),
new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.min.z),
new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.max.z),
];
}

View File

@@ -247,6 +247,33 @@ describe('useScrollDrive', () => {
acceleration: expect.closeTo(0.6708070121920944, 14),
driveSpeed: expect.closeTo(0.7300617499975173, 14),
});
expect(callbacks).toHaveLength(1);
const decayingAccelerations: number[] = [];
let frameNow = 216;
while (callbacks.size > 0 && frameNow < 10_000) {
expect(callbacks).toHaveLength(1);
const decayFrame = [...callbacks.entries()][0]!;
callbacks.delete(decayFrame[0]);
decayFrame[1](frameNow);
await nextTick();
decayingAccelerations.push(drive().acceleration);
frameNow += 16;
}
expect(decayingAccelerations.length).toBeGreaterThan(1);
expect(decayingAccelerations.every((value, index) => (
index === 0 || value <= decayingAccelerations[index - 1]!
))).toBe(true);
expect(decayingAccelerations.at(-1)).toBe(0);
expect(drive()).toMatchObject({
scrollY: 750,
routeProgress: 0.75,
direction: 0,
acceleration: 0,
driveSpeed: 0.18,
});
expect(callbacks).toHaveLength(0);
window.dispatchEvent(new Event('scroll'));
expect(callbacks).toHaveLength(1);