fix: close phase 2 journey blockers
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user