1192 lines
33 KiB
TypeScript
1192 lines
33 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { act, 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,
|
|
'data-testid': dataTestId,
|
|
}: {
|
|
src?: string | null;
|
|
alt?: string;
|
|
className?: string;
|
|
'data-testid'?: string;
|
|
}) =>
|
|
src ? (
|
|
<img
|
|
src={src}
|
|
alt={alt}
|
|
className={className}
|
|
data-testid={dataTestId}
|
|
/>
|
|
) : 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: '香蕉',
|
|
},
|
|
],
|
|
visualPackage: null,
|
|
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 createVisualPackageDraft(): BabyObjectMatchDraft {
|
|
return {
|
|
...createDraft(),
|
|
visualPackage: {
|
|
themePrompt: '果园主题',
|
|
assets: [
|
|
{
|
|
assetId: 'baby-object-visual-background',
|
|
assetKind: 'background',
|
|
imageSrc: 'data:image/png;base64,background',
|
|
assetObjectId: null,
|
|
generationProvider: 'vector-engine-gpt-image-2',
|
|
prompt: '背景',
|
|
},
|
|
{
|
|
assetId: 'baby-object-visual-ui-frame',
|
|
assetKind: 'ui-frame',
|
|
imageSrc: 'data:image/png;base64,ui',
|
|
assetObjectId: null,
|
|
generationProvider: 'vector-engine-gpt-image-2',
|
|
prompt: 'UI',
|
|
},
|
|
{
|
|
assetId: 'baby-object-visual-gift-box',
|
|
assetKind: 'gift-box',
|
|
imageSrc: 'data:image/png;base64,gift',
|
|
assetObjectId: null,
|
|
generationProvider: 'vector-engine-gpt-image-2',
|
|
prompt: '礼盒',
|
|
},
|
|
{
|
|
assetId: 'baby-object-visual-basket',
|
|
assetKind: 'basket',
|
|
imageSrc: 'data:image/png;base64,basket',
|
|
assetObjectId: null,
|
|
generationProvider: 'vector-engine-gpt-image-2',
|
|
prompt: '篮子',
|
|
},
|
|
{
|
|
assetId: 'baby-object-visual-smoke-puff',
|
|
assetKind: 'smoke-puff',
|
|
imageSrc: 'data:image/png;base64,smoke',
|
|
assetObjectId: null,
|
|
generationProvider: 'vector-engine-gpt-image-2',
|
|
prompt: '烟雾',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
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;
|
|
buttons?: number;
|
|
clientX: number;
|
|
clientY: number;
|
|
},
|
|
) {
|
|
const event = new Event(type, { bubbles: true, cancelable: true });
|
|
Object.assign(event, options);
|
|
target.dispatchEvent(event);
|
|
return event;
|
|
}
|
|
|
|
function setStageRect(stage: HTMLElement) {
|
|
Object.defineProperty(stage, 'getBoundingClientRect', {
|
|
configurable: true,
|
|
value: () => ({
|
|
x: 0,
|
|
y: 0,
|
|
left: 0,
|
|
top: 0,
|
|
right: 320,
|
|
bottom: 240,
|
|
width: 320,
|
|
height: 240,
|
|
toJSON: () => ({}),
|
|
}),
|
|
});
|
|
}
|
|
|
|
function dragItemWithHand(stage: HTMLElement, button: 0 | 2, targetX: number) {
|
|
setStageRect(stage);
|
|
|
|
act(() => {
|
|
dispatchPointerEvent(stage, 'pointerdown', {
|
|
pointerId: button + 1,
|
|
button,
|
|
buttons: button === 2 ? 2 : 1,
|
|
clientX: 160,
|
|
clientY: 89,
|
|
});
|
|
});
|
|
act(() => {
|
|
dispatchPointerEvent(stage, 'pointermove', {
|
|
pointerId: button + 1,
|
|
button,
|
|
buttons: button === 2 ? 2 : 1,
|
|
clientX: targetX,
|
|
clientY: 190,
|
|
});
|
|
});
|
|
act(() => {
|
|
dispatchPointerEvent(stage, 'pointerup', {
|
|
pointerId: button + 1,
|
|
button,
|
|
buttons: 0,
|
|
clientX: targetX,
|
|
clientY: 190,
|
|
});
|
|
});
|
|
}
|
|
|
|
async function advanceRoundIntro() {
|
|
await advanceInitialTargetPreview();
|
|
await advanceGiftIntro();
|
|
}
|
|
|
|
async function advanceInitialTargetPreview() {
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(720);
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(720);
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
});
|
|
}
|
|
|
|
async function advanceGiftIntro() {
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(620);
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(640);
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(620);
|
|
});
|
|
}
|
|
|
|
async function advanceFeedback() {
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(1200);
|
|
});
|
|
}
|
|
|
|
test('shows the first gift item after gift and item animations', async () => {
|
|
vi.useFakeTimers();
|
|
render(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={createRandomSequence([0, 0])}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy();
|
|
expect(screen.getByTestId('baby-object-intro-item')).toBeTruthy();
|
|
expect(screen.getByTestId('baby-object-current-item').textContent).toBe('');
|
|
|
|
await advanceRoundIntro();
|
|
|
|
expect(
|
|
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
|
|
).toBeTruthy();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('previews both target items before the first gift box round', async () => {
|
|
vi.useFakeTimers();
|
|
render(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={createRandomSequence([0])}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByTestId('baby-object-intro-item')).toBeTruthy();
|
|
expect(
|
|
within(screen.getByTestId('baby-object-intro-item')).getByAltText('苹果'),
|
|
).toBeTruthy();
|
|
expect(screen.queryByLabelText('礼物盒')).toBeNull();
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
});
|
|
|
|
expect(screen.getByTestId('baby-object-intro-item').className).toContain(
|
|
'baby-object-runtime__intro-item--flying',
|
|
);
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(720);
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
});
|
|
|
|
expect(screen.getByTestId('baby-object-intro-item')).toBeTruthy();
|
|
expect(
|
|
within(screen.getByTestId('baby-object-intro-item')).getByAltText('香蕉'),
|
|
).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(720);
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
});
|
|
|
|
expect(screen.queryByTestId('baby-object-intro-item')).toBeNull();
|
|
expect(screen.getByLabelText('礼物盒')).toBeTruthy();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('applies generated visual package to stage, gift box, baskets, smoke and hud', async () => {
|
|
vi.useFakeTimers();
|
|
const { container } = render(
|
|
<BabyObjectMatchRuntimeShell draft={createVisualPackageDraft()} />,
|
|
);
|
|
const stage = container.querySelector('.baby-object-runtime__stage');
|
|
if (!(stage instanceof HTMLElement)) {
|
|
throw new Error('Missing baby object runtime stage');
|
|
}
|
|
|
|
expect(stage.classList.contains('baby-object-runtime__stage--skinned')).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
screen
|
|
.getByTestId('baby-object-background-image')
|
|
.getAttribute('src'),
|
|
).toBe('data:image/png;base64,background');
|
|
expect(
|
|
stage.style.getPropertyValue('--baby-object-ui-frame-image'),
|
|
).toContain('ui');
|
|
expect(stage.style.getPropertyValue('--baby-object-smoke-image')).toContain(
|
|
'smoke',
|
|
);
|
|
await advanceInitialTargetPreview();
|
|
|
|
expect(screen.getByAltText('礼物盒')).toBeTruthy();
|
|
expect(
|
|
container.querySelector('.baby-object-runtime__basket-shell-image'),
|
|
).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(620);
|
|
});
|
|
|
|
expect(screen.getByTestId('baby-object-smoke-effect')).toBeTruthy();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('uses default runtime hand indicators instead of per-draft generated hand assets', async () => {
|
|
vi.useFakeTimers();
|
|
const random = createRandomSequence([0, 0]);
|
|
const draftWithLegacyHandAssets: BabyObjectMatchDraft = {
|
|
...createVisualPackageDraft(),
|
|
visualPackage: {
|
|
...createVisualPackageDraft().visualPackage!,
|
|
assets: [
|
|
...createVisualPackageDraft().visualPackage!.assets,
|
|
{
|
|
assetId: 'legacy-left-hand',
|
|
assetKind: 'left-hand',
|
|
imageSrc: 'data:image/png;base64,legacy-left-hand',
|
|
assetObjectId: null,
|
|
generationProvider: 'vector-engine-gpt-image-2',
|
|
prompt: '旧左手',
|
|
},
|
|
{
|
|
assetId: 'legacy-right-hand',
|
|
assetKind: 'right-hand',
|
|
imageSrc: 'data:image/png;base64,legacy-right-hand',
|
|
assetObjectId: null,
|
|
generationProvider: 'vector-engine-gpt-image-2',
|
|
prompt: '旧右手',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const { container, rerender } = render(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={draftWithLegacyHandAssets}
|
|
random={random}
|
|
mocapInput={createMocapInput()}
|
|
/>,
|
|
);
|
|
|
|
await advanceRoundIntro();
|
|
|
|
rerender(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={draftWithLegacyHandAssets}
|
|
random={random}
|
|
mocapInput={createMocapInput({
|
|
latestCommand: {
|
|
actions: [],
|
|
hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'right' }],
|
|
primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
|
|
leftHand: null,
|
|
rightHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
|
|
},
|
|
rawPacketPreview: {
|
|
text: 'show-default-hand',
|
|
receivedAtMs: 1,
|
|
},
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByTestId('baby-object-left-hand')).toBeTruthy();
|
|
const stage = container.querySelector('.baby-object-runtime__stage');
|
|
expect(stage).toBeInstanceOf(HTMLElement);
|
|
expect(
|
|
(stage as HTMLElement).style.getPropertyValue(
|
|
'--baby-object-left-hand-image',
|
|
),
|
|
).toBe('');
|
|
expect(
|
|
(stage as HTMLElement).style.getPropertyValue(
|
|
'--baby-object-right-hand-image',
|
|
),
|
|
).toBe('');
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('removes the gift box after smoke releases the current item', async () => {
|
|
vi.useFakeTimers();
|
|
render(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createVisualPackageDraft()}
|
|
random={createRandomSequence([0])}
|
|
/>,
|
|
);
|
|
|
|
await advanceInitialTargetPreview();
|
|
|
|
expect(screen.getByLabelText('礼物盒')).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(620);
|
|
});
|
|
|
|
expect(screen.getByLabelText('礼物盒')).toBeTruthy();
|
|
expect(screen.getByTestId('baby-object-smoke-effect')).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(640);
|
|
});
|
|
|
|
expect(screen.queryByLabelText('礼物盒')).toBeNull();
|
|
expect(screen.getByTestId('baby-object-smoke-effect')).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(620);
|
|
});
|
|
|
|
expect(screen.queryByLabelText('礼物盒')).toBeNull();
|
|
expect(
|
|
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
|
|
).toBeTruthy();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('keeps left and right baskets fixed while only the gift item is random', async () => {
|
|
vi.useFakeTimers();
|
|
render(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={createRandomSequence([0.99])}
|
|
/>,
|
|
);
|
|
|
|
await advanceRoundIntro();
|
|
|
|
expect(
|
|
within(screen.getByTestId('baby-object-current-item')).getByAltText('香蕉'),
|
|
).toBeTruthy();
|
|
expect(screen.getByLabelText('左侧篮子 苹果')).toBeTruthy();
|
|
expect(screen.getByLabelText('右侧篮子 香蕉')).toBeTruthy();
|
|
expect(
|
|
within(screen.getByLabelText('左侧篮子 苹果')).getByText('苹果'),
|
|
).toBeTruthy();
|
|
expect(
|
|
within(screen.getByLabelText('右侧篮子 香蕉')).getByText('香蕉'),
|
|
).toBeTruthy();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('mocap hand must touch the current item before dropping it into a basket', async () => {
|
|
vi.useFakeTimers();
|
|
const random = createRandomSequence([0, 0]);
|
|
const { rerender } = render(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={random}
|
|
mocapInput={createMocapInput()}
|
|
/>,
|
|
);
|
|
|
|
await advanceRoundIntro();
|
|
|
|
rerender(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={random}
|
|
mocapInput={createMocapInput({
|
|
latestCommand: {
|
|
actions: [],
|
|
hands: [{ x: 0.24, y: 0.72, state: 'open_palm', side: 'right' }],
|
|
primaryHand: { x: 0.24, y: 0.72, state: 'open_palm', side: 'right' },
|
|
leftHand: null,
|
|
rightHand: { x: 0.24, y: 0.72, state: 'open_palm', side: 'right' },
|
|
},
|
|
rawPacketPreview: {
|
|
text: 'drop-without-grab',
|
|
receivedAtMs: 1,
|
|
},
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText('真棒')).toBeNull();
|
|
expect(screen.queryByText('再想一想吧')).toBeNull();
|
|
|
|
rerender(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={random}
|
|
mocapInput={createMocapInput({
|
|
latestCommand: {
|
|
actions: [],
|
|
hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'right' }],
|
|
primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
|
|
leftHand: null,
|
|
rightHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
|
|
},
|
|
rawPacketPreview: {
|
|
text: 'touch-current-item',
|
|
receivedAtMs: 2,
|
|
},
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByTestId('baby-object-left-hand')).toBeTruthy();
|
|
expect(screen.queryByText('真棒')).toBeNull();
|
|
|
|
rerender(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={random}
|
|
mocapInput={createMocapInput({
|
|
latestCommand: {
|
|
actions: [],
|
|
hands: [{ x: 0.22, y: 0.78, state: 'open_palm', side: 'right' }],
|
|
primaryHand: { x: 0.22, y: 0.78, state: 'open_palm', side: 'right' },
|
|
leftHand: null,
|
|
rightHand: { x: 0.22, y: 0.78, state: 'open_palm', side: 'right' },
|
|
},
|
|
rawPacketPreview: {
|
|
text: 'drop-left-basket',
|
|
receivedAtMs: 3,
|
|
},
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText('真棒')).toBeTruthy();
|
|
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('mocap hand uses skeleton wrist before hand landmark points in baby object runtime', async () => {
|
|
vi.useFakeTimers();
|
|
const random = createRandomSequence([0, 0]);
|
|
const { rerender } = render(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={random}
|
|
mocapInput={createMocapInput()}
|
|
/>,
|
|
);
|
|
|
|
await advanceRoundIntro();
|
|
|
|
rerender(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={random}
|
|
mocapInput={createMocapInput({
|
|
latestCommand: {
|
|
actions: [],
|
|
hands: [
|
|
{
|
|
x: 0.5,
|
|
y: 0.37,
|
|
state: 'open_palm',
|
|
side: 'right',
|
|
source: 'palm_center',
|
|
wrist: { x: 0.62, y: 0.37 },
|
|
},
|
|
],
|
|
primaryHand: {
|
|
x: 0.5,
|
|
y: 0.37,
|
|
state: 'open_palm',
|
|
side: 'right',
|
|
source: 'palm_center',
|
|
wrist: { x: 0.62, y: 0.37 },
|
|
},
|
|
leftHand: null,
|
|
rightHand: {
|
|
x: 0.5,
|
|
y: 0.37,
|
|
state: 'open_palm',
|
|
side: 'right',
|
|
source: 'palm_center',
|
|
wrist: { x: 0.62, y: 0.37 },
|
|
},
|
|
bodyJoints: {
|
|
rightWrist: { x: 0.64, y: 0.37 },
|
|
},
|
|
},
|
|
rawPacketPreview: {
|
|
text: 'hand-points-over-item-skeleton-wrist-away',
|
|
receivedAtMs: 1,
|
|
},
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByTestId('baby-object-left-hand')).toBeTruthy();
|
|
expect(screen.queryByText('真棒')).toBeNull();
|
|
|
|
rerender(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={random}
|
|
mocapInput={createMocapInput({
|
|
latestCommand: {
|
|
actions: [],
|
|
hands: [
|
|
{
|
|
x: 0.62,
|
|
y: 0.37,
|
|
state: 'open_palm',
|
|
side: 'right',
|
|
source: 'palm_center',
|
|
wrist: { x: 0.5, y: 0.37 },
|
|
},
|
|
],
|
|
primaryHand: {
|
|
x: 0.62,
|
|
y: 0.37,
|
|
state: 'open_palm',
|
|
side: 'right',
|
|
source: 'palm_center',
|
|
wrist: { x: 0.5, y: 0.37 },
|
|
},
|
|
leftHand: null,
|
|
rightHand: {
|
|
x: 0.62,
|
|
y: 0.37,
|
|
state: 'open_palm',
|
|
side: 'right',
|
|
source: 'palm_center',
|
|
wrist: { x: 0.62, y: 0.37 },
|
|
},
|
|
bodyJoints: {
|
|
rightWrist: { x: 0.5, y: 0.37 },
|
|
},
|
|
},
|
|
rawPacketPreview: {
|
|
text: 'skeleton-wrist-over-item-hand-points-away',
|
|
receivedAtMs: 2,
|
|
},
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByTestId('baby-object-left-hand').className).toContain(
|
|
'baby-object-runtime__hand--holding-left-corner',
|
|
);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('basket judgement accepts the enlarged basket edge while keeping center gap safe', async () => {
|
|
vi.useFakeTimers();
|
|
const random = createRandomSequence([0, 0]);
|
|
const { rerender } = render(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={random}
|
|
mocapInput={createMocapInput()}
|
|
/>,
|
|
);
|
|
|
|
await advanceRoundIntro();
|
|
|
|
rerender(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={random}
|
|
mocapInput={createMocapInput({
|
|
latestCommand: {
|
|
actions: [],
|
|
hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'right' }],
|
|
primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
|
|
leftHand: null,
|
|
rightHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
|
|
},
|
|
rawPacketPreview: {
|
|
text: 'touch-current-item-before-narrow-zone',
|
|
receivedAtMs: 1,
|
|
},
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
rerender(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={random}
|
|
mocapInput={createMocapInput({
|
|
latestCommand: {
|
|
actions: [],
|
|
hands: [{ x: 0.37, y: 0.82, state: 'open_palm', side: 'right' }],
|
|
primaryHand: { x: 0.37, y: 0.82, state: 'open_palm', side: 'right' },
|
|
leftHand: null,
|
|
rightHand: { x: 0.37, y: 0.82, state: 'open_palm', side: 'right' },
|
|
},
|
|
rawPacketPreview: {
|
|
text: 'outside-enlarged-left-hitbox-center-gap',
|
|
receivedAtMs: 2,
|
|
},
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText('真棒')).toBeNull();
|
|
expect(screen.queryByText('再想一想吧')).toBeNull();
|
|
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
|
|
|
|
rerender(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={random}
|
|
mocapInput={createMocapInput({
|
|
latestCommand: {
|
|
actions: [],
|
|
hands: [{ x: 0.36, y: 0.62, state: 'open_palm', side: 'right' }],
|
|
primaryHand: { x: 0.36, y: 0.62, state: 'open_palm', side: 'right' },
|
|
leftHand: null,
|
|
rightHand: { x: 0.36, y: 0.62, state: 'open_palm', side: 'right' },
|
|
},
|
|
rawPacketPreview: {
|
|
text: 'enlarged-left-hitbox-edge',
|
|
receivedAtMs: 3,
|
|
},
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText('真棒')).toBeTruthy();
|
|
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('either mocap hand can drag the current item into either basket', async () => {
|
|
vi.useFakeTimers();
|
|
const random = createRandomSequence([0, 0]);
|
|
const { rerender } = render(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={random}
|
|
mocapInput={createMocapInput()}
|
|
/>,
|
|
);
|
|
|
|
await advanceRoundIntro();
|
|
|
|
rerender(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={random}
|
|
mocapInput={createMocapInput({
|
|
latestCommand: {
|
|
actions: [],
|
|
hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'left' }],
|
|
primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' },
|
|
leftHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' },
|
|
rightHand: null,
|
|
},
|
|
rawPacketPreview: { text: 'right-hand-touch-item', receivedAtMs: 1 },
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
rerender(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={random}
|
|
mocapInput={createMocapInput({
|
|
latestCommand: {
|
|
actions: [],
|
|
hands: [{ x: 0.78, y: 0.78, state: 'open_palm', side: 'left' }],
|
|
primaryHand: { x: 0.78, y: 0.78, state: 'open_palm', side: 'left' },
|
|
leftHand: { x: 0.78, y: 0.78, state: 'open_palm', side: 'left' },
|
|
rightHand: null,
|
|
},
|
|
rawPacketPreview: { text: 'right-hand-drop-right', receivedAtMs: 2 },
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText('再想一想吧')).toBeTruthy();
|
|
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('holding hand indicator anchors to the lower item corner by hand side', async () => {
|
|
vi.useFakeTimers();
|
|
const leftHandRandom = createRandomSequence([0, 0]);
|
|
const leftHandRuntime = render(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={leftHandRandom}
|
|
mocapInput={createMocapInput()}
|
|
/>,
|
|
);
|
|
|
|
await advanceRoundIntro();
|
|
|
|
leftHandRuntime.rerender(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={leftHandRandom}
|
|
mocapInput={createMocapInput({
|
|
latestCommand: {
|
|
actions: [],
|
|
hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'right' }],
|
|
primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
|
|
leftHand: null,
|
|
rightHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
|
|
},
|
|
rawPacketPreview: {
|
|
text: 'left-hand-holding-corner',
|
|
receivedAtMs: 1,
|
|
},
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByTestId('baby-object-left-hand').className).toContain(
|
|
'baby-object-runtime__hand--holding-left-corner',
|
|
);
|
|
|
|
leftHandRuntime.unmount();
|
|
|
|
const rightHandRandom = createRandomSequence([0, 0]);
|
|
const rightHandRuntime = render(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={rightHandRandom}
|
|
mocapInput={createMocapInput()}
|
|
/>,
|
|
);
|
|
|
|
await advanceRoundIntro();
|
|
|
|
rightHandRuntime.rerender(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={rightHandRandom}
|
|
mocapInput={createMocapInput({
|
|
latestCommand: {
|
|
actions: [],
|
|
hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'left' }],
|
|
primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' },
|
|
leftHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' },
|
|
rightHand: null,
|
|
},
|
|
rawPacketPreview: {
|
|
text: 'right-hand-holding-corner',
|
|
receivedAtMs: 2,
|
|
},
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByTestId('baby-object-right-hand').className).toContain(
|
|
'baby-object-runtime__hand--holding-right-corner',
|
|
);
|
|
rightHandRuntime.unmount();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('mocap action names do not select a basket without touching and dragging item', async () => {
|
|
vi.useFakeTimers();
|
|
const random = createRandomSequence([0, 0]);
|
|
const { rerender } = render(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={random}
|
|
mocapInput={createMocapInput()}
|
|
/>,
|
|
);
|
|
|
|
await advanceRoundIntro();
|
|
|
|
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: 1 },
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText('真棒')).toBeNull();
|
|
expect(screen.queryByText('再想一想吧')).toBeNull();
|
|
expect(
|
|
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
|
|
).toBeTruthy();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('mocap unknown hand movement does not grab or select a basket', async () => {
|
|
vi.useFakeTimers();
|
|
const random = createRandomSequence([0, 0]);
|
|
const { rerender } = render(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={random}
|
|
mocapInput={createMocapInput()}
|
|
/>,
|
|
);
|
|
|
|
await advanceRoundIntro();
|
|
|
|
for (let index = 0; index < 4; index += 1) {
|
|
const x = [0.5, 0.5, 0.22, 0.22][index] ?? 0.5;
|
|
const y = [0.37, 0.78, 0.78, 0.37][index] ?? 0.37;
|
|
rerender(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={random}
|
|
mocapInput={createMocapInput({
|
|
latestCommand: {
|
|
actions: [],
|
|
hands: [{ x, y, state: 'open_palm', side: 'unknown' }],
|
|
primaryHand: { x, y, state: 'open_palm', side: 'unknown' },
|
|
leftHand: null,
|
|
rightHand: null,
|
|
},
|
|
rawPacketPreview: {
|
|
text: `unknown-drag-${index + 1}`,
|
|
receivedAtMs: index + 1,
|
|
},
|
|
})}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
expect(screen.queryByText('真棒')).toBeNull();
|
|
expect(screen.queryByText('再想一想吧')).toBeNull();
|
|
expect(
|
|
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
|
|
).toBeTruthy();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('left mouse hand drags a correct item into the left basket', async () => {
|
|
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');
|
|
}
|
|
|
|
await advanceRoundIntro();
|
|
dragItemWithHand(stage, 0, 70);
|
|
|
|
expect(screen.getByText('真棒')).toBeTruthy();
|
|
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
|
|
expect(screen.getByLabelText('左侧篮子 苹果').className).toContain(
|
|
'baby-object-runtime__basket--correct',
|
|
);
|
|
|
|
await advanceFeedback();
|
|
|
|
expect(screen.queryByText('真棒')).toBeNull();
|
|
expect(screen.getByTestId('baby-object-current-item').textContent).toBe('');
|
|
expect(screen.getByLabelText('礼物盒')).toBeTruthy();
|
|
|
|
await advanceRoundIntro();
|
|
|
|
expect(
|
|
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
|
|
).toBeTruthy();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('ignores drag input until the item animation finishes', async () => {
|
|
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');
|
|
}
|
|
|
|
dragItemWithHand(stage, 0, 70);
|
|
|
|
expect(screen.queryByText('真棒')).toBeNull();
|
|
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
|
|
|
|
await advanceRoundIntro();
|
|
dragItemWithHand(stage, 0, 70);
|
|
|
|
expect(screen.getByText('真棒')).toBeTruthy();
|
|
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('keeps the back button outside active gameplay pointer input', async () => {
|
|
vi.useFakeTimers();
|
|
const onBack = vi.fn();
|
|
render(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={createRandomSequence([0, 0])}
|
|
onBack={onBack}
|
|
/>,
|
|
);
|
|
|
|
await advanceRoundIntro();
|
|
|
|
const backButton = screen.getByRole('button', { name: '返回' });
|
|
let pointerDownEvent!: Event;
|
|
act(() => {
|
|
pointerDownEvent = dispatchPointerEvent(backButton, 'pointerdown', {
|
|
pointerId: 9,
|
|
button: 0,
|
|
buttons: 1,
|
|
clientX: 16,
|
|
clientY: 16,
|
|
});
|
|
});
|
|
|
|
expect(pointerDownEvent.defaultPrevented).toBe(false);
|
|
expect(screen.queryByTestId('baby-object-left-hand')).toBeNull();
|
|
|
|
act(() => {
|
|
backButton.click();
|
|
});
|
|
|
|
expect(onBack).toHaveBeenCalledTimes(1);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('correct placement automatically shows the next gift item', async () => {
|
|
vi.useFakeTimers();
|
|
const { container } = render(
|
|
<BabyObjectMatchRuntimeShell
|
|
draft={createDraft()}
|
|
random={createRandomSequence([0, 0.99])}
|
|
/>,
|
|
);
|
|
const stage = container.querySelector('.baby-object-runtime__stage');
|
|
if (!(stage instanceof HTMLElement)) {
|
|
throw new Error('Missing baby object runtime stage');
|
|
}
|
|
|
|
expect(screen.getByTestId('baby-object-current-item').textContent).toBe('');
|
|
|
|
await advanceRoundIntro();
|
|
|
|
expect(
|
|
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
|
|
).toBeTruthy();
|
|
|
|
dragItemWithHand(stage, 0, 70);
|
|
|
|
expect(screen.getByText('真棒')).toBeTruthy();
|
|
|
|
await advanceFeedback();
|
|
|
|
expect(screen.queryByText('真棒')).toBeNull();
|
|
expect(screen.getByTestId('baby-object-current-item').textContent).toBe('');
|
|
|
|
await advanceRoundIntro();
|
|
|
|
expect(
|
|
within(screen.getByTestId('baby-object-current-item')).getByAltText('香蕉'),
|
|
).toBeTruthy();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('wrong basket keeps the item active after feedback', async () => {
|
|
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');
|
|
}
|
|
|
|
await advanceRoundIntro();
|
|
dragItemWithHand(stage, 2, 250);
|
|
|
|
expect(screen.getByText('再想一想吧')).toBeTruthy();
|
|
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
|
|
|
|
await advanceFeedback();
|
|
|
|
expect(screen.queryByText('再想一想吧')).toBeNull();
|
|
expect(
|
|
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
|
|
).toBeTruthy();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('twenty correct placements completes the level', async () => {
|
|
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) {
|
|
await advanceRoundIntro();
|
|
dragItemWithHand(stage, 0, 70);
|
|
await advanceFeedback();
|
|
}
|
|
|
|
expect(screen.getAllByText('恭喜你!小朋友!').length).toBeGreaterThan(0);
|
|
expect(screen.getByRole('button', { name: '再来一次' })).toBeTruthy();
|
|
expect(screen.getByRole('button', { name: '下一关' })).toBeTruthy();
|
|
vi.useRealTimers();
|
|
});
|