feat: add edutainment drawing and visual package flows

This commit is contained in:
2026-05-14 14:17:10 +08:00
parent 10e8beea80
commit e444266e1e
109 changed files with 8788 additions and 996 deletions

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { act, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
@@ -17,11 +17,21 @@ vi.mock('../ResolvedAssetImage', () => ({
src,
alt,
className,
'data-testid': dataTestId,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
'data-testid'?: string;
}) =>
src ? (
<img
src={src}
alt={alt}
className={className}
data-testid={dataTestId}
/>
) : null,
}));
vi.mock('../../services/useMocapInput', () => ({
@@ -60,6 +70,7 @@ function createDraft(): BabyObjectMatchDraft {
prompt: '香蕉',
},
],
visualPackage: null,
themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'],
publicationStatus: 'published',
createdAt: '2026-05-11T00:00:00.000Z',
@@ -68,6 +79,57 @@ function createDraft(): BabyObjectMatchDraft {
};
}
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 {
@@ -146,7 +208,26 @@ function dragHand(stage: HTMLElement, button: 0 | 2) {
});
}
test('opens the gift box with F and shows the next item', () => {
async function advanceRoundIntro() {
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()}
@@ -154,23 +235,92 @@ test('opens the gift box with F and shows the next item', () => {
/>,
);
expect(
within(screen.getByTestId('baby-object-current-item')).queryByAltText(
'苹果',
),
).toBeNull();
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy();
expect(screen.getByTestId('baby-object-current-item').textContent).toBe('');
await advanceRoundIntro();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'苹果',
),
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', () => {
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',
);
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('removes the gift box after smoke releases the current item', async () => {
vi.useFakeTimers();
render(
<BabyObjectMatchRuntimeShell
draft={createVisualPackageDraft()}
random={createRandomSequence([0])}
/>,
);
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()}
@@ -178,109 +328,28 @@ test('keeps left and right baskets fixed while only the gift item is random', ()
/>,
);
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
await advanceRoundIntro();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'香蕉',
),
within(screen.getByTestId('baby-object-current-item')).getByAltText('香蕉'),
).toBeTruthy();
expect(screen.getByLabelText('左侧篮子 苹果')).toBeTruthy();
expect(screen.getByLabelText('右侧篮子 香蕉')).toBeTruthy();
vi.useRealTimers();
});
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', () => {
test('mocap camera-right hand movement sends the player left hand item into the left basket', async () => {
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 },
})}
mocapInput={createMocapInput()}
/>,
);
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 },
})}
/>,
);
await advanceRoundIntro();
rerender(
<BabyObjectMatchRuntimeShell
@@ -294,7 +363,10 @@ test('mocap camera-right hand movement sends the player left hand item into the
leftHand: null,
rightHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
},
rawPacketPreview: { text: 'camera-right-horizontal-1', receivedAtMs: 3 },
rawPacketPreview: {
text: 'camera-right-horizontal-1',
receivedAtMs: 1,
},
})}
/>,
);
@@ -311,7 +383,10 @@ test('mocap camera-right hand movement sends the player left hand item into the
leftHand: null,
rightHand: { x: 0.24, y: 0.45, state: 'open_palm', side: 'right' },
},
rawPacketPreview: { text: 'camera-right-horizontal-2', receivedAtMs: 4 },
rawPacketPreview: {
text: 'camera-right-horizontal-2',
receivedAtMs: 2,
},
})}
/>,
);
@@ -328,7 +403,10 @@ test('mocap camera-right hand movement sends the player left hand item into the
leftHand: null,
rightHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
},
rawPacketPreview: { text: 'camera-right-horizontal-3', receivedAtMs: 5 },
rawPacketPreview: {
text: 'camera-right-horizontal-3',
receivedAtMs: 3,
},
})}
/>,
);
@@ -347,7 +425,10 @@ test('mocap camera-right hand movement sends the player left hand item into the
leftHand: null,
rightHand: { x: 0.31, y: 0.45, state: 'open_palm', side: 'right' },
},
rawPacketPreview: { text: 'camera-right-horizontal-4', receivedAtMs: 6 },
rawPacketPreview: {
text: 'camera-right-horizontal-4',
receivedAtMs: 4,
},
})}
/>,
);
@@ -357,42 +438,18 @@ test('mocap camera-right hand movement sends the player left hand item into the
vi.useRealTimers();
});
test('mocap camera-left hand movement sends the player right hand item into the right basket', () => {
test('mocap camera-left hand movement sends the player right hand item into the right basket', async () => {
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 },
})}
mocapInput={createMocapInput()}
/>,
);
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 },
})}
/>,
);
await advanceRoundIntro();
rerender(
<BabyObjectMatchRuntimeShell
@@ -406,7 +463,7 @@ test('mocap camera-left hand movement sends the player right hand item into the
leftHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-1', receivedAtMs: 3 },
rawPacketPreview: { text: 'camera-left-horizontal-1', receivedAtMs: 1 },
})}
/>,
);
@@ -423,7 +480,7 @@ test('mocap camera-left hand movement sends the player right hand item into the
leftHand: { x: 0.8, y: 0.45, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-2', receivedAtMs: 4 },
rawPacketPreview: { text: 'camera-left-horizontal-2', receivedAtMs: 2 },
})}
/>,
);
@@ -440,7 +497,7 @@ test('mocap camera-left hand movement sends the player right hand item into the
leftHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-3', receivedAtMs: 5 },
rawPacketPreview: { text: 'camera-left-horizontal-3', receivedAtMs: 3 },
})}
/>,
);
@@ -459,7 +516,7 @@ test('mocap camera-left hand movement sends the player right hand item into the
leftHand: { x: 0.73, y: 0.45, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-4', receivedAtMs: 6 },
rawPacketPreview: { text: 'camera-left-horizontal-4', receivedAtMs: 4 },
})}
/>,
);
@@ -469,41 +526,18 @@ test('mocap camera-left hand movement sends the player right hand item into the
vi.useRealTimers();
});
test('mocap action names do not select a basket without horizontal hand movement', () => {
test('mocap action names do not select a basket without horizontal hand movement', async () => {
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-left', receivedAtMs: 1 },
})}
mocapInput={createMocapInput()}
/>,
);
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 },
})}
/>,
);
await advanceRoundIntro();
rerender(
<BabyObjectMatchRuntimeShell
@@ -517,7 +551,7 @@ test('mocap action names do not select a basket without horizontal hand movement
leftHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'action-only-wave', receivedAtMs: 3 },
rawPacketPreview: { text: 'action-only-wave', receivedAtMs: 1 },
})}
/>,
);
@@ -525,47 +559,23 @@ test('mocap action names do not select a basket without horizontal hand movement
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.queryByText('再想一想吧')).toBeNull();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'苹果',
),
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy();
vi.useRealTimers();
});
test('mocap unknown hand horizontal movement does not select a basket', () => {
test('mocap unknown hand horizontal movement does not select a basket', async () => {
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: 'unknown' }],
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'unknown' },
leftHand: null,
rightHand: null,
},
rawPacketPreview: { text: 'open-unknown', receivedAtMs: 1 },
})}
mocapInput={createMocapInput()}
/>,
);
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 },
})}
/>,
);
await advanceRoundIntro();
for (let index = 0; index < 4; index += 1) {
const x = [0.22, 0.24, 0.22, 0.31][index] ?? 0.22;
@@ -583,7 +593,7 @@ test('mocap unknown hand horizontal movement does not select a basket', () => {
},
rawPacketPreview: {
text: `unknown-horizontal-${index + 1}`,
receivedAtMs: index + 3,
receivedAtMs: index + 1,
},
})}
/>,
@@ -593,13 +603,12 @@ test('mocap unknown hand horizontal movement does not select a basket', () => {
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.queryByText('再想一想吧')).toBeNull();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'苹果',
),
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy();
vi.useRealTimers();
});
test('left hand horizontal drag sends a correct item into the left basket', () => {
test('left hand horizontal drag sends a correct item into the left basket', async () => {
vi.useFakeTimers();
const { container } = render(
<BabyObjectMatchRuntimeShell
@@ -612,26 +621,30 @@ test('left hand horizontal drag sends a correct item into the left basket', () =
throw new Error('Missing baby object runtime stage');
}
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
await advanceRoundIntro();
dragHand(stage, 0);
expect(screen.getByText('真棒')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
expect(screen.getByLabelText('左侧篮子 苹果').className).toContain(
'baby-object-runtime__basket--correct',
);
act(() => {
vi.advanceTimersByTime(800);
});
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')).queryByAltText(
'苹果',
),
).toBeNull();
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy();
vi.useRealTimers();
});
test('wrong basket keeps the item active after feedback', () => {
test('ignores drag input until the item animation finishes', async () => {
vi.useFakeTimers();
const { container } = render(
<BabyObjectMatchRuntimeShell
@@ -644,26 +657,86 @@ test('wrong basket keeps the item active after feedback', () => {
throw new Error('Missing baby object runtime stage');
}
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
dragHand(stage, 0);
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
await advanceRoundIntro();
dragHand(stage, 0);
expect(screen.getByText('真棒')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
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();
dragHand(stage, 0);
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();
dragHand(stage, 2);
expect(screen.getByText('再想一想吧')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
act(() => {
vi.advanceTimersByTime(800);
});
await advanceFeedback();
expect(screen.queryByText('再想一想吧')).toBeNull();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'苹果',
),
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy();
vi.useRealTimers();
});
test('twenty correct placements completes the level', () => {
test('twenty correct placements completes the level', async () => {
vi.useFakeTimers();
const randomValues = Array.from({ length: 40 }, () => 0);
const { container } = render(
@@ -678,11 +751,9 @@ test('twenty correct placements completes the level', () => {
}
for (let index = 0; index < 20; index += 1) {
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
await advanceRoundIntro();
dragHand(stage, 0);
act(() => {
vi.advanceTimersByTime(800);
});
await advanceFeedback();
}
expect(screen.getAllByText('恭喜你!小朋友!').length).toBeGreaterThan(0);