/* @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 ? ( {alt} ) : 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 { 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( , ); 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( , ); 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( , ); 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( , ); await advanceRoundIntro(); rerender( , ); 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( , ); 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( , ); 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( , ); await advanceRoundIntro(); rerender( , ); expect(screen.queryByText('真棒')).toBeNull(); expect(screen.queryByText('再想一想吧')).toBeNull(); rerender( , ); expect(screen.getByTestId('baby-object-left-hand')).toBeTruthy(); expect(screen.queryByText('真棒')).toBeNull(); rerender( , ); 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( , ); await advanceRoundIntro(); rerender( , ); expect(screen.queryByTestId('baby-object-left-hand')).toBeTruthy(); expect(screen.queryByText('真棒')).toBeNull(); rerender( , ); 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( , ); await advanceRoundIntro(); rerender( , ); rerender( , ); expect(screen.queryByText('真棒')).toBeNull(); expect(screen.queryByText('再想一想吧')).toBeNull(); expect(screen.getByLabelText('成功次数').textContent).toBe('0/20'); rerender( , ); 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( , ); await advanceRoundIntro(); rerender( , ); rerender( , ); 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( , ); await advanceRoundIntro(); leftHandRuntime.rerender( , ); 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( , ); await advanceRoundIntro(); rightHandRuntime.rerender( , ); 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( , ); await advanceRoundIntro(); rerender( , ); 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( , ); 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( , ); } 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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(); });