/* @vitest-environment jsdom */ import { act, fireEvent, render, screen } from '@testing-library/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 }>; primaryHand?: { x: number; y: number; state: string; side: string } | null; leftHand?: { x: number; y: number; state: string; side: string } | null; rightHand?: { x: number; y: number; state: string; side: string } | null; bodyCenter?: { 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, }), })); 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(); }); 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.getByLabelText('绿色圆环')).toBeTruthy(); expect(screen.getByText('请横屏体验')).toBeTruthy(); }); test('re-entering within the same runtime session opens the start button', () => { markChildMotionWarmupCompletedInRuntime(); render(); expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy(); }); 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 keeps the warmup flow on the motion data source', async () => { vi.useFakeTimers(); mocapMock.command = { actions: [], bodyCenter: { x: 0.5, y: 0.6 }, hands: [], primaryHand: null, leftHand: null, rightHand: null, }; const { rerender, unmount } = render(); expect(screen.queryByText('摄像头暂不可用,已切换到本地演示')).toBeNull(); expect(screen.queryByText('动作数据已连接,等待识别')).toBeNull(); expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain( 'left: 50%', ); await act(async () => { vi.advanceTimersByTime(2100); await vi.runOnlyPendingTimersAsync(); }); await vi.waitFor(() => { expect(screen.getByText('打个招呼')).toBeTruthy(); }); mocapMock.command = { actions: ['open_palm'], bodyCenter: { x: 0.5, y: 0.6 }, hands: [{ x: 0.48, y: 0.34, state: 'open_palm', side: 'left' }], primaryHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' }, leftHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' }, rightHand: null, }; mocapMock.receivedAtMs += 1; await act(async () => { rerender(); vi.advanceTimersByTime(1000); await vi.runOnlyPendingTimersAsync(); }); await vi.waitFor(() => { expect(screen.getByText('准备热身')).toBeTruthy(); }); await act(async () => { vi.advanceTimersByTime(1000); await vi.runOnlyPendingTimersAsync(); }); await vi.waitFor(() => { expect(screen.getByRole('heading', { name: '向左一步' })).toBeTruthy(); }); mocapMock.command = { actions: [], bodyCenter: { x: 0.34, y: 0.6 }, hands: [], primaryHand: null, leftHand: null, rightHand: null, }; mocapMock.receivedAtMs += 1; await act(async () => { rerender(); }); await vi.waitFor(() => { expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain( 'left: 34%', ); }); await act(async () => { vi.advanceTimersByTime(2100); await vi.runOnlyPendingTimersAsync(); }); await vi.waitFor(() => { expect(screen.getByRole('heading', { name: '回到中间来' })).toBeTruthy(); }); await act(async () => { unmount(); }); vi.useRealTimers(); }); test('mocap open palm completes the greeting wave step', async () => { vi.useFakeTimers(); const { rerender, unmount } = render(); await act(async () => { vi.advanceTimersByTime(2100); await vi.runOnlyPendingTimersAsync(); }); await vi.waitFor(() => { expect(screen.getByText('打个招呼')).toBeTruthy(); }); 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 vi.waitFor(() => { expect(screen.getByText('准备热身')).toBeTruthy(); }); await act(async () => { unmount(); }); vi.useRealTimers(); }); test('mocap hand tracks complete left and right wave steps only after movement is visible', async () => { vi.useFakeTimers(); const { rerender, unmount } = render(); const advancePositionStep = async (key: string, code: string) => { await act(async () => { fireEvent.keyDown(window, { key, code }); }); await act(async () => { vi.advanceTimersByTime(2100); await vi.runOnlyPendingTimersAsync(); }); await act(async () => { fireEvent.keyUp(window, { key, code }); }); }; await act(async () => { vi.advanceTimersByTime(2100); await vi.runOnlyPendingTimersAsync(); }); await vi.waitFor(() => { expect(screen.getByText('打个招呼')).toBeTruthy(); }); mocapMock.command = { actions: ['open_palm'], hands: [{ x: 0.48, y: 0.34, state: 'open_palm', side: 'left' }], primaryHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' }, leftHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' }, rightHand: null, }; mocapMock.receivedAtMs += 1; await act(async () => { rerender(); }); await act(async () => { vi.advanceTimersByTime(1000); await vi.runOnlyPendingTimersAsync(); }); await advancePositionStep('a', 'KeyA'); await act(async () => { vi.advanceTimersByTime(120); await vi.runOnlyPendingTimersAsync(); vi.advanceTimersByTime(2100); await vi.runOnlyPendingTimersAsync(); }); await advancePositionStep('d', 'KeyD'); await act(async () => { vi.advanceTimersByTime(120); await vi.runOnlyPendingTimersAsync(); vi.advanceTimersByTime(2100); await vi.runOnlyPendingTimersAsync(); }); await vi.waitFor(() => { expect(screen.getByRole('heading', { name: '挥动左手' })).toBeTruthy(); }); mocapMock.command = { actions: [], leftHand: { x: 0.3, y: 0.38, state: 'unknown', side: 'left' }, primaryHand: { x: 0.3, y: 0.38, state: 'unknown', side: 'left' }, rightHand: null, }; mocapMock.receivedAtMs += 1; await act(async () => { rerender(); }); mocapMock.command = { actions: [], leftHand: { x: 0.39, y: 0.36, state: 'unknown', side: 'left' }, primaryHand: { x: 0.39, y: 0.36, state: 'unknown', side: 'left' }, rightHand: null, }; mocapMock.receivedAtMs += 1; await act(async () => { rerender(); }); mocapMock.command = { actions: [], leftHand: { x: 0.31, y: 0.34, state: 'unknown', side: 'left' }, primaryHand: { x: 0.31, y: 0.34, state: 'unknown', side: 'left' }, rightHand: null, }; mocapMock.receivedAtMs += 1; await act(async () => { rerender(); }); await vi.waitFor(() => { expect(screen.getByRole('heading', { name: '挥动右手' })).toBeTruthy(); }); mocapMock.command = { actions: ['right_hand_wave'], leftHand: null, primaryHand: { x: 0.64, y: 0.35, state: 'unknown', side: 'right' }, rightHand: { x: 0.64, y: 0.35, state: 'unknown', side: 'right' }, }; mocapMock.receivedAtMs += 1; await act(async () => { rerender(); }); await vi.waitFor(() => { expect(screen.getByRole('heading', { name: '原地跳一下' })).toBeTruthy(); }); await act(async () => { vi.advanceTimersByTime(720); await vi.runOnlyPendingTimersAsync(); 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(); });