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

@@ -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);