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 => ({ 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 => ({ 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.findAll('.journey-poster-composition > span')).toHaveLength(7); expect(wrapper.find('.journey-poster__sun').exists()).toBe(true); expect(wrapper.find('.journey-poster__road').exists()).toBe(true); expect(wrapper.find('.journey-poster__car').exists()).toBe(true); 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(); 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: '

Semantic portfolio

' }, }); 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'); }); });