/* @vitest-environment jsdom */ import { act, fireEvent, render, screen } from '@testing-library/react'; import type { ReactElement } from 'react'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { ChildMotionWarmupDemo } from './ChildMotionWarmupDemo'; import { markChildMotionWarmupCompletedInRuntime, resetChildMotionWarmupRuntimeSession, } from './childMotionWarmupModel'; const mocapMock = vi.hoisted(() => ({ status: 'connected' as 'idle' | 'connecting' | 'connected' | 'error', command: null as null | { actions: string[]; hands?: Array<{ x: number; y: number; state: string; side: string; wrist?: { x: number; y: number } | null; }>; primaryHand?: { x: number; y: number; state: string; side: string; wrist?: { x: number; y: number } | null; } | null; leftHand?: { x: number; y: number; state: string; side: string; wrist?: { x: number; y: number } | null; } | null; rightHand?: { x: number; y: number; state: string; side: string; wrist?: { x: number; y: number } | null; } | null; bodyCenter?: { x: number; y: number } | null; bodyJoints?: { leftShoulder?: { x: number; y: number } | null; rightShoulder?: { x: number; y: number } | null; leftElbow?: { x: number; y: number } | null; rightElbow?: { x: number; y: number } | null; }; }, receivedAtMs: 1, })); vi.mock('../../services/useMocapInput', () => ({ useMocapInput: ({ enabled }: { enabled: boolean }) => ({ status: enabled ? mocapMock.status : 'idle', latestCommand: enabled ? mocapMock.command : null, rawPacketPreview: enabled && mocapMock.command ? { text: JSON.stringify(mocapMock.command), receivedAtMs: mocapMock.receivedAtMs, } : null, error: null, }), })); vi.mock('../ResolvedAssetImage', () => ({ ResolvedAssetImage: ({ src, alt, className, }: { src?: string | null; alt?: string; className?: string; }) => (src ? {alt} : null), })); beforeEach(() => { resetChildMotionWarmupRuntimeSession(); vi.restoreAllMocks(); mocapMock.status = 'connected'; mocapMock.command = null; mocapMock.receivedAtMs = 1; Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: undefined, }); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); function setMocapBodyCenter(x: number) { mocapMock.command = { actions: [], bodyCenter: { x, y: 0.6 }, hands: [], primaryHand: null, leftHand: null, rightHand: null, }; mocapMock.receivedAtMs += 1; } async function advanceWarmupTime(ms: number) { await act(async () => { vi.advanceTimersByTime(ms); }); } async function revealCurrentStepCue() { await advanceWarmupTime(1100); } async function completeCurrentPositionStepByHold() { await advanceWarmupTime(2200); await advanceWarmupTime(900); } async function completeCurrentNarrationStep() { await revealCurrentStepCue(); await advanceWarmupTime(1000); await advanceWarmupTime(900); } async function sendMocapLeftHandTrack( rerender: (ui: ReactElement) => void, points: number[], options: { raised?: boolean } = {}, ) { for (const x of points) { const y = options.raised ? 0.34 : 0.72; const wrist = { x, y }; mocapMock.command = { actions: [], bodyCenter: { x: 0.5, y: 0.7 }, bodyJoints: { leftShoulder: { x: 0.4, y: 0.42 }, leftElbow: { x: 0.36, y: 0.5 }, }, hands: [{ x, y, state: 'unknown', side: 'left', wrist }], primaryHand: { x, y, state: 'unknown', side: 'left', wrist }, leftHand: { x, y, state: 'unknown', side: 'left', wrist }, rightHand: null, }; mocapMock.receivedAtMs += 1; await act(async () => { rerender(); }); } } function setMocapCameraHandTrackPoint({ cameraSide, x, y, }: { cameraSide: 'left' | 'right'; x: number; y: number; }) { const wrist = { x, y }; const hand = { x, y, state: 'unknown', side: cameraSide, wrist }; const command = { actions: [], bodyCenter: { x: 0.5, y: 0.7 }, bodyJoints: { leftShoulder: { x: 0.62, y: 0.48 }, leftElbow: { x: 0.7, y: 0.5 }, rightShoulder: { x: 0.38, y: 0.48 }, rightElbow: { x: 0.3, y: 0.5 }, }, hands: [hand], primaryHand: hand, leftHand: null as null | typeof hand, rightHand: null as null | typeof hand, }; if (cameraSide === 'left') { command.leftHand = hand; } else { command.rightHand = hand; } mocapMock.command = command; mocapMock.receivedAtMs += 1; } async function sendMocapCameraHandTrack( rerender: (ui: ReactElement) => void, cameraSide: 'left' | 'right', points: Array<{ x: number; y: number }>, ) { for (const point of points) { setMocapCameraHandTrackPoint({ cameraSide, ...point }); await act(async () => { rerender(); }); } } async function sendPlayerLeftArmSwingTrack( rerender: (ui: ReactElement) => void, ) { await sendMocapCameraHandTrack(rerender, 'right', [ { x: 0.2, y: 0.5 }, { x: 0.16, y: 0.42 }, { x: 0.13, y: 0.34 }, { x: 0.15, y: 0.43 }, { x: 0.19, y: 0.51 }, ]); } async function sendPlayerRightArmSwingTrack( rerender: (ui: ReactElement) => void, ) { await sendMocapCameraHandTrack(rerender, 'left', [ { x: 0.8, y: 0.5 }, { x: 0.84, y: 0.42 }, { x: 0.87, y: 0.34 }, { x: 0.85, y: 0.43 }, { x: 0.81, y: 0.51 }, ]); } async function completeGreetingByWaveTrack( rerender: (ui: ReactElement) => void, ) { await sendMocapLeftHandTrack(rerender, [0.42, 0.51, 0.58, 0.49, 0.43], { raised: true, }); } test('renders the warmup stage and starts with the center ring step', () => { render(); expect(screen.getByTestId('child-motion-demo')).toBeTruthy(); expect(screen.getByText('来到圆圈这里')).toBeTruthy(); expect(screen.queryByLabelText('绿色圆环')).toBeNull(); expect(screen.getByText('请横屏体验')).toBeTruthy(); }); test('shows narration first before revealing the step cue', async () => { vi.useFakeTimers(); render(); expect(screen.getByText('来到圆圈这里')).toBeTruthy(); expect(screen.queryByLabelText('绿色圆环')).toBeNull(); expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('intro'); await advanceWarmupTime(1000); expect(screen.getByLabelText('绿色圆环')).toBeTruthy(); expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('active'); }); test('re-entering within the same runtime session opens the start button', () => { markChildMotionWarmupCompletedInRuntime(); render(); expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy(); }); test('start button opens the baby object match level', () => { markChildMotionWarmupCompletedInRuntime(); render(); fireEvent.click(screen.getByRole('button', { name: '开始游戏' })); expect(screen.getByTestId('baby-object-match-runtime')).toBeTruthy(); expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy(); expect(screen.queryByText('下一关正在设计中')).toBeNull(); }); test('developer keyboard input moves the avatar and triggers jump state', () => { render(); const avatar = screen.getByTestId('child-motion-avatar'); fireEvent.keyDown(window, { key: 'a', code: 'KeyA' }); expect(avatar.getAttribute('style')).toContain('left: 34%'); fireEvent.keyDown(window, { key: 'd', code: 'KeyD' }); expect(avatar.getAttribute('style')).toContain('left: 66%'); fireEvent.keyUp(window, { key: 'd', code: 'KeyD' }); expect(avatar.getAttribute('style')).toContain('left: 50%'); fireEvent.keyDown(window, { key: ' ', code: 'Space' }); expect(avatar.className).toContain('child-motion-avatar--jumping'); }); test('mocap body center dampens small jitter before moving the avatar', async () => { setMocapBodyCenter(0.5); const { rerender } = render(); expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain( 'left: 50%', ); setMocapBodyCenter(0.508); await act(async () => { rerender(); }); expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain( 'left: 50%', ); setMocapBodyCenter(0.34); await act(async () => { rerender(); }); const style = screen.getByTestId('child-motion-avatar').getAttribute('style'); expect(style).toContain('left: 46.5%'); expect(style).not.toContain('left: 34%'); }); test('mocap body center keeps the warmup flow on the motion data source', async () => { vi.useFakeTimers(); setMocapBodyCenter(0.5); const { rerender, unmount } = render(); expect(screen.queryByText('摄像头暂不可用,已切换到本地演示')).toBeNull(); expect(screen.queryByText('动作数据已连接,等待识别')).toBeNull(); expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain( 'left: 50%', ); await revealCurrentStepCue(); await completeCurrentPositionStepByHold(); await vi.waitFor(() => { expect(screen.getByText('打个招呼')).toBeTruthy(); }); await revealCurrentStepCue(); await completeGreetingByWaveTrack(rerender); await advanceWarmupTime(900); await vi.waitFor(() => { expect(screen.getByText('准备热身')).toBeTruthy(); }); await completeCurrentNarrationStep(); await vi.waitFor(() => { expect(screen.getByRole('heading', { name: '向左一步' })).toBeTruthy(); }); await revealCurrentStepCue(); for (const targetX of [0.34, 0.34, 0.34, 0.34, 0.34]) { setMocapBodyCenter(targetX); await act(async () => { rerender(); }); } await vi.waitFor(() => { expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain( 'left: 37', ); }); await completeCurrentPositionStepByHold(); await vi.waitFor(() => { expect(screen.getByRole('heading', { name: '回到中间来' })).toBeTruthy(); }); await act(async () => { unmount(); }); vi.useRealTimers(); }); test('mocap greeting requires a real horizontal wave track', async () => { vi.useFakeTimers(); const { rerender, unmount } = render(); await revealCurrentStepCue(); await completeCurrentPositionStepByHold(); await vi.waitFor(() => { expect(screen.getByText('打个招呼')).toBeTruthy(); }); await revealCurrentStepCue(); mocapMock.command = { actions: ['open_palm'], hands: [{ x: 0.46, y: 0.34, state: 'open_palm', side: 'left' }], primaryHand: { x: 0.46, y: 0.34, state: 'open_palm', side: 'left' }, leftHand: { x: 0.46, y: 0.34, state: 'open_palm', side: 'left' }, rightHand: null, }; mocapMock.receivedAtMs += 1; await act(async () => { rerender(); }); await advanceWarmupTime(900); expect(screen.getByRole('heading', { name: '打个招呼' })).toBeTruthy(); await sendMocapLeftHandTrack(rerender, [0.42, 0.51, 0.58, 0.49, 0.43], { raised: false, }); await advanceWarmupTime(900); expect(screen.getByRole('heading', { name: '打个招呼' })).toBeTruthy(); for (const x of [0.42, 0.51, 0.58, 0.49, 0.43]) { const wrist = { x, y: 0.34 }; mocapMock.command = { actions: [], bodyCenter: { x: 0.5, y: 0.7 }, hands: [{ x, y: 0.34, state: 'unknown', side: 'left', wrist }], primaryHand: { x, y: 0.34, state: 'unknown', side: 'left', wrist }, leftHand: { x, y: 0.34, state: 'unknown', side: 'left', wrist }, rightHand: null, }; mocapMock.receivedAtMs += 1; await act(async () => { rerender(); }); } await advanceWarmupTime(900); expect(screen.getByRole('heading', { name: '打个招呼' })).toBeTruthy(); await completeGreetingByWaveTrack(rerender); await advanceWarmupTime(900); await vi.waitFor(() => { expect(screen.getByText('准备热身')).toBeTruthy(); }); await act(async () => { unmount(); }); vi.useRealTimers(); }); test('mocap arm swing steps require body-side mapping and vertical open arm motion', async () => { vi.useFakeTimers(); const { rerender, unmount } = render(); const advancePositionStep = async (key: string, code: string) => { await revealCurrentStepCue(); await act(async () => { fireEvent.keyDown(window, { key, code }); }); await completeCurrentPositionStepByHold(); await act(async () => { fireEvent.keyUp(window, { key, code }); }); }; await revealCurrentStepCue(); await completeCurrentPositionStepByHold(); await vi.waitFor(() => { expect(screen.getByText('打个招呼')).toBeTruthy(); }); await revealCurrentStepCue(); await completeGreetingByWaveTrack(rerender); await advanceWarmupTime(900); await completeCurrentNarrationStep(); await advancePositionStep('a', 'KeyA'); await revealCurrentStepCue(); await completeCurrentPositionStepByHold(); await advancePositionStep('d', 'KeyD'); await revealCurrentStepCue(); await completeCurrentPositionStepByHold(); await vi.waitFor(() => { expect(screen.getByRole('heading', { name: '挥动左手' })).toBeTruthy(); }); await revealCurrentStepCue(); await sendMocapCameraHandTrack(rerender, 'left', [ { x: 0.78, y: 0.5 }, { x: 0.86, y: 0.5 }, { x: 0.79, y: 0.5 }, { x: 0.87, y: 0.5 }, { x: 0.8, y: 0.5 }, ]); await advanceWarmupTime(900); expect(screen.getByRole('heading', { name: '挥动左手' })).toBeTruthy(); await sendMocapCameraHandTrack(rerender, 'right', [ { x: 0.32, y: 0.74 }, { x: 0.24, y: 0.74 }, { x: 0.31, y: 0.74 }, { x: 0.23, y: 0.74 }, { x: 0.3, y: 0.74 }, ]); await advanceWarmupTime(900); expect(screen.getByRole('heading', { name: '挥动左手' })).toBeTruthy(); await sendPlayerLeftArmSwingTrack(rerender); await advanceWarmupTime(900); await vi.waitFor(() => { expect(screen.getByRole('heading', { name: '挥动右手' })).toBeTruthy(); }); await revealCurrentStepCue(); await sendMocapCameraHandTrack(rerender, 'right', [ { x: 0.2, y: 0.5 }, { x: 0.16, y: 0.42 }, { x: 0.13, y: 0.34 }, { x: 0.15, y: 0.43 }, { x: 0.19, y: 0.51 }, ]); await advanceWarmupTime(900); expect(screen.getByRole('heading', { name: '挥动右手' })).toBeTruthy(); await sendPlayerRightArmSwingTrack(rerender); await advanceWarmupTime(900); await vi.waitFor(() => { expect(screen.getByRole('heading', { name: '原地跳一下' })).toBeTruthy(); }); await advanceWarmupTime(720); await act(async () => { unmount(); }); vi.useRealTimers(); }); test('connects camera stream and releases it on unmount', async () => { const stopTrack = vi.fn(); const stream = { getTracks: () => [ { stop: stopTrack, }, ], } as unknown as MediaStream; const getUserMedia = vi.fn().mockResolvedValue(stream); const play = vi .spyOn(HTMLMediaElement.prototype, 'play') .mockResolvedValue(undefined); Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: { getUserMedia, }, }); const { unmount } = render(); expect(screen.queryByText('摄像头暂不可用,已切换到本地演示')).toBeNull(); expect(screen.getByText('动作数据已连接,等待识别')).toBeTruthy(); await vi.waitFor(() => { expect(getUserMedia).toHaveBeenCalledWith({ audio: false, video: { facingMode: 'user', }, }); expect(play).toHaveBeenCalled(); }); unmount(); expect(stopTrack).toHaveBeenCalled(); });