feat: integrate accessible journey world and controls
This commit is contained in:
168
tests/unit/SceneCanvas.test.ts
Normal file
168
tests/unit/SceneCanvas.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
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 { 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());
|
||||
|
||||
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('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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DriveControls', () => {
|
||||
const mountControls = (motionPolicy: MotionPolicy, maximum: QualityTier = 'high') => mount(DriveControls, {
|
||||
props: { policy: motionPolicy, maximumQuality: maximum, sceneStatus: 'running' },
|
||||
});
|
||||
|
||||
it('uses exact native control names and a visible non-color status', async () => {
|
||||
const wrapper = mountControls(policy());
|
||||
|
||||
expect(wrapper.get('button').text()).toBe('Pause motion');
|
||||
expect(wrapper.get('select').attributes('aria-label')).toBe('Quality control');
|
||||
expect(wrapper.text()).toContain('Motion: active');
|
||||
await wrapper.get('button').trigger('click');
|
||||
expect(wrapper.emitted('pause')).toHaveLength(1);
|
||||
});
|
||||
|
||||
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 }));
|
||||
expect(wrapper.get('button').text()).toBe(label);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyHud', () => {
|
||||
it('renders route percent, current section, quality and arrival from one snapshot', () => {
|
||||
const wrapper = mount(JourneyHud, {
|
||||
props: {
|
||||
journey: journey({ routeProgress: 1, activeSectionId: 'contact', arrived: true }),
|
||||
sectionLabel: 'Contact',
|
||||
quality: 'low',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('100%');
|
||||
expect(wrapper.text()).toContain('Contact');
|
||||
expect(wrapper.text()).toContain('Low');
|
||||
expect(wrapper.text()).toContain('ARRIVED');
|
||||
expect(wrapper.attributes('aria-hidden')).toBe('true');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user