Files
Genarrative/src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx

569 lines
16 KiB
TypeScript

/* @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 ? <img src={src} alt={alt} className={className} /> : 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(<ChildMotionWarmupDemo />);
});
}
}
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(<ChildMotionWarmupDemo />);
});
}
}
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(<ChildMotionWarmupDemo />);
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(<ChildMotionWarmupDemo />);
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(<ChildMotionWarmupDemo />);
expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy();
});
test('start button opens the baby object match level', () => {
markChildMotionWarmupCompletedInRuntime();
render(<ChildMotionWarmupDemo />);
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(<ChildMotionWarmupDemo />);
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(<ChildMotionWarmupDemo />);
expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain(
'left: 50%',
);
setMocapBodyCenter(0.508);
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain(
'left: 50%',
);
setMocapBodyCenter(0.34);
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
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(<ChildMotionWarmupDemo />);
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(<ChildMotionWarmupDemo />);
});
}
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(<ChildMotionWarmupDemo />);
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(<ChildMotionWarmupDemo />);
});
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(<ChildMotionWarmupDemo />);
});
}
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(<ChildMotionWarmupDemo />);
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(<ChildMotionWarmupDemo />);
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();
});