Files
Portfolio/tests/unit/SceneCanvas.test.ts

277 lines
9.8 KiB
TypeScript

import { mount, flushPromises } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { nextTick } from 'vue';
import SceneCanvas from '@/components/SceneCanvas.vue';
import DriveControls from '@/components/DriveControls.vue';
import JourneyHud from '@/components/JourneyHud.vue';
import type { AudioUiState } from '@/audio/types';
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
import type { JourneySnapshot } from '@/journey/types';
const sceneMocks = vi.hoisted(() => ({
build: vi.fn(async () => ({ render: vi.fn(), dispose: vi.fn() })),
}));
vi.mock('@/scene/world/environment', () => ({
createProceduralSceneGeneration: sceneMocks.build,
}));
const journey = (overrides: Partial<JourneySnapshot> = {}): JourneySnapshot => ({
scrollY: 0,
routeProgress: 0.42,
direction: 1,
acceleration: 0.3,
driveSpeed: 0.6,
activeSectionId: 'infrastructure',
chapter: 'infrastructure',
sign: null,
arrived: false,
...overrides,
});
const policy = (overrides: Partial<MotionPolicy> = {}): MotionPolicy => ({
allowSceneRequest: true,
allowAutomaticVideo: true,
maximumTier: 'high',
reason: 'full',
...overrides,
});
describe('SceneCanvas', () => {
beforeEach(() => sceneMocks.build.mockClear());
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it.each([
['reduced motion', policy({ allowSceneRequest: false, maximumTier: 'static', reason: 'system-reduce' })],
['saved Static mode', policy({ allowSceneRequest: false, maximumTier: 'static', reason: 'saved-static' })],
])('does not request or render the scene for %s', async (_name, blockedPolicy) => {
const wrapper = mount(SceneCanvas, {
props: {
journey: journey(),
policy: blockedPolicy,
quality: 'static',
sceneDescription: 'A decorative night road behind the portfolio content.',
},
});
await flushPromises();
expect(sceneMocks.build).not.toHaveBeenCalled();
expect(wrapper.findAll('canvas')).toHaveLength(0);
expect(wrapper.get('img').attributes()).toMatchObject({ alt: '', width: '1600', height: '900' });
expect(wrapper.text()).toContain('A decorative night road behind the portfolio content.');
wrapper.unmount();
});
it('owns exactly one decorative canvas when full motion is allowed', async () => {
const wrapper = mount(SceneCanvas, {
props: {
journey: journey(),
policy: policy(),
quality: 'high',
sceneDescription: 'A decorative night road behind the portfolio content.',
},
});
await flushPromises();
expect(sceneMocks.build).toHaveBeenCalledTimes(1);
expect(wrapper.findAll('canvas')).toHaveLength(1);
expect(wrapper.get('canvas').attributes('aria-hidden')).toBe('true');
expect(wrapper.get('.journey-world').attributes('aria-hidden')).toBe('true');
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, {
props: {
journey: journey(),
policy: policy(),
quality: 'medium',
sceneDescription: 'A decorative night road behind the portfolio content.',
},
slots: { default: '<main><h1>Semantic portfolio</h1></main>' },
});
await flushPromises();
expect(wrapper.get('img').isVisible()).toBe(true);
expect(wrapper.findAll('canvas')).toHaveLength(0);
expect(wrapper.text()).toContain('Semantic portfolio');
wrapper.unmount();
});
it('aborts and removes the canvas on a Static transition', async () => {
const wrapper = mount(SceneCanvas, {
props: {
journey: journey(),
policy: policy(),
quality: 'high',
sceneDescription: 'A decorative night road behind the portfolio content.',
},
});
await flushPromises();
expect(wrapper.findAll('canvas')).toHaveLength(1);
await wrapper.setProps({
policy: policy({ allowSceneRequest: false, maximumTier: 'static', reason: 'saved-static' }),
quality: 'static',
});
await nextTick();
expect(wrapper.findAll('canvas')).toHaveLength(0);
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', () => {
const mountControls = (
motionPolicy: MotionPolicy,
maximum: QualityTier = 'high',
audioState: AudioUiState = { kind: 'idle' },
) => mount(DriveControls, {
attachTo: document.body,
props: { policy: motionPolicy, maximumQuality: maximum, sceneStatus: 'running', audioState },
});
it('presents one explicit drive action before audio activation', async () => {
const wrapper = mountControls(policy());
expect(wrapper.get('[data-drive-state="idle"]').text()).toContain('START THE DRIVE');
expect(wrapper.find('[aria-label="Mute sound"]').exists()).toBe(false);
await wrapper.get('[aria-label="Start the drive"]').trigger('click');
expect(wrapper.emitted('audioStart')).toHaveLength(1);
wrapper.unmount();
});
it('collapses the running state to named vehicle controls and an upward settings panel', async () => {
const wrapper = mountControls(policy(), 'high', { kind: 'running', muted: false, volume: 0.4 });
expect(wrapper.get('[aria-label="Mute sound"]')).toBeTruthy();
expect(wrapper.get('[aria-label="Sound volume"]')).toBeTruthy();
expect(wrapper.get('[aria-label="Pause motion"]')).toBeTruthy();
const settings = wrapper.get('[aria-label="Drive settings"]');
await settings.trigger('click');
const panel = wrapper.get('[role="dialog"][aria-label="Drive settings"]');
expect(panel.text()).not.toContain('Motion: active');
expect(panel.find('[role="status"]').exists()).toBe(false);
expect(panel.get('select').attributes('aria-label')).toBe('Quality control');
expect(settings.attributes('aria-expanded')).toBe('true');
wrapper.unmount();
});
it('dismisses settings with Escape and outside interaction', async () => {
const wrapper = mountControls(policy(), 'high', { kind: 'running', muted: false, volume: 0.4 });
const settings = wrapper.get('[aria-label="Drive settings"]');
await settings.trigger('click');
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await nextTick();
expect(wrapper.find('[role="dialog"][aria-label="Drive settings"]').exists()).toBe(false);
expect(document.activeElement).toBe(settings.element);
await settings.trigger('click');
document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
await nextTick();
expect(wrapper.find('[role="dialog"][aria-label="Drive settings"]').exists()).toBe(false);
wrapper.unmount();
});
it.each([
['visitor-pause', 'Resume motion'],
['saved-static', 'Enable motion'],
] as const)('offers the correct recovery action for %s', (reason, label) => {
const wrapper = mountControls(
policy({ allowSceneRequest: false, maximumTier: 'static', reason }),
'static',
{ kind: 'running', muted: false, volume: 0.4 },
);
expect(wrapper.get(`[aria-label="${label}"]`)).toBeTruthy();
wrapper.unmount();
});
});
describe('JourneyHud', () => {
it('renders only route percent, current section, and arrival from one snapshot', () => {
const wrapper = mount(JourneyHud, {
props: {
journey: journey({ routeProgress: 1, activeSectionId: 'contact', arrived: true }),
sectionLabel: 'Contact',
},
});
expect(wrapper.text()).toContain('100%');
expect(wrapper.text()).toContain('Contact');
expect(wrapper.text()).toContain('ARRIVED');
expect(wrapper.text()).not.toContain('Low');
expect(wrapper.attributes('aria-hidden')).toBe('true');
});
});