feat: add baby object match edutainment flow
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -37,6 +37,18 @@ vi.mock('../../services/useMocapInput', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
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();
|
||||
@@ -71,6 +83,18 @@ test('re-entering within the same runtime session opens the start button', () =>
|
||||
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 />);
|
||||
|
||||
|
||||
@@ -4,12 +4,19 @@ import type {
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
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 {
|
||||
MocapConnectionStatus,
|
||||
MocapHandInput,
|
||||
MocapInputCommand,
|
||||
} from '../../services/useMocapInput';
|
||||
import { useMocapInput } from '../../services/useMocapInput';
|
||||
import { BabyObjectMatchRuntimeShell } from '../edutainment-runtime/BabyObjectMatchRuntimeShell';
|
||||
import {
|
||||
applyChildMotionWarmupCompletion,
|
||||
CHILD_MOTION_CENTER_X,
|
||||
@@ -33,6 +40,41 @@ type CameraAccessState = 'idle' | 'requesting' | 'ready' | 'blocked';
|
||||
type MotionSourceState = 'connecting' | 'ready' | 'waiting' | 'offline';
|
||||
type WarmupMocapGestureIntent = 'greeting' | 'left-hand' | 'right-hand' | 'jump';
|
||||
|
||||
const CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT: BabyObjectMatchDraft = {
|
||||
draftId: 'child-motion-demo-baby-object-draft',
|
||||
profileId: 'child-motion-demo-baby-object-profile',
|
||||
templateId: BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workTitle: '宝贝识物',
|
||||
workDescription: '苹果和香蕉识物分类',
|
||||
itemNames: ['苹果', '香蕉'],
|
||||
itemAssets: [
|
||||
{
|
||||
itemId: 'child-motion-demo-baby-object-apple',
|
||||
itemName: '苹果',
|
||||
imageSrc:
|
||||
'data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 512 512%22%3E%3Crect width=%22512%22 height=%22512%22 rx=%2296%22 fill=%22%23fff1d6%22/%3E%3Ccircle cx=%22256%22 cy=%22266%22 r=%22122%22 fill=%22%23ef5b5b%22/%3E%3Cpath d=%22M250 148c20-50 58-66 102-54-18 45-52 70-102 54Z%22 fill=%22%2351a45f%22/%3E%3Cpath d=%22M256 150c-8-34 2-62 28-84%22 stroke=%22%23734822%22 stroke-width=%2218%22 stroke-linecap=%22round%22 fill=%22none%22/%3E%3Ccircle cx=%22216%22 cy=%22226%22 r=%2218%22 fill=%22%23fff%22 opacity=%22.65%22/%3E%3C/svg%3E',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '苹果',
|
||||
},
|
||||
{
|
||||
itemId: 'child-motion-demo-baby-object-banana',
|
||||
itemName: '香蕉',
|
||||
imageSrc:
|
||||
'data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 512 512%22%3E%3Crect width=%22512%22 height=%22512%22 rx=%2296%22 fill=%22%23e9f7ff%22/%3E%3Cpath d=%22M142 302c128 74 228 38 278-122 14 144-84 244-226 220-52-9-84-38-52-98Z%22 fill=%22%23ffd75d%22/%3E%3Cpath d=%22M406 180c6-20 18-34 38-44%22 stroke=%22%238b5b22%22 stroke-width=%2218%22 stroke-linecap=%22round%22/%3E%3Cpath d=%22M158 310c70 40 152 42 218-38%22 stroke=%22%23fff2a7%22 stroke-width=%2220%22 stroke-linecap=%22round%22 fill=%22none%22 opacity=%22.72%22/%3E%3C/svg%3E',
|
||||
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',
|
||||
};
|
||||
|
||||
const WARMUP_MOCAP_WAVE_MIN_POINTS = 3;
|
||||
const WARMUP_MOCAP_WAVE_MIN_X_RANGE = 0.055;
|
||||
|
||||
@@ -246,7 +288,6 @@ function getStepIndex(stepId: ChildMotionWarmupStepId) {
|
||||
'jump_once',
|
||||
'warmup_finish',
|
||||
'level_select',
|
||||
'play_placeholder',
|
||||
];
|
||||
return Math.max(0, order.indexOf(stepId));
|
||||
}
|
||||
@@ -377,6 +418,7 @@ export function ChildMotionWarmupDemo() {
|
||||
const [stepId, setStepId] = useState<ChildMotionWarmupStepId>(() =>
|
||||
hasCompletedChildMotionWarmupInRuntime() ? 'level_select' : 'center_arrive',
|
||||
);
|
||||
const [isBabyObjectRuntimeOpen, setIsBabyObjectRuntimeOpen] = useState(false);
|
||||
const [avatarX, setAvatarX] = useState(CHILD_MOTION_CENTER_X);
|
||||
const [calibration, setCalibration] = useState(
|
||||
createEmptyChildMotionCalibration,
|
||||
@@ -778,16 +820,24 @@ export function ChildMotionWarmupDemo() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartPlaceholderLevel = () => {
|
||||
setStepId('play_placeholder');
|
||||
};
|
||||
|
||||
const handleReturnToStart = () => {
|
||||
setStepId('level_select');
|
||||
const handleStartBabyObjectLevel = () => {
|
||||
setIsBabyObjectRuntimeOpen(true);
|
||||
};
|
||||
|
||||
const lineText = useMemo(() => step.spokenLines.join(','), [step.spokenLines]);
|
||||
|
||||
if (isBabyObjectRuntimeOpen) {
|
||||
return (
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT}
|
||||
onBack={() => {
|
||||
setIsBabyObjectRuntimeOpen(false);
|
||||
setStepId('level_select');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="child-motion-demo" data-testid="child-motion-demo">
|
||||
<div className="child-motion-orientation-tip" role="status">
|
||||
@@ -846,21 +896,12 @@ export function ChildMotionWarmupDemo() {
|
||||
|
||||
{step.kind === 'levelSelect' ? (
|
||||
<div className="child-motion-start-panel">
|
||||
<button type="button" onClick={handleStartPlaceholderLevel}>
|
||||
<button type="button" onClick={handleStartBabyObjectLevel}>
|
||||
开始游戏
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step.kind === 'placeholder' ? (
|
||||
<div className="child-motion-start-panel">
|
||||
<span>下一关正在设计中</span>
|
||||
<button type="button" onClick={handleReturnToStart}>
|
||||
回到开始
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ChildMotionCalibrationPanel calibration={calibration} />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -25,14 +25,11 @@ describe('childMotionWarmupModel', () => {
|
||||
'jump_once',
|
||||
'warmup_finish',
|
||||
'level_select',
|
||||
'play_placeholder',
|
||||
]);
|
||||
expect(resolveNextChildMotionWarmupStep('center_arrive')).toBe(
|
||||
'wave_greeting',
|
||||
);
|
||||
expect(resolveNextChildMotionWarmupStep('level_select')).toBe(
|
||||
'play_placeholder',
|
||||
);
|
||||
expect(resolveNextChildMotionWarmupStep('level_select')).toBe('level_select');
|
||||
});
|
||||
|
||||
it('checks position completion against the active green ring target', () => {
|
||||
|
||||
@@ -10,8 +10,7 @@ export type ChildMotionWarmupStepId =
|
||||
| 'wave_right_hand'
|
||||
| 'jump_once'
|
||||
| 'warmup_finish'
|
||||
| 'level_select'
|
||||
| 'play_placeholder';
|
||||
| 'level_select';
|
||||
|
||||
export type ChildMotionWarmupTarget = 'center' | 'left' | 'right';
|
||||
|
||||
@@ -20,8 +19,7 @@ export type ChildMotionWarmupStepKind =
|
||||
| 'gesture'
|
||||
| 'narration'
|
||||
| 'finish'
|
||||
| 'levelSelect'
|
||||
| 'placeholder';
|
||||
| 'levelSelect';
|
||||
|
||||
export type ChildMotionWarmupStep = {
|
||||
id: ChildMotionWarmupStepId;
|
||||
@@ -151,12 +149,6 @@ export const CHILD_MOTION_WARMUP_STEPS: ChildMotionWarmupStep[] = [
|
||||
title: '准备开始',
|
||||
spokenLines: ['现在开始我们的游戏吧'],
|
||||
},
|
||||
{
|
||||
id: 'play_placeholder',
|
||||
kind: 'placeholder',
|
||||
title: '下一关',
|
||||
spokenLines: ['游戏关卡正在准备中'],
|
||||
},
|
||||
];
|
||||
|
||||
const STEP_BY_ID = new Map(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
@@ -65,6 +66,8 @@ type CustomWorldCreationHubProps = {
|
||||
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
|
||||
onClaimPuzzlePointIncentive?: ((item: PuzzleWorkSummary) => void) | null;
|
||||
claimingPuzzleProfileId?: string | null;
|
||||
babyObjectMatchItems?: BabyObjectMatchDraft[];
|
||||
onOpenBabyObjectMatchDetail?: ((item: BabyObjectMatchDraft) => void) | null;
|
||||
visualNovelItems?: VisualNovelWorkSummary[];
|
||||
onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null;
|
||||
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
|
||||
@@ -167,6 +170,8 @@ export function CustomWorldCreationHub({
|
||||
onDeletePuzzle = null,
|
||||
onClaimPuzzlePointIncentive = null,
|
||||
claimingPuzzleProfileId = null,
|
||||
babyObjectMatchItems = [],
|
||||
onOpenBabyObjectMatchDetail = null,
|
||||
visualNovelItems = [],
|
||||
onOpenVisualNovelDetail = null,
|
||||
onDeleteVisualNovel = null,
|
||||
@@ -189,6 +194,7 @@ export function CustomWorldCreationHub({
|
||||
match3dItems,
|
||||
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
|
||||
puzzleItems,
|
||||
babyObjectMatchItems,
|
||||
visualNovelItems,
|
||||
canDeleteRpg: Boolean(onDeletePublished),
|
||||
canDeleteBigFish: Boolean(onDeleteBigFish),
|
||||
@@ -209,6 +215,7 @@ export function CustomWorldCreationHub({
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle: onDeletePuzzle ?? undefined,
|
||||
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
|
||||
onOpenBabyObjectMatchDetail: onOpenBabyObjectMatchDetail ?? undefined,
|
||||
onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined,
|
||||
onDeleteVisualNovel: onDeleteVisualNovel ?? undefined,
|
||||
getItemState: getWorkState,
|
||||
@@ -216,6 +223,7 @@ export function CustomWorldCreationHub({
|
||||
[
|
||||
bigFishItems,
|
||||
isSquareHoleCreationVisible,
|
||||
babyObjectMatchItems,
|
||||
items,
|
||||
match3dItems,
|
||||
onDeleteBigFish,
|
||||
@@ -228,6 +236,7 @@ export function CustomWorldCreationHub({
|
||||
onOpenBigFishDetail,
|
||||
onOpenDraft,
|
||||
onOpenMatch3DDetail,
|
||||
onOpenBabyObjectMatchDetail,
|
||||
onOpenPuzzleDetail,
|
||||
onOpenSquareHoleDetail,
|
||||
onOpenVisualNovelDetail,
|
||||
@@ -259,6 +268,37 @@ export function CustomWorldCreationHub({
|
||||
[activeFilter, shelfItems],
|
||||
);
|
||||
|
||||
function handleOpenShelfItem(item: CreationWorkShelfItem) {
|
||||
switch (item.source.kind) {
|
||||
case 'puzzle':
|
||||
onOpenPuzzleDetail?.(item.source.item);
|
||||
return;
|
||||
case 'baby-object-match':
|
||||
onOpenBabyObjectMatchDetail?.(item.source.item);
|
||||
return;
|
||||
case 'visual-novel':
|
||||
onOpenVisualNovelDetail?.(item.source.item);
|
||||
return;
|
||||
case 'big-fish':
|
||||
onOpenBigFishDetail?.(item.source.item);
|
||||
return;
|
||||
case 'match3d':
|
||||
onOpenMatch3DDetail?.(item.source.item);
|
||||
return;
|
||||
case 'square-hole':
|
||||
onOpenSquareHoleDetail?.(item.source.item);
|
||||
return;
|
||||
case 'rpg':
|
||||
if (item.status === 'draft') {
|
||||
onOpenDraft(item.source.item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.source.item.profileId) {
|
||||
onEnterPublished(item.source.item.profileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildDeleteAction(item: CreationWorkShelfItem) {
|
||||
if (!item.canDelete) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import { buildCreationWorkShelfItems } from './creationWorkShelf';
|
||||
|
||||
test('buildCreationWorkShelfItems maps visual novel items with VN public code', () => {
|
||||
@@ -81,3 +82,62 @@ test('buildCreationWorkShelfItems attaches open and delete actions through shelf
|
||||
expect(onOpenPuzzleDetail).toHaveBeenCalledWith(puzzleWork);
|
||||
expect(onDeletePuzzle).toHaveBeenCalledWith(puzzleWork);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
|
||||
const baseDraft: BabyObjectMatchDraft = {
|
||||
draftId: 'baby-object-draft-1',
|
||||
profileId: 'baby-object-profile-12345678',
|
||||
templateId: 'baby-object-match',
|
||||
templateName: '宝贝识物',
|
||||
workTitle: '宝贝识物',
|
||||
workDescription: '苹果和香蕉识物分类',
|
||||
itemNames: ['苹果', '香蕉'],
|
||||
itemAssets: [
|
||||
{
|
||||
itemId: 'baby-object-item-1',
|
||||
itemName: '苹果',
|
||||
imageSrc: '/apple.png',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '苹果',
|
||||
},
|
||||
{
|
||||
itemId: 'baby-object-item-2',
|
||||
itemName: '香蕉',
|
||||
imageSrc: '/banana.png',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '香蕉',
|
||||
},
|
||||
],
|
||||
themeTags: ['寓教于乐'],
|
||||
publicationStatus: 'draft',
|
||||
createdAt: '2026-05-11T00:00:00.000Z',
|
||||
updatedAt: '2026-05-11T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
};
|
||||
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [],
|
||||
babyObjectMatchItems: [
|
||||
baseDraft,
|
||||
{
|
||||
...baseDraft,
|
||||
draftId: 'baby-object-draft-2',
|
||||
profileId: 'baby-object-profile-87654321',
|
||||
publicationStatus: 'published',
|
||||
publishedAt: '2026-05-11T01:00:00.000Z',
|
||||
updatedAt: '2026-05-11T01:00:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(items[0]?.kind).toBe('baby-object-match');
|
||||
expect(items[0]?.status).toBe('published');
|
||||
expect(items[0]?.publicWorkCode).toBe('BO-87654321');
|
||||
expect(items[0]?.sharePath).toContain('/works/detail?work=BO-87654321');
|
||||
expect(items[1]?.status).toBe('draft');
|
||||
expect(items[1]?.publicWorkCode).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
@@ -7,6 +8,7 @@ import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contrac
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||
import {
|
||||
buildBabyObjectMatchPublicWorkCode,
|
||||
buildBigFishPublicWorkCode,
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
@@ -21,6 +23,7 @@ export type CreationWorkShelfKind =
|
||||
| 'match3d'
|
||||
| 'square-hole'
|
||||
| 'puzzle'
|
||||
| 'baby-object-match'
|
||||
| 'visual-novel';
|
||||
export type CreationWorkShelfStatus = 'draft' | 'published';
|
||||
|
||||
@@ -77,6 +80,10 @@ export type CreationWorkShelfSource =
|
||||
| {
|
||||
kind: 'visual-novel';
|
||||
item: VisualNovelWorkSummary;
|
||||
}
|
||||
| {
|
||||
kind: 'baby-object-match';
|
||||
item: BabyObjectMatchDraft;
|
||||
};
|
||||
|
||||
export type CreationWorkShelfActions = {
|
||||
@@ -116,6 +123,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
match3dItems?: Match3DWorkSummary[];
|
||||
squareHoleItems?: SquareHoleWorkSummary[];
|
||||
puzzleItems: PuzzleWorkSummary[];
|
||||
babyObjectMatchItems?: BabyObjectMatchDraft[];
|
||||
visualNovelItems?: VisualNovelWorkSummary[];
|
||||
canDeleteRpg?: boolean;
|
||||
canDeleteBigFish?: boolean;
|
||||
@@ -135,6 +143,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
|
||||
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
|
||||
onOpenBabyObjectMatchDetail?: (item: BabyObjectMatchDraft) => void;
|
||||
onOpenVisualNovelDetail?: (item: VisualNovelWorkSummary) => void;
|
||||
onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void;
|
||||
getItemState?: (
|
||||
@@ -148,6 +157,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
match3dItems = [],
|
||||
squareHoleItems = [],
|
||||
puzzleItems,
|
||||
babyObjectMatchItems = [],
|
||||
visualNovelItems = [],
|
||||
canDeleteRpg = false,
|
||||
canDeleteBigFish = false,
|
||||
@@ -167,6 +177,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle,
|
||||
onClaimPuzzlePointIncentive,
|
||||
onOpenBabyObjectMatchDetail,
|
||||
onOpenVisualNovelDetail,
|
||||
onDeleteVisualNovel,
|
||||
getItemState,
|
||||
@@ -205,6 +216,11 @@ export function buildCreationWorkShelfItems(params: {
|
||||
onClaimPointIncentive: onClaimPuzzlePointIncentive,
|
||||
}),
|
||||
),
|
||||
...babyObjectMatchItems.map((item) =>
|
||||
mapBabyObjectMatchDraftToShelfItem(item, {
|
||||
onOpen: onOpenBabyObjectMatchDetail,
|
||||
}),
|
||||
),
|
||||
...visualNovelItems.map((item) =>
|
||||
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, {
|
||||
onOpen: onOpenVisualNovelDetail,
|
||||
@@ -446,6 +462,55 @@ function mapPuzzleWorkToShelfItem(
|
||||
};
|
||||
}
|
||||
|
||||
function mapBabyObjectMatchDraftToShelfItem(
|
||||
item: BabyObjectMatchDraft,
|
||||
adapter: WorkShelfAdapter<BabyObjectMatchDraft>,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
|
||||
const publicWorkCode =
|
||||
status === 'published'
|
||||
? buildBabyObjectMatchPublicWorkCode(item.profileId)
|
||||
: null;
|
||||
const coverImageSrc =
|
||||
item.itemAssets.find((asset) => asset.imageSrc.trim())?.imageSrc ?? null;
|
||||
|
||||
return {
|
||||
id: item.profileId,
|
||||
kind: 'baby-object-match',
|
||||
status,
|
||||
title: item.workTitle.trim() || item.templateName,
|
||||
summary:
|
||||
item.workDescription.trim() ||
|
||||
`${item.itemNames[0]}和${item.itemNames[1]}识物分类`,
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
publicWorkCode,
|
||||
sharePath:
|
||||
publicWorkCode && status === 'published'
|
||||
? buildPublicWorkStagePath('work-detail', publicWorkCode)
|
||||
: null,
|
||||
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
|
||||
canDelete: false,
|
||||
canShare: status === 'published' && Boolean(publicWorkCode),
|
||||
badges: [
|
||||
buildStatusBadge(status),
|
||||
{ id: 'type', label: '宝贝识物', tone: 'neutral' },
|
||||
],
|
||||
metrics:
|
||||
status === 'published'
|
||||
? buildPublishedMetrics({
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
})
|
||||
: [],
|
||||
actions: buildWorkShelfActions(item, adapter),
|
||||
source: { kind: 'baby-object-match', item },
|
||||
};
|
||||
}
|
||||
|
||||
function mapVisualNovelWorkToShelfItem(
|
||||
item: VisualNovelWorkSummary,
|
||||
canDelete: boolean,
|
||||
@@ -541,7 +606,6 @@ function mapSquareHoleWorkToShelfItem(
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function buildWorkShelfActions<TItem>(
|
||||
item: TItem,
|
||||
adapter: WorkShelfAdapter<TItem>,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { BabyObjectMatchWorkspace } from './BabyObjectMatchWorkspace';
|
||||
|
||||
test('baby object match workspace requires two item names before submit', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCreateDraft = vi.fn();
|
||||
|
||||
render(
|
||||
<BabyObjectMatchWorkspace onBack={() => {}} onCreateDraft={onCreateDraft} />,
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /生成宝贝识物草稿/u,
|
||||
});
|
||||
expect(submitButton).toHaveProperty('disabled', true);
|
||||
|
||||
await user.type(screen.getByLabelText('物品 A'), '苹果');
|
||||
expect(submitButton).toHaveProperty('disabled', true);
|
||||
|
||||
await user.type(screen.getByLabelText('物品 B'), '香蕉');
|
||||
expect(submitButton).toHaveProperty('disabled', false);
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(onCreateDraft).toHaveBeenCalledWith({
|
||||
itemAName: '苹果',
|
||||
itemBName: '香蕉',
|
||||
});
|
||||
});
|
||||
|
||||
test('baby object match workspace calls back when return button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBack = vi.fn();
|
||||
|
||||
render(
|
||||
<BabyObjectMatchWorkspace onBack={onBack} onCreateDraft={() => {}} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
187
src/components/edutainment-creation/BabyObjectMatchWorkspace.tsx
Normal file
187
src/components/edutainment-creation/BabyObjectMatchWorkspace.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { ArrowLeft, Gift, Loader2, WandSparkles } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { CreateBabyObjectMatchDraftRequest } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import { validateBabyObjectMatchItemNames } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
|
||||
type BabyObjectMatchWorkspaceProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
initialPayload?: CreateBabyObjectMatchDraftRequest | null;
|
||||
onBack: () => void;
|
||||
onCreateDraft: (payload: CreateBabyObjectMatchDraftRequest) => void;
|
||||
showBackButton?: boolean;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
type BabyObjectMatchFormState = {
|
||||
itemAName: string;
|
||||
itemBName: string;
|
||||
};
|
||||
|
||||
function resolveInitialFormState(
|
||||
initialPayload: CreateBabyObjectMatchDraftRequest | null | undefined,
|
||||
): BabyObjectMatchFormState {
|
||||
return {
|
||||
itemAName: initialPayload?.itemAName ?? '',
|
||||
itemBName: initialPayload?.itemBName ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export function BabyObjectMatchWorkspace({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
initialPayload = null,
|
||||
onBack,
|
||||
onCreateDraft,
|
||||
showBackButton = true,
|
||||
title = null,
|
||||
}: BabyObjectMatchWorkspaceProps) {
|
||||
const [formState, setFormState] = useState<BabyObjectMatchFormState>(() =>
|
||||
resolveInitialFormState(initialPayload),
|
||||
);
|
||||
const appliedInitialKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextInitialKey = JSON.stringify(initialPayload ?? null);
|
||||
if (appliedInitialKeyRef.current === nextInitialKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
appliedInitialKeyRef.current = nextInitialKey;
|
||||
setFormState(resolveInitialFormState(initialPayload));
|
||||
}, [initialPayload]);
|
||||
|
||||
const validation = useMemo(
|
||||
() => validateBabyObjectMatchItemNames(formState),
|
||||
[formState],
|
||||
);
|
||||
const canSubmit = validation.valid && !isBusy;
|
||||
|
||||
const submitForm = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
onCreateDraft({
|
||||
itemAName: validation.itemAName,
|
||||
itemBName: validation.itemBName,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden">
|
||||
{showBackButton ? (
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{title ? (
|
||||
<div className="mb-3 shrink-0 sm:mb-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
|
||||
{title}
|
||||
</h1>
|
||||
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
|
||||
BETA
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className={`grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(14rem,0.56fr)] ${isBusy ? 'opacity-55' : ''}`}
|
||||
>
|
||||
<div className="grid min-h-0 gap-3 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<label className="block min-h-0">
|
||||
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||
物品 A
|
||||
</span>
|
||||
<input
|
||||
value={formState.itemAName}
|
||||
disabled={isBusy}
|
||||
placeholder=""
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
itemAName: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="min-h-12 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-emerald-200 focus:bg-white focus:ring-2 focus:ring-emerald-100"
|
||||
aria-label="物品 A"
|
||||
/>
|
||||
</label>
|
||||
<label className="block min-h-0">
|
||||
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||
物品 B
|
||||
</span>
|
||||
<input
|
||||
value={formState.itemBName}
|
||||
disabled={isBusy}
|
||||
placeholder=""
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
itemBName: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="min-h-12 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-emerald-200 focus:bg-white focus:ring-2 focus:ring-emerald-100"
|
||||
aria-label="物品 B"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-[8rem] overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-[linear-gradient(145deg,rgba(236,253,245,0.92),rgba(255,247,237,0.86))] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)]">
|
||||
<div className="absolute -right-8 -top-8 h-28 w-28 rounded-full bg-emerald-200/42" />
|
||||
<div className="absolute -bottom-10 left-6 h-24 w-24 rounded-full bg-amber-200/48" />
|
||||
<div className="relative flex h-full min-h-[7rem] flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="grid h-14 w-14 place-items-center rounded-[1.1rem] bg-white/82 text-emerald-600 shadow-[0_12px_30px_rgba(16,185,129,0.14)]">
|
||||
<Gift className="h-7 w-7" />
|
||||
</div>
|
||||
<div className="text-lg font-black text-[var(--platform-text-strong)]">
|
||||
宝贝识物
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 shrink-0 space-y-3">
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={submitForm}
|
||||
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
<span>生成宝贝识物草稿</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BabyObjectMatchWorkspace;
|
||||
@@ -0,0 +1,105 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
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 { BabyObjectMatchResultView } from './BabyObjectMatchResultView';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
|
||||
}));
|
||||
|
||||
function createDraft(overrides: Partial<BabyObjectMatchDraft> = {}) {
|
||||
const draft: BabyObjectMatchDraft = {
|
||||
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,a',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '苹果',
|
||||
},
|
||||
{
|
||||
itemId: 'baby-object-item-2',
|
||||
itemName: '香蕉',
|
||||
imageSrc: 'data:image/svg+xml;utf8,b',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '香蕉',
|
||||
},
|
||||
],
|
||||
themeTags: ['宝贝识物'],
|
||||
publicationStatus: 'draft',
|
||||
createdAt: '2026-05-11T00:00:00.000Z',
|
||||
updatedAt: '2026-05-11T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
test('baby object result publishes with exact edutainment tag', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPublish = vi.fn();
|
||||
|
||||
render(
|
||||
<BabyObjectMatchResultView
|
||||
draft={createDraft()}
|
||||
onBack={() => {}}
|
||||
onPublish={onPublish}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '发布' }));
|
||||
|
||||
expect(onPublish).toHaveBeenCalledTimes(1);
|
||||
expect(onPublish.mock.calls[0]?.[0].themeTags[0]).toBe(
|
||||
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
|
||||
);
|
||||
expect(onPublish.mock.calls[0]?.[0].themeTags).toContain('宝贝识物');
|
||||
});
|
||||
|
||||
test('baby object result exposes save and test run actions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaveDraft = vi.fn();
|
||||
const onStartTestRun = vi.fn();
|
||||
|
||||
render(
|
||||
<BabyObjectMatchResultView
|
||||
draft={createDraft()}
|
||||
onBack={() => {}}
|
||||
onSaveDraft={onSaveDraft}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '保存草稿' }));
|
||||
await user.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
expect(onSaveDraft).toHaveBeenCalledTimes(1);
|
||||
expect(onStartTestRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
166
src/components/edutainment-result/BabyObjectMatchResultView.tsx
Normal file
166
src/components/edutainment-result/BabyObjectMatchResultView.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { ArrowLeft, CheckCircle2, Loader2, Play, Save, Tag } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import {
|
||||
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
|
||||
hasBabyObjectMatchRequiredTag,
|
||||
normalizeBabyObjectMatchTags,
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type BabyObjectMatchResultViewProps = {
|
||||
draft: BabyObjectMatchDraft;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onSaveDraft?: (draft: BabyObjectMatchDraft) => void;
|
||||
onPublish?: (draft: BabyObjectMatchDraft) => void;
|
||||
onStartTestRun?: (draft: BabyObjectMatchDraft) => void;
|
||||
};
|
||||
|
||||
function normalizeDraftForAction(draft: BabyObjectMatchDraft) {
|
||||
return {
|
||||
...draft,
|
||||
themeTags: normalizeBabyObjectMatchTags(draft.themeTags),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function BabyObjectMatchResultView({
|
||||
draft,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSaveDraft,
|
||||
onPublish,
|
||||
onStartTestRun,
|
||||
}: BabyObjectMatchResultViewProps) {
|
||||
const normalizedDraft = useMemo(() => normalizeDraftForAction(draft), [draft]);
|
||||
const publishReady =
|
||||
normalizedDraft.itemNames.every((itemName) => itemName.trim()) &&
|
||||
normalizedDraft.itemAssets.every((asset) => asset.imageSrc.trim()) &&
|
||||
hasBabyObjectMatchRequiredTag(normalizedDraft.themeTags);
|
||||
const isPublished = normalizedDraft.publicationStatus === 'published';
|
||||
|
||||
return (
|
||||
<div className="platform-page-stage platform-remap-surface flex h-full min-h-0 flex-col overflow-hidden px-3 pb-3 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-4 xl:pt-4">
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</button>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
|
||||
{isPublished ? '已发布' : '草稿'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-0.5">
|
||||
<section className="grid gap-3 lg:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]">
|
||||
<div className="rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]">
|
||||
<div className="text-sm font-black text-[var(--platform-text-soft)]">
|
||||
模板
|
||||
</div>
|
||||
<h1 className="mt-2 m-0 text-3xl font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:text-5xl">
|
||||
{normalizedDraft.workTitle}
|
||||
</h1>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{normalizedDraft.themeTags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-black ${
|
||||
tag === BABY_OBJECT_MATCH_EDUTAINMENT_TAG
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/72 text-[var(--platform-text-base)]'
|
||||
}`}
|
||||
>
|
||||
<Tag className="h-3 w-3" />
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{normalizedDraft.itemAssets.map((asset) => (
|
||||
<article
|
||||
key={asset.itemId}
|
||||
className="overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/72 shadow-[inset_0_1px_0_rgba(255,255,255,0.76)]"
|
||||
>
|
||||
<div className="relative aspect-square overflow-hidden bg-[linear-gradient(145deg,rgba(236,253,245,0.92),rgba(255,247,237,0.86))]">
|
||||
<ResolvedAssetImage
|
||||
src={asset.imageSrc}
|
||||
alt={asset.itemName}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{asset.generationProvider === 'placeholder' ? (
|
||||
<span className="absolute right-2 top-2 rounded-full bg-white/86 px-2 py-0.5 text-[10px] font-black text-[var(--platform-text-soft)] shadow-sm">
|
||||
占位图
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="truncate text-lg font-black text-[var(--platform-text-strong)]">
|
||||
{asset.itemName}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid shrink-0 gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || !onSaveDraft}
|
||||
onClick={() => onSaveDraft?.(normalizedDraft)}
|
||||
className="platform-button platform-button--secondary justify-center disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
保存草稿
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || !onStartTestRun}
|
||||
onClick={() => onStartTestRun?.(normalizedDraft)}
|
||||
className="platform-button platform-button--secondary justify-center disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
试玩
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || !publishReady || !onPublish}
|
||||
onClick={() => onPublish?.(normalizedDraft)}
|
||||
className="platform-button platform-button--primary justify-center disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
)}
|
||||
发布
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BabyObjectMatchResultView;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,583 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Gift,
|
||||
PartyPopper,
|
||||
RotateCcw,
|
||||
SkipForward,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
BabyObjectMatchDraft,
|
||||
BabyObjectMatchItemAsset,
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type {
|
||||
MocapHandInput,
|
||||
MocapInputCommand,
|
||||
UseMocapInputResult,
|
||||
} from '../../services/useMocapInput';
|
||||
import { useMocapInput } from '../../services/useMocapInput';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
const BABY_OBJECT_MATCH_SUCCESS_TARGET = 20;
|
||||
const BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS = 760;
|
||||
const BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE = 0.05;
|
||||
const BABY_OBJECT_MATCH_HAND_PATH_LIMIT = 16;
|
||||
|
||||
type BabyObjectMatchRuntimeShellProps = {
|
||||
draft: BabyObjectMatchDraft;
|
||||
embedded?: boolean;
|
||||
enableMocapInput?: boolean;
|
||||
mocapInput?: UseMocapInputResult | null;
|
||||
random?: BabyObjectMatchRandom;
|
||||
onBack?: () => void;
|
||||
onNextLevel?: () => void;
|
||||
};
|
||||
|
||||
type BasketSide = 'left' | 'right';
|
||||
type RuntimePhase = 'waiting' | 'active' | 'correct' | 'wrong' | 'complete';
|
||||
|
||||
type RuntimeRound = {
|
||||
item: BabyObjectMatchItemAsset;
|
||||
baskets: Record<BasketSide, BabyObjectMatchItemAsset>;
|
||||
};
|
||||
|
||||
type DragState = {
|
||||
side: BasketSide;
|
||||
startX: number;
|
||||
lastX: number;
|
||||
};
|
||||
|
||||
type RuntimeHandPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type RuntimeMocapHandPaths = {
|
||||
left: RuntimeHandPoint[];
|
||||
right: RuntimeHandPoint[];
|
||||
};
|
||||
|
||||
type BabyObjectMatchRandom = () => number;
|
||||
|
||||
const OPEN_PALM_ACTIONS = [
|
||||
'open_palm',
|
||||
'open_palm_up',
|
||||
'open',
|
||||
'palm',
|
||||
'hand_open',
|
||||
];
|
||||
|
||||
const GRAB_ACTIONS = [
|
||||
'grab',
|
||||
'grabbing',
|
||||
'close',
|
||||
'fist',
|
||||
'closed_fist',
|
||||
'closed',
|
||||
];
|
||||
|
||||
function pickRandomIndex(length: number, random: BabyObjectMatchRandom) {
|
||||
if (length <= 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(length - 1, Math.floor(random() * length));
|
||||
}
|
||||
|
||||
function buildRuntimeRound(
|
||||
draft: BabyObjectMatchDraft,
|
||||
random: BabyObjectMatchRandom,
|
||||
): RuntimeRound {
|
||||
const items = draft.itemAssets;
|
||||
const item = items[pickRandomIndex(items.length, random)] ?? items[0]!;
|
||||
|
||||
return {
|
||||
item,
|
||||
baskets: {
|
||||
left: items[0]!,
|
||||
right: items[1]!,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isHorizontalDrag(dragState: DragState) {
|
||||
return (
|
||||
Math.abs(dragState.lastX - dragState.startX) >=
|
||||
BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE
|
||||
);
|
||||
}
|
||||
|
||||
function hasMocapAction(command: MocapInputCommand, actions: string[]) {
|
||||
return command.actions.some((action) => actions.includes(action));
|
||||
}
|
||||
|
||||
function mocapHandToRuntimePoint(
|
||||
hand: MocapHandInput | null | undefined,
|
||||
): RuntimeHandPoint | null {
|
||||
if (!hand) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { x: hand.x, y: hand.y };
|
||||
}
|
||||
|
||||
function appendRuntimeHandPoint(
|
||||
points: RuntimeHandPoint[],
|
||||
point: RuntimeHandPoint,
|
||||
) {
|
||||
return [...points, point].slice(-BABY_OBJECT_MATCH_HAND_PATH_LIMIT);
|
||||
}
|
||||
|
||||
function hasRuntimeHorizontalMovePath(points: RuntimeHandPoint[]) {
|
||||
if (points.length < 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const xValues = points.map((point) => point.x);
|
||||
return (
|
||||
Math.max(...xValues) - Math.min(...xValues) >=
|
||||
BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMocapHandPaths(
|
||||
command: MocapInputCommand,
|
||||
currentPaths: RuntimeMocapHandPaths,
|
||||
) {
|
||||
// 本地 mocap 当前按摄像头视角输出 handedness,这里换回用户身体视角再选篮。
|
||||
const leftPoint = mocapHandToRuntimePoint(command.rightHand);
|
||||
const rightPoint = mocapHandToRuntimePoint(command.leftHand);
|
||||
|
||||
return {
|
||||
left: leftPoint
|
||||
? appendRuntimeHandPoint(currentPaths.left, leftPoint)
|
||||
: currentPaths.left,
|
||||
right: rightPoint
|
||||
? appendRuntimeHandPoint(currentPaths.right, rightPoint)
|
||||
: currentPaths.right,
|
||||
} satisfies RuntimeMocapHandPaths;
|
||||
}
|
||||
|
||||
function hasOpenPalmMocapHand(command: MocapInputCommand) {
|
||||
return (
|
||||
hasMocapAction(command, OPEN_PALM_ACTIONS) ||
|
||||
Boolean(command.hands?.some((hand) => hand.state === 'open_palm')) ||
|
||||
command.leftHand?.state === 'open_palm' ||
|
||||
command.rightHand?.state === 'open_palm' ||
|
||||
command.primaryHand?.state === 'open_palm'
|
||||
);
|
||||
}
|
||||
|
||||
function hasGrabMocapHand(command: MocapInputCommand) {
|
||||
return (
|
||||
hasMocapAction(command, GRAB_ACTIONS) ||
|
||||
Boolean(command.hands?.some((hand) => hand.state === 'grab')) ||
|
||||
command.leftHand?.state === 'grab' ||
|
||||
command.rightHand?.state === 'grab' ||
|
||||
command.primaryHand?.state === 'grab'
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMocapHorizontalMoveSide(
|
||||
paths: RuntimeMocapHandPaths,
|
||||
): BasketSide | null {
|
||||
if (hasRuntimeHorizontalMovePath(paths.left)) {
|
||||
return 'left';
|
||||
}
|
||||
|
||||
if (hasRuntimeHorizontalMovePath(paths.right)) {
|
||||
return 'right';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildMocapPacketKey(
|
||||
command: MocapInputCommand,
|
||||
rawPacketPreview: UseMocapInputResult['rawPacketPreview'],
|
||||
) {
|
||||
return rawPacketPreview?.receivedAtMs !== undefined
|
||||
? `${rawPacketPreview.receivedAtMs}:${rawPacketPreview.text}`
|
||||
: JSON.stringify(command);
|
||||
}
|
||||
|
||||
export function BabyObjectMatchRuntimeShell({
|
||||
draft,
|
||||
embedded = false,
|
||||
enableMocapInput = true,
|
||||
mocapInput = null,
|
||||
random,
|
||||
onBack,
|
||||
onNextLevel,
|
||||
}: BabyObjectMatchRuntimeShellProps) {
|
||||
const randomRef = useRef<BabyObjectMatchRandom>(random ?? (() => Math.random()));
|
||||
const feedbackTimerRef = useRef<number | null>(null);
|
||||
const dragStateRef = useRef<DragState | null>(null);
|
||||
const handledMocapPacketKeyRef = useRef<string | null>(null);
|
||||
const hasOpenPalmBeforeGrabRef = useRef(false);
|
||||
const mocapHandPathsRef = useRef<RuntimeMocapHandPaths>({
|
||||
left: [],
|
||||
right: [],
|
||||
});
|
||||
const [phase, setPhase] = useState<RuntimePhase>('waiting');
|
||||
const [successCount, setSuccessCount] = useState(0);
|
||||
const [round, setRound] = useState<RuntimeRound | null>(null);
|
||||
const [feedbackText, setFeedbackText] = useState<string | null>(null);
|
||||
const [lastTargetSide, setLastTargetSide] = useState<BasketSide | null>(null);
|
||||
const liveMocapInput = useMocapInput({
|
||||
enabled: enableMocapInput && !mocapInput,
|
||||
});
|
||||
const resolvedMocapInput = mocapInput ?? liveMocapInput;
|
||||
|
||||
const progressText = `${successCount}/${BABY_OBJECT_MATCH_SUCCESS_TARGET}`;
|
||||
const isComplete = phase === 'complete';
|
||||
const currentItem = round?.item ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
randomRef.current = random ?? (() => Math.random());
|
||||
}, [random]);
|
||||
|
||||
const clearFeedbackTimer = useCallback(() => {
|
||||
if (feedbackTimerRef.current !== null) {
|
||||
window.clearTimeout(feedbackTimerRef.current);
|
||||
feedbackTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openGiftBox = useCallback(() => {
|
||||
if (phase !== 'waiting') {
|
||||
return;
|
||||
}
|
||||
|
||||
clearFeedbackTimer();
|
||||
setFeedbackText(null);
|
||||
setLastTargetSide(null);
|
||||
setRound(buildRuntimeRound(draft, randomRef.current));
|
||||
setPhase('active');
|
||||
}, [clearFeedbackTimer, draft, phase]);
|
||||
|
||||
const resetRuntime = useCallback(() => {
|
||||
clearFeedbackTimer();
|
||||
dragStateRef.current = null;
|
||||
handledMocapPacketKeyRef.current = null;
|
||||
hasOpenPalmBeforeGrabRef.current = false;
|
||||
mocapHandPathsRef.current = { left: [], right: [] };
|
||||
setSuccessCount(0);
|
||||
setRound(null);
|
||||
setFeedbackText(null);
|
||||
setLastTargetSide(null);
|
||||
setPhase('waiting');
|
||||
}, [clearFeedbackTimer]);
|
||||
|
||||
const finishFeedback = useCallback(
|
||||
(nextSuccessCount: number, wasCorrect: boolean) => {
|
||||
clearFeedbackTimer();
|
||||
feedbackTimerRef.current = window.setTimeout(() => {
|
||||
feedbackTimerRef.current = null;
|
||||
if (wasCorrect) {
|
||||
if (nextSuccessCount >= BABY_OBJECT_MATCH_SUCCESS_TARGET) {
|
||||
setFeedbackText('恭喜你!小朋友!');
|
||||
setRound(null);
|
||||
setPhase('complete');
|
||||
return;
|
||||
}
|
||||
|
||||
setRound(null);
|
||||
setFeedbackText(null);
|
||||
setLastTargetSide(null);
|
||||
setPhase('waiting');
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedbackText(null);
|
||||
setLastTargetSide(null);
|
||||
mocapHandPathsRef.current = { left: [], right: [] };
|
||||
setPhase('active');
|
||||
}, BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS);
|
||||
},
|
||||
[clearFeedbackTimer],
|
||||
);
|
||||
|
||||
const sendItemToBasket = useCallback(
|
||||
(side: BasketSide) => {
|
||||
if (phase !== 'active' || !round) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCorrect = round.baskets[side].itemId === round.item.itemId;
|
||||
setLastTargetSide(side);
|
||||
if (isCorrect) {
|
||||
const nextSuccessCount = successCount + 1;
|
||||
setSuccessCount(nextSuccessCount);
|
||||
setFeedbackText('真棒');
|
||||
setPhase('correct');
|
||||
finishFeedback(nextSuccessCount, true);
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedbackText('再想一想吧');
|
||||
setPhase('wrong');
|
||||
finishFeedback(successCount, false);
|
||||
},
|
||||
[finishFeedback, phase, round, successCount],
|
||||
);
|
||||
|
||||
useEffect(() => clearFeedbackTimer, [clearFeedbackTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'waiting') {
|
||||
mocapHandPathsRef.current = { left: [], right: [] };
|
||||
return;
|
||||
}
|
||||
hasOpenPalmBeforeGrabRef.current = false;
|
||||
}, [phase]);
|
||||
|
||||
useEffect(() => {
|
||||
const command = resolvedMocapInput.latestCommand;
|
||||
if (!command || isComplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
const packetKey = buildMocapPacketKey(
|
||||
command,
|
||||
resolvedMocapInput.rawPacketPreview,
|
||||
);
|
||||
if (handledMocapPacketKeyRef.current === packetKey) {
|
||||
return;
|
||||
}
|
||||
handledMocapPacketKeyRef.current = packetKey;
|
||||
|
||||
if (phase === 'waiting') {
|
||||
if (hasGrabMocapHand(command) && hasOpenPalmBeforeGrabRef.current) {
|
||||
hasOpenPalmBeforeGrabRef.current = false;
|
||||
mocapHandPathsRef.current = { left: [], right: [] };
|
||||
openGiftBox();
|
||||
return;
|
||||
}
|
||||
if (hasOpenPalmMocapHand(command)) {
|
||||
hasOpenPalmBeforeGrabRef.current = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase !== 'active') {
|
||||
mocapHandPathsRef.current = { left: [], right: [] };
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPaths = resolveMocapHandPaths(
|
||||
command,
|
||||
mocapHandPathsRef.current,
|
||||
);
|
||||
mocapHandPathsRef.current = nextPaths;
|
||||
|
||||
const targetSide = resolveMocapHorizontalMoveSide(nextPaths);
|
||||
if (targetSide) {
|
||||
sendItemToBasket(targetSide);
|
||||
mocapHandPathsRef.current = { left: [], right: [] };
|
||||
}
|
||||
}, [
|
||||
isComplete,
|
||||
openGiftBox,
|
||||
phase,
|
||||
resolvedMocapInput.latestCommand,
|
||||
resolvedMocapInput.rawPacketPreview,
|
||||
sendItemToBasket,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key.toLowerCase() !== 'f') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
openGiftBox();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [openGiftBox]);
|
||||
|
||||
const getPointerUnitX = (
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
element: HTMLElement,
|
||||
) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const width = rect.width || 1;
|
||||
return Math.max(0, Math.min(1, (event.clientX - rect.left) / width));
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (event.button !== 0 && event.button !== 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const side: BasketSide = event.button === 2 ? 'right' : 'left';
|
||||
const pointerX = getPointerUnitX(event, event.currentTarget);
|
||||
dragStateRef.current = {
|
||||
side,
|
||||
startX: pointerX,
|
||||
lastX: pointerX,
|
||||
};
|
||||
event.preventDefault();
|
||||
if (typeof event.currentTarget.setPointerCapture === 'function') {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (!dragStateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragStateRef.current = {
|
||||
...dragStateRef.current,
|
||||
lastX: getPointerUnitX(event, event.currentTarget),
|
||||
};
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
const dragState = dragStateRef.current;
|
||||
dragStateRef.current = null;
|
||||
if (
|
||||
typeof event.currentTarget.hasPointerCapture === 'function' &&
|
||||
event.currentTarget.hasPointerCapture(event.pointerId)
|
||||
) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
if (!dragState || !isHorizontalDrag(dragState)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendItemToBasket(dragState.side);
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
className={`baby-object-runtime${embedded ? ' baby-object-runtime--embedded' : ''}`}
|
||||
data-testid="baby-object-match-runtime"
|
||||
>
|
||||
<section
|
||||
className="baby-object-runtime__stage"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
{onBack ? (
|
||||
<button
|
||||
type="button"
|
||||
className="baby-object-runtime__back"
|
||||
onClick={onBack}
|
||||
aria-label="返回"
|
||||
title="返回"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="baby-object-runtime__subtitle" role="status">
|
||||
将物品放入对应的篮子里
|
||||
</div>
|
||||
|
||||
<div className="baby-object-runtime__counter" aria-label="成功次数">
|
||||
{progressText}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`baby-object-runtime__gift${phase === 'active' || phase === 'correct' || phase === 'wrong' ? ' baby-object-runtime__gift--open' : ''}`}
|
||||
aria-label="礼物盒"
|
||||
>
|
||||
<Gift className="baby-object-runtime__gift-icon" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`baby-object-runtime__item${
|
||||
phase === 'correct'
|
||||
? ` baby-object-runtime__item--to-${lastTargetSide ?? 'left'}`
|
||||
: phase === 'wrong'
|
||||
? ` baby-object-runtime__item--wrong-${lastTargetSide ?? 'left'}`
|
||||
: ''
|
||||
}`}
|
||||
data-testid="baby-object-current-item"
|
||||
aria-live="polite"
|
||||
>
|
||||
{currentItem ? (
|
||||
<>
|
||||
<ResolvedAssetImage
|
||||
src={currentItem.imageSrc}
|
||||
alt={currentItem.itemName}
|
||||
className="baby-object-runtime__item-image"
|
||||
/>
|
||||
<span className="baby-object-runtime__item-name">
|
||||
{currentItem.itemName}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{feedbackText ? (
|
||||
<div
|
||||
className={`baby-object-runtime__feedback baby-object-runtime__feedback--${phase}`}
|
||||
>
|
||||
{feedbackText}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isComplete ? (
|
||||
<div className="baby-object-runtime__complete" role="dialog">
|
||||
<PartyPopper className="h-8 w-8" />
|
||||
<div>恭喜你!小朋友!</div>
|
||||
<div className="baby-object-runtime__complete-actions">
|
||||
<button type="button" onClick={resetRuntime}>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
再来一次
|
||||
</button>
|
||||
<button type="button" onClick={onNextLevel}>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
下一关
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="baby-object-runtime__baskets">
|
||||
{(['left', 'right'] as const).map((side) => {
|
||||
const basketItem = round?.baskets[side] ?? draft.itemAssets[side === 'left' ? 0 : 1];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={side}
|
||||
className={`baby-object-runtime__basket baby-object-runtime__basket--${side}`}
|
||||
aria-label={`${side === 'left' ? '左侧' : '右侧'}篮子 ${basketItem.itemName}`}
|
||||
>
|
||||
<div className="baby-object-runtime__basket-icon">
|
||||
<ResolvedAssetImage
|
||||
src={basketItem.imageSrc}
|
||||
alt={basketItem.itemName}
|
||||
className="baby-object-runtime__basket-image"
|
||||
/>
|
||||
</div>
|
||||
<div className="baby-object-runtime__basket-body" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default BabyObjectMatchRuntimeShell;
|
||||
@@ -21,6 +21,7 @@ export interface PlatformEntryCreationTypeModalProps {
|
||||
onSelectPuzzle: () => void;
|
||||
onSelectCreativeAgent: () => void;
|
||||
onSelectVisualNovel: () => void;
|
||||
onSelectBabyObjectMatch: () => void;
|
||||
}
|
||||
|
||||
function CreationTypeCard(props: {
|
||||
@@ -101,6 +102,7 @@ export function PlatformEntryCreationTypeModal({
|
||||
onSelectPuzzle,
|
||||
onSelectCreativeAgent,
|
||||
onSelectVisualNovel,
|
||||
onSelectBabyObjectMatch,
|
||||
}: PlatformEntryCreationTypeModalProps) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
@@ -147,6 +149,9 @@ export function PlatformEntryCreationTypeModal({
|
||||
if (item.id === 'visual-novel') {
|
||||
onSelectVisualNovel();
|
||||
}
|
||||
if (item.id === 'baby-object-match') {
|
||||
onSelectBabyObjectMatch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,12 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { act } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PlatformPuzzleGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
type PlatformEdutainmentGalleryCard,
|
||||
type PlatformPuzzleGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
@@ -52,6 +57,31 @@ function createPuzzleEntry(): PlatformPuzzleGalleryCard {
|
||||
};
|
||||
}
|
||||
|
||||
function createBabyObjectMatchEntry(): PlatformEdutainmentGalleryCard {
|
||||
return {
|
||||
sourceType: 'edutainment',
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workId: 'baby-object-match-work-1',
|
||||
profileId: 'baby-object-match-profile-1',
|
||||
publicWorkCode: 'EDU-BABY01',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '百梦主',
|
||||
worldName: '宝贝识物水果篮',
|
||||
subtitle: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
summaryText: '将物品放入对应的篮子里。',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['寓教于乐'],
|
||||
playCount: 12,
|
||||
remixCount: 0,
|
||||
likeCount: 4,
|
||||
recentPlayCount7d: 0,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-11T10:00:00.000Z',
|
||||
updatedAt: '2026-05-11T12:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -140,6 +170,23 @@ test('PlatformWorkDetailView switches remix action label for owned work edit', (
|
||||
expect(screen.queryByRole('button', { name: '作品改造' })).toBeNull();
|
||||
});
|
||||
|
||||
test('PlatformWorkDetailView labels baby object match works', () => {
|
||||
render(
|
||||
<PlatformWorkDetailView
|
||||
entry={createBabyObjectMatchEntry()}
|
||||
isBusy={false}
|
||||
error={null}
|
||||
onBack={vi.fn()}
|
||||
onLike={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
onRemix={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('宝贝识物')).toBeTruthy();
|
||||
expect(screen.getByText('EDU-BABY01')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('PlatformWorkDetailView cycles puzzle level cover slides', () => {
|
||||
vi.useFakeTimers();
|
||||
const { container } = render(
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
formatPlatformWorkDisplayName,
|
||||
formatPlatformWorkDisplayTags,
|
||||
formatPlatformWorldTime,
|
||||
isEdutainmentGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorldCoverSlides,
|
||||
@@ -66,6 +67,9 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
|
||||
if ('sourceType' in entry && entry.sourceType === 'visual-novel') {
|
||||
return '视觉小说';
|
||||
}
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return entry.templateName;
|
||||
}
|
||||
return 'RPG';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
canExposePublicWork,
|
||||
filterEdutainmentPublicWorks,
|
||||
@@ -28,6 +32,27 @@ function buildPuzzleCard(themeTags: string[]): PlatformPublicGalleryCard {
|
||||
};
|
||||
}
|
||||
|
||||
function buildBabyObjectMatchCard(themeTags: string[]): PlatformPublicGalleryCard {
|
||||
return {
|
||||
sourceType: 'edutainment',
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workId: 'baby-object-match-work-1',
|
||||
profileId: 'baby-object-match-profile-1',
|
||||
publicWorkCode: 'EDU-BABY01',
|
||||
ownerUserId: 'user-education',
|
||||
authorDisplayName: '动作 Demo 作者',
|
||||
worldName: '宝贝识物水果篮',
|
||||
subtitle: '宝贝识物',
|
||||
summaryText: '将物品放入对应的篮子里。',
|
||||
coverImageSrc: null,
|
||||
themeTags,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-11T10:00:00.000Z',
|
||||
updatedAt: '2026-05-11T10:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
@@ -56,4 +81,14 @@ describe('platformEdutainmentVisibility', () => {
|
||||
expect(canExposePublicWork(exact)).toBe(false);
|
||||
expect(canExposePublicWork(general)).toBe(true);
|
||||
});
|
||||
|
||||
test('applies the same exact tag rule to baby object match cards', () => {
|
||||
const exact = buildBabyObjectMatchCard(['寓教于乐', '宝贝识物']);
|
||||
const fuzzy = buildBabyObjectMatchCard(['寓教于乐 ', '宝贝识物']);
|
||||
|
||||
expect(isEdutainmentPublicWork(exact)).toBe(true);
|
||||
expect(isEdutainmentPublicWork(fuzzy)).toBe(false);
|
||||
expect(filterEdutainmentPublicWorks([exact, fuzzy])).toEqual([exact]);
|
||||
expect(filterGeneralPublicWorks([exact, fuzzy])).toEqual([fuzzy]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
derivePlatformCreationTypes,
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
isPlatformCreationTypeVisible,
|
||||
} from './platformEntryCreationTypes';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test('database entry config controls visibility open state and display order', () => {
|
||||
const cards = derivePlatformCreationTypes([
|
||||
{
|
||||
@@ -100,10 +104,9 @@ test('visible platform creation types hide invisible cards and put locked cards
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getVisiblePlatformCreationTypes(cards).map((item) => item.id)).toEqual([
|
||||
'open',
|
||||
'locked',
|
||||
]);
|
||||
expect(getVisiblePlatformCreationTypes(cards).map((item) => item.id)).toEqual(
|
||||
['open', 'locked'],
|
||||
);
|
||||
expect(isPlatformCreationTypeVisible(cards, 'hidden')).toBe(false);
|
||||
expect(isPlatformCreationTypeVisible(cards, 'open')).toBe(true);
|
||||
expect(
|
||||
@@ -113,3 +116,65 @@ test('visible platform creation types hide invisible cards and put locked cards
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('edutainment switch hides baby object match creation entry from database config', () => {
|
||||
const cards = derivePlatformCreationTypes([
|
||||
{
|
||||
id: 'baby-object-match',
|
||||
title: '宝贝识物',
|
||||
subtitle: '亲子识物分类',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/baby-object-match.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 1,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
subtitle: '拼图',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 2,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(isPlatformCreationTypeVisible(cards, 'baby-object-match')).toBe(true);
|
||||
|
||||
vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false');
|
||||
|
||||
const hiddenCards = derivePlatformCreationTypes([
|
||||
{
|
||||
id: 'baby-object-match',
|
||||
title: '宝贝识物',
|
||||
subtitle: '亲子识物分类',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/baby-object-match.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 1,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
subtitle: '拼图',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 2,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(isPlatformCreationTypeVisible(hiddenCards, 'baby-object-match')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
getVisiblePlatformCreationTypes(hiddenCards).map((item) => item.id),
|
||||
).toEqual(['puzzle']);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
|
||||
import { isEdutainmentEntryEnabled } from './platformEdutainmentVisibility';
|
||||
|
||||
export type PlatformCreationTypeId = string;
|
||||
|
||||
@@ -46,7 +47,9 @@ export function derivePlatformCreationTypes(
|
||||
badge: item.badge,
|
||||
imageSrc: item.imageSrc,
|
||||
locked: !item.open,
|
||||
hidden: !item.visible,
|
||||
hidden:
|
||||
!item.visible ||
|
||||
(item.id === 'baby-object-match' && !isEdutainmentEntryEnabled()),
|
||||
}));
|
||||
|
||||
return [
|
||||
|
||||
@@ -37,6 +37,10 @@ export type SelectionStage =
|
||||
| 'visual-novel-result'
|
||||
| 'visual-novel-gallery-detail'
|
||||
| 'visual-novel-runtime'
|
||||
| 'baby-object-match-workspace'
|
||||
| 'baby-object-match-generating'
|
||||
| 'baby-object-match-result'
|
||||
| 'baby-object-match-runtime'
|
||||
| 'puzzle-agent-workspace'
|
||||
| 'puzzle-generating'
|
||||
| 'puzzle-onboarding'
|
||||
|
||||
@@ -25,9 +25,12 @@ import {
|
||||
RpgEntryHomeView,
|
||||
type RpgEntryHomeViewProps,
|
||||
} from './RpgEntryHomeView';
|
||||
import type {
|
||||
PlatformPublicGalleryCard,
|
||||
PlatformPuzzleGalleryCard,
|
||||
import {
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
type PlatformEdutainmentGalleryCard,
|
||||
type PlatformPublicGalleryCard,
|
||||
type PlatformPuzzleGalleryCard,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
|
||||
const {
|
||||
@@ -445,6 +448,37 @@ function buildTaggedPuzzleEntry(
|
||||
} satisfies PlatformPuzzleGalleryCard;
|
||||
}
|
||||
|
||||
function buildBabyObjectMatchEntry(
|
||||
id: string,
|
||||
worldName: string,
|
||||
themeTags: string[] = ['寓教于乐'],
|
||||
overrides: Partial<PlatformEdutainmentGalleryCard> = {},
|
||||
) {
|
||||
return {
|
||||
sourceType: 'edutainment',
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workId: `baby-object-match-work-${id}`,
|
||||
profileId: `baby-object-match-profile-${id}`,
|
||||
publicWorkCode: `EDU-${id.toUpperCase()}`,
|
||||
ownerUserId: 'user-edutainment',
|
||||
authorDisplayName: '动作 Demo 作者',
|
||||
worldName,
|
||||
subtitle: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
summaryText: '将物品放入对应的篮子里。',
|
||||
coverImageSrc: null,
|
||||
themeTags,
|
||||
playCount: 8,
|
||||
remixCount: 0,
|
||||
likeCount: 4,
|
||||
recentPlayCount7d: 5,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-11T10:00:00.000Z',
|
||||
updatedAt: '2026-05-11T10:00:00.000Z',
|
||||
...overrides,
|
||||
} satisfies PlatformEdutainmentGalleryCard;
|
||||
}
|
||||
|
||||
function mockDesktopLayout() {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
@@ -1312,6 +1346,49 @@ test('mobile discover hides edutainment channel and work when switch is disabled
|
||||
expect(onSearchPublicCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('mobile discover keeps baby object match works in edutainment channel only', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchPublicCode = vi.fn();
|
||||
const onOpenGalleryDetail = vi.fn();
|
||||
const babyObjectMatchEntry = buildBabyObjectMatchEntry(
|
||||
'baby01',
|
||||
'宝贝识物水果篮',
|
||||
);
|
||||
const generalEntry = buildTaggedPuzzleEntry('normal02', '普通拼图作品', [
|
||||
'儿童教育',
|
||||
]);
|
||||
|
||||
renderStatefulLoggedOutHomeView({
|
||||
latestEntries: [babyObjectMatchEntry, generalEntry],
|
||||
onOpenGalleryDetail,
|
||||
onSearchPublicCode,
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
const discoverPanel = document.getElementById('platform-tab-panel-category');
|
||||
if (!discoverPanel) {
|
||||
throw new Error('缺少发现面板');
|
||||
}
|
||||
|
||||
expect(within(discoverPanel).getByText('普通拼图作品')).toBeTruthy();
|
||||
expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '寓教于乐' }));
|
||||
const babyObjectMatchButton = within(discoverPanel).getByRole('button', {
|
||||
name: /宝贝识物水果篮/u,
|
||||
});
|
||||
expect(within(babyObjectMatchButton).getByText('宝贝识物')).toBeTruthy();
|
||||
expect(within(discoverPanel).queryByText('普通拼图作品')).toBeNull();
|
||||
|
||||
await user.click(babyObjectMatchButton);
|
||||
expect(onOpenGalleryDetail).toHaveBeenCalledWith(babyObjectMatchEntry);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, '宝贝识物水果篮{enter}');
|
||||
expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
|
||||
expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull();
|
||||
expect(onSearchPublicCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('discover search keeps public code fallback when local works do not match', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchPublicCode = vi.fn();
|
||||
|
||||
@@ -92,6 +92,7 @@ import {
|
||||
formatPlatformWorkDisplayTag,
|
||||
formatPlatformWorldTime,
|
||||
isBigFishGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
isMatch3DGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
isSquareHoleGalleryEntry,
|
||||
@@ -1193,7 +1194,9 @@ function DesktopTrendingItem({
|
||||
? '大鱼'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? '拼图'
|
||||
: describePublicGalleryCardKind(entry)}
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? entry.templateName
|
||||
: describePublicGalleryCardKind(entry)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1510,7 +1513,9 @@ function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
|
||||
? 'square-hole'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? 'visual-novel'
|
||||
: 'rpg';
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? `edutainment:${entry.templateId}`
|
||||
: 'rpg';
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
@@ -1622,7 +1627,9 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
|
||||
? '方洞'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? '视觉'
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? entry.templateName
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
return formatPlatformWorkDisplayTag(kind);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPuzzleWorkCoverSlides,
|
||||
buildPlatformWorldDisplayTags,
|
||||
buildPuzzleWorkCoverSlides,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
formatPlatformWorkDisplayName,
|
||||
formatPlatformWorkDisplayTags,
|
||||
formatPlatformWorldTime,
|
||||
isEdutainmentGalleryEntry,
|
||||
isVisualNovelGalleryEntry,
|
||||
mapBabyObjectMatchDraftToPlatformGalleryCard,
|
||||
mapVisualNovelWorkToPlatformGalleryCard,
|
||||
type PlatformEdutainmentGalleryCard,
|
||||
resolvePlatformPublicWorkCode,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
|
||||
@@ -132,3 +137,73 @@ test('maps visual novel work to platform gallery card with VN public code', () =
|
||||
expect(resolvePlatformPublicWorkCode(card)).toBe('VN-12345678');
|
||||
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['悬疑', '列车']);
|
||||
});
|
||||
|
||||
test('keeps baby object match public card code and template label intact', () => {
|
||||
const card: PlatformEdutainmentGalleryCard = {
|
||||
sourceType: 'edutainment',
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workId: 'baby-object-match-work-1',
|
||||
profileId: 'baby-object-match-profile-1',
|
||||
sourceSessionId: 'baby-object-match-session-1',
|
||||
publicWorkCode: 'EDU-BABY01',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '百梦主',
|
||||
worldName: '宝贝识物水果篮',
|
||||
subtitle: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
summaryText: '将物品放入对应的篮子里。',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['寓教于乐'],
|
||||
playCount: 3,
|
||||
remixCount: 0,
|
||||
likeCount: 1,
|
||||
recentPlayCount7d: 3,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-11T10:00:00.000Z',
|
||||
updatedAt: '2026-05-11T10:00:00.000Z',
|
||||
};
|
||||
|
||||
expect(isEdutainmentGalleryEntry(card)).toBe(true);
|
||||
expect(resolvePlatformPublicWorkCode(card)).toBe('EDU-BABY01');
|
||||
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['寓教于乐']);
|
||||
});
|
||||
|
||||
test('maps baby object match draft to edutainment public card', () => {
|
||||
const card = mapBabyObjectMatchDraftToPlatformGalleryCard({
|
||||
draftId: 'baby-object-draft-1',
|
||||
profileId: 'baby-object-profile-12345678',
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workTitle: '宝贝识物水果篮',
|
||||
workDescription: '苹果和香蕉识物分类',
|
||||
itemNames: ['苹果', '香蕉'],
|
||||
itemAssets: [
|
||||
{
|
||||
itemId: 'baby-object-item-1',
|
||||
itemName: '苹果',
|
||||
imageSrc: '/apple.png',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '苹果',
|
||||
},
|
||||
{
|
||||
itemId: 'baby-object-item-2',
|
||||
itemName: '香蕉',
|
||||
imageSrc: '/banana.png',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '香蕉',
|
||||
},
|
||||
],
|
||||
themeTags: ['寓教于乐', '宝贝识物'],
|
||||
publicationStatus: 'published',
|
||||
createdAt: '2026-05-11T10:00:00.000Z',
|
||||
updatedAt: '2026-05-11T12:00:00.000Z',
|
||||
publishedAt: '2026-05-11T12:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(isEdutainmentGalleryEntry(card)).toBe(true);
|
||||
expect(card.publicWorkCode).toBe('BO-12345678');
|
||||
expect(card.coverImageSrc).toBe('/apple.png');
|
||||
expect(card.themeTags[0]).toBe('寓教于乐');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type {
|
||||
Match3DGeneratedItemAsset,
|
||||
Match3DWorkSummary,
|
||||
@@ -18,6 +20,7 @@ import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contra
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||
import {
|
||||
buildBabyObjectMatchPublicWorkCode,
|
||||
buildBigFishPublicWorkCode,
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
@@ -28,6 +31,8 @@ import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export const PLATFORM_WORK_NAME_DISPLAY_LIMIT = 8;
|
||||
export const PLATFORM_WORK_TAG_DISPLAY_LIMIT = 4;
|
||||
export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID = 'baby-object-match';
|
||||
export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME = '宝贝识物';
|
||||
|
||||
export type PlatformWorldCardLike =
|
||||
| CustomWorldGalleryCard
|
||||
@@ -36,7 +41,8 @@ export type PlatformWorldCardLike =
|
||||
| PlatformMatch3DGalleryCard
|
||||
| PlatformSquareHoleGalleryCard
|
||||
| PlatformPuzzleGalleryCard
|
||||
| PlatformVisualNovelGalleryCard;
|
||||
| PlatformVisualNovelGalleryCard
|
||||
| PlatformEdutainmentGalleryCard;
|
||||
|
||||
export type PlatformPuzzleGalleryCard = {
|
||||
sourceType: 'puzzle';
|
||||
@@ -161,13 +167,38 @@ export type PlatformVisualNovelGalleryCard = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformEdutainmentGalleryCard = {
|
||||
sourceType: 'edutainment';
|
||||
templateId: typeof EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID;
|
||||
templateName: typeof EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME;
|
||||
workId: string;
|
||||
profileId: string;
|
||||
sourceSessionId?: string | null;
|
||||
publicWorkCode: string;
|
||||
ownerUserId: string;
|
||||
authorDisplayName: string;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
themeTags: string[];
|
||||
playCount?: number;
|
||||
remixCount?: number;
|
||||
likeCount?: number;
|
||||
recentPlayCount7d?: number;
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformPublicGalleryCard =
|
||||
| CustomWorldGalleryCard
|
||||
| PlatformBigFishGalleryCard
|
||||
| PlatformMatch3DGalleryCard
|
||||
| PlatformSquareHoleGalleryCard
|
||||
| PlatformPuzzleGalleryCard
|
||||
| PlatformVisualNovelGalleryCard;
|
||||
| PlatformVisualNovelGalleryCard
|
||||
| PlatformEdutainmentGalleryCard;
|
||||
|
||||
export function isLibraryWorldEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
@@ -205,6 +236,12 @@ export function isVisualNovelGalleryEntry(
|
||||
return 'sourceType' in entry && entry.sourceType === 'visual-novel';
|
||||
}
|
||||
|
||||
export function isEdutainmentGalleryEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is PlatformEdutainmentGalleryCard {
|
||||
return 'sourceType' in entry && entry.sourceType === 'edutainment';
|
||||
}
|
||||
|
||||
export function mapPuzzleWorkToPlatformGalleryCard(
|
||||
work: PuzzleWorkSummary,
|
||||
): PlatformPuzzleGalleryCard {
|
||||
@@ -280,8 +317,7 @@ export function mapSquareHoleWorkToPlatformGalleryCard(
|
||||
holeOptions: work.holeOptions,
|
||||
shapeCount: work.shapeCount,
|
||||
difficulty: work.difficulty,
|
||||
themeTags:
|
||||
work.tags.length > 0 ? work.tags : [work.themeText, '方洞挑战'],
|
||||
themeTags: work.tags.length > 0 ? work.tags : [work.themeText, '方洞挑战'],
|
||||
playCount: work.playCount ?? 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
@@ -343,6 +379,40 @@ export function mapVisualNovelWorkToPlatformGalleryCard(
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBabyObjectMatchDraftToPlatformGalleryCard(
|
||||
draft: BabyObjectMatchDraft,
|
||||
): PlatformEdutainmentGalleryCard {
|
||||
return {
|
||||
sourceType: 'edutainment',
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workId: draft.profileId,
|
||||
profileId: draft.profileId,
|
||||
sourceSessionId: draft.draftId,
|
||||
publicWorkCode: buildBabyObjectMatchPublicWorkCode(draft.profileId),
|
||||
ownerUserId: 'current-user',
|
||||
authorDisplayName: '百梦主',
|
||||
worldName: draft.workTitle.trim() || draft.templateName,
|
||||
subtitle: draft.templateName,
|
||||
summaryText:
|
||||
draft.workDescription.trim() ||
|
||||
`${draft.itemNames[0]}和${draft.itemNames[1]}识物分类`,
|
||||
coverImageSrc:
|
||||
draft.itemAssets.find((asset) => asset.imageSrc.trim())?.imageSrc ?? null,
|
||||
themeTags:
|
||||
draft.themeTags.length > 0
|
||||
? draft.themeTags
|
||||
: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG],
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
recentPlayCount7d: 0,
|
||||
visibility: 'published',
|
||||
publishedAt: draft.publishedAt,
|
||||
updatedAt: draft.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) {
|
||||
return {
|
||||
playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0,
|
||||
@@ -482,9 +552,7 @@ export function formatPlatformWorkDisplayTags(
|
||||
) {
|
||||
return [
|
||||
...new Set(
|
||||
tags
|
||||
.map((tag) => formatPlatformWorkDisplayTag(tag))
|
||||
.filter(Boolean),
|
||||
tags.map((tag) => formatPlatformWorkDisplayTag(tag)).filter(Boolean),
|
||||
),
|
||||
].slice(0, limit);
|
||||
}
|
||||
@@ -506,13 +574,13 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['抓大鹅'];
|
||||
return entry.themeTags.length > 0
|
||||
? entry.themeTags.slice(0, 3)
|
||||
: ['抓大鹅'];
|
||||
}
|
||||
|
||||
if (isSquareHoleGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0
|
||||
? entry.themeTags.slice(0, 3)
|
||||
: ['方洞'];
|
||||
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['方洞'];
|
||||
}
|
||||
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
@@ -521,6 +589,12 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
: ['视觉小说'];
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0
|
||||
? entry.themeTags.slice(0, 3)
|
||||
: [entry.templateName];
|
||||
}
|
||||
|
||||
if (!isLibraryWorldEntry(entry)) {
|
||||
return [
|
||||
describePlatformThemeLabel(entry.themeMode),
|
||||
@@ -607,6 +681,10 @@ export function resolvePlatformPublicWorkCode(
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user