feat: add baby object match edutainment flow
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-12 16:08:59 +08:00
parent cf074837a4
commit d41f260a2a
58 changed files with 5628 additions and 466 deletions

View File

@@ -0,0 +1,692 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import {
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
BABY_OBJECT_MATCH_TEMPLATE_ID,
BABY_OBJECT_MATCH_TEMPLATE_NAME,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { UseMocapInputResult } from '../../services/useMocapInput';
import { BabyObjectMatchRuntimeShell } from './BabyObjectMatchRuntimeShell';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
vi.mock('../../services/useMocapInput', () => ({
useMocapInput: () => ({
status: 'idle',
latestCommand: null,
rawPacketPreview: null,
error: null,
}),
}));
function createDraft(): BabyObjectMatchDraft {
return {
draftId: 'baby-object-draft-1',
profileId: 'baby-object-profile-1',
templateId: BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
workTitle: '宝贝识物',
workDescription: '苹果和香蕉识物分类',
itemNames: ['苹果', '香蕉'],
itemAssets: [
{
itemId: 'baby-object-item-1',
itemName: '苹果',
imageSrc: 'data:image/svg+xml;utf8,apple',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '苹果',
},
{
itemId: 'baby-object-item-2',
itemName: '香蕉',
imageSrc: 'data:image/svg+xml;utf8,banana',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '香蕉',
},
],
themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'],
publicationStatus: 'published',
createdAt: '2026-05-11T00:00:00.000Z',
updatedAt: '2026-05-11T00:00:00.000Z',
publishedAt: '2026-05-11T00:00:00.000Z',
};
}
function createMocapInput(
overrides: Partial<UseMocapInputResult> = {},
): UseMocapInputResult {
return {
status: 'connected',
latestCommand: null,
rawPacketPreview: null,
error: null,
...overrides,
};
}
function createRandomSequence(values: number[]) {
let index = 0;
return () => {
const value = values[index] ?? values[values.length - 1] ?? 0;
index += 1;
return value;
};
}
function dispatchPointerEvent(
target: HTMLElement,
type: string,
options: {
pointerId: number;
button?: number;
clientX: number;
clientY: number;
},
) {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.assign(event, options);
target.dispatchEvent(event);
}
function dragHand(stage: HTMLElement, button: 0 | 2) {
Object.defineProperty(stage, 'getBoundingClientRect', {
configurable: true,
value: () => ({
x: 0,
y: 0,
left: 0,
top: 0,
right: 320,
bottom: 240,
width: 320,
height: 240,
toJSON: () => ({}),
}),
});
act(() => {
dispatchPointerEvent(stage, 'pointerdown', {
pointerId: button + 1,
button,
clientX: 20,
clientY: 140,
});
});
act(() => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: button + 1,
button,
clientX: 120,
clientY: 140,
});
});
act(() => {
dispatchPointerEvent(stage, 'pointerup', {
pointerId: button + 1,
button,
clientX: 120,
clientY: 140,
});
});
}
test('opens the gift box with F and shows the next item', () => {
render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0, 0])}
/>,
);
expect(
within(screen.getByTestId('baby-object-current-item')).queryByAltText(
'苹果',
),
).toBeNull();
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'苹果',
),
).toBeTruthy();
});
test('keeps left and right baskets fixed while only the gift item is random', () => {
render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0.99])}
/>,
);
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'香蕉',
),
).toBeTruthy();
expect(screen.getByLabelText('左侧篮子 苹果')).toBeTruthy();
expect(screen.getByLabelText('右侧篮子 香蕉')).toBeTruthy();
});
test('mocap open palm followed by grab opens the gift box', () => {
const { rerender } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0, 0])}
mocapInput={createMocapInput()}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0, 0])}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
leftHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'open-left', receivedAtMs: 1 },
})}
/>,
);
expect(
within(screen.getByTestId('baby-object-current-item')).queryByAltText(
'苹果',
),
).toBeNull();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0, 0])}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'left' }],
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
leftHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'grab-left', receivedAtMs: 2 },
})}
/>,
);
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'苹果',
),
).toBeTruthy();
});
test('mocap camera-right hand movement sends the player left hand item into the left basket', () => {
vi.useFakeTimers();
const random = createRandomSequence([0, 0]);
const { rerender } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'right' },
},
rawPacketPreview: { text: 'open-camera-right', receivedAtMs: 1 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'right' }],
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'right' },
leftHand: null,
rightHand: { x: 0.5, y: 0.5, state: 'grab', side: 'right' },
},
rawPacketPreview: { text: 'grab-camera-right', receivedAtMs: 2 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.22, y: 0.45, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
},
rawPacketPreview: { text: 'camera-right-horizontal-1', receivedAtMs: 3 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.24, y: 0.45, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.24, y: 0.45, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.24, y: 0.45, state: 'open_palm', side: 'right' },
},
rawPacketPreview: { text: 'camera-right-horizontal-2', receivedAtMs: 4 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.22, y: 0.45, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
},
rawPacketPreview: { text: 'camera-right-horizontal-3', receivedAtMs: 5 },
})}
/>,
);
expect(screen.queryByText('真棒')).toBeNull();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.31, y: 0.45, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.31, y: 0.45, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.31, y: 0.45, state: 'open_palm', side: 'right' },
},
rawPacketPreview: { text: 'camera-right-horizontal-4', receivedAtMs: 6 },
})}
/>,
);
expect(screen.getByText('真棒')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
vi.useRealTimers();
});
test('mocap camera-left hand movement sends the player right hand item into the right basket', () => {
vi.useFakeTimers();
const random = createRandomSequence([0, 0]);
const { rerender } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
leftHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'open-camera-left', receivedAtMs: 1 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'left' }],
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
leftHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'grab-camera-left', receivedAtMs: 2 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.82, y: 0.45, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
leftHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-1', receivedAtMs: 3 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.8, y: 0.45, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.8, y: 0.45, state: 'open_palm', side: 'left' },
leftHand: { x: 0.8, y: 0.45, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-2', receivedAtMs: 4 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.82, y: 0.45, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
leftHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-3', receivedAtMs: 5 },
})}
/>,
);
expect(screen.queryByText('再想一想吧')).toBeNull();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.73, y: 0.45, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.73, y: 0.45, state: 'open_palm', side: 'left' },
leftHand: { x: 0.73, y: 0.45, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-4', receivedAtMs: 6 },
})}
/>,
);
expect(screen.getByText('再想一想吧')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
vi.useRealTimers();
});
test('mocap action names do not select a basket without horizontal hand movement', () => {
const random = createRandomSequence([0, 0]);
const { rerender } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
leftHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'open-left', receivedAtMs: 1 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'left' }],
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
leftHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'grab-left', receivedAtMs: 2 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: ['wave_left_hand', 'wave_right_hand', 'wave'],
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
leftHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'action-only-wave', receivedAtMs: 3 },
})}
/>,
);
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.queryByText('再想一想吧')).toBeNull();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'苹果',
),
).toBeTruthy();
});
test('mocap unknown hand horizontal movement does not select a basket', () => {
const random = createRandomSequence([0, 0]);
const { rerender } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'unknown' }],
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'unknown' },
leftHand: null,
rightHand: null,
},
rawPacketPreview: { text: 'open-unknown', receivedAtMs: 1 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'unknown' }],
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'unknown' },
leftHand: null,
rightHand: null,
},
rawPacketPreview: { text: 'grab-unknown', receivedAtMs: 2 },
})}
/>,
);
for (let index = 0; index < 4; index += 1) {
const x = [0.22, 0.24, 0.22, 0.31][index] ?? 0.22;
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x, y: 0.45, state: 'open_palm', side: 'unknown' }],
primaryHand: { x, y: 0.45, state: 'open_palm', side: 'unknown' },
leftHand: null,
rightHand: null,
},
rawPacketPreview: {
text: `unknown-horizontal-${index + 1}`,
receivedAtMs: index + 3,
},
})}
/>,
);
}
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.queryByText('再想一想吧')).toBeNull();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'苹果',
),
).toBeTruthy();
});
test('left hand horizontal drag sends a correct item into the left basket', () => {
vi.useFakeTimers();
const { container } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0, 0])}
/>,
);
const stage = container.querySelector('.baby-object-runtime__stage');
if (!(stage instanceof HTMLElement)) {
throw new Error('Missing baby object runtime stage');
}
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
dragHand(stage, 0);
expect(screen.getByText('真棒')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
act(() => {
vi.advanceTimersByTime(800);
});
expect(screen.queryByText('真棒')).toBeNull();
expect(
within(screen.getByTestId('baby-object-current-item')).queryByAltText(
'苹果',
),
).toBeNull();
vi.useRealTimers();
});
test('wrong basket keeps the item active after feedback', () => {
vi.useFakeTimers();
const { container } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0, 0])}
/>,
);
const stage = container.querySelector('.baby-object-runtime__stage');
if (!(stage instanceof HTMLElement)) {
throw new Error('Missing baby object runtime stage');
}
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
dragHand(stage, 2);
expect(screen.getByText('再想一想吧')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
act(() => {
vi.advanceTimersByTime(800);
});
expect(screen.queryByText('再想一想吧')).toBeNull();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'苹果',
),
).toBeTruthy();
vi.useRealTimers();
});
test('twenty correct placements completes the level', () => {
vi.useFakeTimers();
const randomValues = Array.from({ length: 40 }, () => 0);
const { container } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence(randomValues)}
/>,
);
const stage = container.querySelector('.baby-object-runtime__stage');
if (!(stage instanceof HTMLElement)) {
throw new Error('Missing baby object runtime stage');
}
for (let index = 0; index < 20; index += 1) {
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
dragHand(stage, 0);
act(() => {
vi.advanceTimersByTime(800);
});
}
expect(screen.getAllByText('恭喜你!小朋友!').length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: '再来一次' })).toBeTruthy();
expect(screen.getByRole('button', { name: '下一关' })).toBeTruthy();
vi.useRealTimers();
});