feat: add edutainment drawing and visual package flows

This commit is contained in:
2026-05-14 14:17:10 +08:00
parent 10e8beea80
commit e444266e1e
109 changed files with 8788 additions and 996 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -60,14 +60,25 @@ describe('childMotionWarmupModel', () => {
{
type: 'left-hand',
path: [
{ x: 0.3, y: 0.4 },
{ x: 0.34, y: 0.32 },
{ x: 0.3, y: 0.4, armAngleDeg: 12, armReach: 0.2 },
{ x: 0.34, y: 0.32, armAngleDeg: 44, armReach: 0.28 },
],
},
);
const withRightHand = applyChildMotionWarmupCompletion(
'wave_right_hand',
withLeftHand,
{
type: 'right-hand',
path: [
{ x: 0.7, y: 0.42, armAngleDeg: 10, armReach: 0.22 },
{ x: 0.82, y: 0.3, armAngleDeg: 46, armReach: 0.31 },
],
},
);
const completed = applyChildMotionWarmupCompletion(
'jump_once',
withLeftHand,
withRightHand,
{
type: 'jump',
jumpSpace: 0.14,
@@ -77,6 +88,16 @@ describe('childMotionWarmupModel', () => {
expect(completed.leftBoundary).toBeCloseTo(0.16);
expect(completed.rightBoundary).toBeCloseTo(0.16);
expect(completed.leftHandPath).toHaveLength(2);
expect(completed.leftHandSpace).toEqual({
minX: 0.3,
maxX: 0.34,
minY: 0.32,
maxY: 0.4,
minAngleDeg: 12,
maxAngleDeg: 44,
maxReach: 0.28,
});
expect(completed.rightHandSpace?.maxReach).toBe(0.31);
expect(completed.jumpSpace).toBe(0.14);
});
});
@@ -32,6 +32,20 @@ export type ChildMotionWarmupStep = {
export type ChildMotionPoint = {
x: number;
y: number;
isRaised?: boolean;
isArmExtended?: boolean;
armAngleDeg?: number;
armReach?: number;
};
export type ChildMotionHandSpace = {
minX: number;
maxX: number;
minY: number;
maxY: number;
minAngleDeg: number | null;
maxAngleDeg: number | null;
maxReach: number | null;
};
export type ChildMotionWarmupCalibration = {
@@ -39,6 +53,8 @@ export type ChildMotionWarmupCalibration = {
rightBoundary: number | null;
leftHandPath: ChildMotionPoint[];
rightHandPath: ChildMotionPoint[];
leftHandSpace: ChildMotionHandSpace | null;
rightHandSpace: ChildMotionHandSpace | null;
jumpSpace: number | null;
};
@@ -206,10 +222,39 @@ export function createEmptyChildMotionCalibration(): ChildMotionWarmupCalibratio
rightBoundary: null,
leftHandPath: [],
rightHandPath: [],
leftHandSpace: null,
rightHandSpace: null,
jumpSpace: null,
};
}
function resolveChildMotionHandSpace(
path: ChildMotionPoint[],
): ChildMotionHandSpace | null {
if (path.length === 0) {
return null;
}
const xValues = path.map((point) => point.x);
const yValues = path.map((point) => point.y);
const angleValues = path
.map((point) => point.armAngleDeg)
.filter((angle): angle is number => typeof angle === 'number');
const reachValues = path
.map((point) => point.armReach)
.filter((reach): reach is number => typeof reach === 'number');
return {
minX: Math.min(...xValues),
maxX: Math.max(...xValues),
minY: Math.min(...yValues),
maxY: Math.max(...yValues),
minAngleDeg: angleValues.length > 0 ? Math.min(...angleValues) : null,
maxAngleDeg: angleValues.length > 0 ? Math.max(...angleValues) : null,
maxReach: reachValues.length > 0 ? Math.max(...reachValues) : null,
};
}
export function applyChildMotionWarmupCompletion(
stepId: ChildMotionWarmupStepId,
calibration: ChildMotionWarmupCalibration,
@@ -233,6 +278,7 @@ export function applyChildMotionWarmupCompletion(
return {
...calibration,
leftHandPath: completion.path,
leftHandSpace: resolveChildMotionHandSpace(completion.path),
};
}
@@ -240,6 +286,7 @@ export function applyChildMotionWarmupCompletion(
return {
...calibration,
rightHandPath: completion.path,
rightHandSpace: resolveChildMotionHandSpace(completion.path),
};
}
@@ -5,9 +5,10 @@ import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
const noopCreateType = () => {};
@@ -189,6 +190,40 @@ const hiddenSquareHoleItem: SquareHoleWorkSummary = {
sourceSessionId: 'square-hole-session-hidden',
};
const babyObjectMatchDraftItem: BabyObjectMatchDraft = {
draftId: 'baby-object-draft-delete',
profileId: 'baby-object-profile-delete',
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: '香蕉',
},
],
visualPackage: null,
themeTags: ['寓教于乐'],
publicationStatus: 'draft',
createdAt: new Date('2026-05-11T10:00:00.000Z').toISOString(),
updatedAt: new Date('2026-05-11T10:00:00.000Z').toISOString(),
publishedAt: null,
};
test('creation hub reflects updated draft title summary and counts after rerender', async () => {
const user = userEvent.setup();
const onCreateType = vi.fn();
@@ -462,6 +497,34 @@ test('creation hub shows delete action for persisted rpg drafts', () => {
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
});
test('creation hub shows delete action for baby object match drafts', async () => {
const user = userEvent.setup();
const onDeleteBabyObjectMatch = vi.fn();
const onOpenBabyObjectMatchDetail = vi.fn();
render(
<CustomWorldCreationHub
items={[]}
babyObjectMatchItems={[babyObjectMatchDraftItem]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenBabyObjectMatchDetail={onOpenBabyObjectMatchDetail}
onDeleteBabyObjectMatch={onDeleteBabyObjectMatch}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
await user.click(screen.getByRole('button', { name: '删除' }));
expect(onDeleteBabyObjectMatch).toHaveBeenCalledWith(babyObjectMatchDraftItem);
expect(onOpenBabyObjectMatchDetail).not.toHaveBeenCalled();
});
test('creation hub published work delete action is available beside share without opening card', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();
@@ -68,6 +68,7 @@ type CustomWorldCreationHubProps = {
claimingPuzzleProfileId?: string | null;
babyObjectMatchItems?: BabyObjectMatchDraft[];
onOpenBabyObjectMatchDetail?: ((item: BabyObjectMatchDraft) => void) | null;
onDeleteBabyObjectMatch?: ((item: BabyObjectMatchDraft) => void) | null;
visualNovelItems?: VisualNovelWorkSummary[];
onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null;
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
@@ -172,6 +173,7 @@ export function CustomWorldCreationHub({
claimingPuzzleProfileId = null,
babyObjectMatchItems = [],
onOpenBabyObjectMatchDetail = null,
onDeleteBabyObjectMatch = null,
visualNovelItems = [],
onOpenVisualNovelDetail = null,
onDeleteVisualNovel = null,
@@ -202,6 +204,7 @@ export function CustomWorldCreationHub({
canDeleteSquareHole:
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
onOpenRpgDraft: onOpenDraft,
onEnterRpgPublished: onEnterPublished,
@@ -216,6 +219,7 @@ export function CustomWorldCreationHub({
onDeletePuzzle: onDeletePuzzle ?? undefined,
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
onOpenBabyObjectMatchDetail: onOpenBabyObjectMatchDetail ?? undefined,
onDeleteBabyObjectMatch: onDeleteBabyObjectMatch ?? undefined,
onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined,
onDeleteVisualNovel: onDeleteVisualNovel ?? undefined,
getItemState: getWorkState,
@@ -231,6 +235,7 @@ export function CustomWorldCreationHub({
onDeleteSquareHole,
onDeletePublished,
onDeletePuzzle,
onDeleteBabyObjectMatch,
onDeleteVisualNovel,
onClaimPuzzlePointIncentive,
onOpenBigFishDetail,
@@ -269,6 +274,7 @@ export function CustomWorldCreationHub({
);
function handleOpenShelfItem(item: CreationWorkShelfItem) {
onOpenShelfItem?.(item);
switch (item.source.kind) {
case 'puzzle':
onOpenPuzzleDetail?.(item.source.item);
@@ -379,8 +385,7 @@ export function CustomWorldCreationHub({
metricSnapshot[buildWorkMetricCacheItemKey(item)]
}
onOpen={() => {
onOpenShelfItem?.(item);
item.actions.open();
handleOpenShelfItem(item);
}}
onDelete={buildDeleteAction(item)}
deleteBusy={deletingWorkId === item.id}
@@ -87,6 +87,8 @@ test('buildCreationWorkShelfItems attaches open and delete actions through shelf
});
test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
const onOpenBabyObjectMatchDetail = vi.fn();
const onDeleteBabyObjectMatch = vi.fn();
const baseDraft: BabyObjectMatchDraft = {
draftId: 'baby-object-draft-1',
profileId: 'baby-object-profile-12345678',
@@ -113,6 +115,7 @@ test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
prompt: '香蕉',
},
],
visualPackage: null,
themeTags: ['寓教于乐'],
publicationStatus: 'draft',
createdAt: '2026-05-11T00:00:00.000Z',
@@ -135,14 +138,23 @@ test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
updatedAt: '2026-05-11T01:00:00.000Z',
},
],
canDeleteBabyObjectMatch: true,
onOpenBabyObjectMatchDetail,
onDeleteBabyObjectMatch,
});
items[1]?.actions.open();
items[1]?.actions.delete?.();
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();
expect(items[1]?.canDelete).toBe(true);
expect(onOpenBabyObjectMatchDetail).toHaveBeenCalledWith(baseDraft);
expect(onDeleteBabyObjectMatch).toHaveBeenCalledWith(baseDraft);
});
test('buildCreationWorkShelfItems sorts works by latest updatedAt across timestamp formats', () => {
@@ -130,6 +130,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteMatch3D?: boolean;
canDeleteSquareHole?: boolean;
canDeletePuzzle?: boolean;
canDeleteBabyObjectMatch?: boolean;
canDeleteVisualNovel?: boolean;
onOpenRpgDraft?: (item: CustomWorldWorkSummary) => void;
onEnterRpgPublished?: (profileId: string) => void;
@@ -144,6 +145,7 @@ export function buildCreationWorkShelfItems(params: {
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
onOpenBabyObjectMatchDetail?: (item: BabyObjectMatchDraft) => void;
onDeleteBabyObjectMatch?: (item: BabyObjectMatchDraft) => void;
onOpenVisualNovelDetail?: (item: VisualNovelWorkSummary) => void;
onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void;
getItemState?: (
@@ -164,6 +166,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteMatch3D = false,
canDeleteSquareHole = false,
canDeletePuzzle = false,
canDeleteBabyObjectMatch = false,
canDeleteVisualNovel = false,
onOpenRpgDraft,
onEnterRpgPublished,
@@ -178,6 +181,7 @@ export function buildCreationWorkShelfItems(params: {
onDeletePuzzle,
onClaimPuzzlePointIncentive,
onOpenBabyObjectMatchDetail,
onDeleteBabyObjectMatch,
onOpenVisualNovelDetail,
onDeleteVisualNovel,
getItemState,
@@ -217,8 +221,9 @@ export function buildCreationWorkShelfItems(params: {
}),
),
...babyObjectMatchItems.map((item) =>
mapBabyObjectMatchDraftToShelfItem(item, {
mapBabyObjectMatchDraftToShelfItem(item, canDeleteBabyObjectMatch, {
onOpen: onOpenBabyObjectMatchDetail,
onDelete: onDeleteBabyObjectMatch,
}),
),
...visualNovelItems.map((item) =>
@@ -467,6 +472,7 @@ function mapPuzzleWorkToShelfItem(
function mapBabyObjectMatchDraftToShelfItem(
item: BabyObjectMatchDraft,
canDelete: boolean,
adapter: WorkShelfAdapter<BabyObjectMatchDraft>,
): CreationWorkShelfItem {
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
@@ -495,7 +501,7 @@ function mapBabyObjectMatchDraftToShelfItem(
? buildPublicWorkStagePath('work-detail', publicWorkCode)
: null,
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
canDelete: false,
canDelete,
canShare: status === 'published' && Boolean(publicWorkCode),
badges: [
buildStatusBadge(status),
@@ -51,6 +51,7 @@ function createDraft(overrides: Partial<BabyObjectMatchDraft> = {}) {
prompt: '香蕉',
},
],
visualPackage: null,
themeTags: ['宝贝识物'],
publicationStatus: 'draft',
createdAt: '2026-05-11T00:00:00.000Z',
@@ -62,13 +63,81 @@ function createDraft(overrides: Partial<BabyObjectMatchDraft> = {}) {
return draft;
}
function createGeneratedDraft() {
return createDraft({
itemAssets: [
{
itemId: 'baby-object-item-1',
itemName: '苹果',
imageSrc: 'data:image/png;base64,a',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '苹果',
},
{
itemId: 'baby-object-item-2',
itemName: '香蕉',
imageSrc: 'data:image/png;base64,b',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '香蕉',
},
],
visualPackage: {
themePrompt: '果园主题',
assets: [
{
assetId: 'baby-object-visual-background',
assetKind: 'background',
imageSrc: 'data:image/png;base64,background',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'background',
},
{
assetId: 'baby-object-visual-ui-frame',
assetKind: 'ui-frame',
imageSrc: 'data:image/png;base64,ui',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'ui',
},
{
assetId: 'baby-object-visual-gift-box',
assetKind: 'gift-box',
imageSrc: 'data:image/png;base64,gift',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'gift',
},
{
assetId: 'baby-object-visual-basket',
assetKind: 'basket',
imageSrc: 'data:image/png;base64,basket',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'basket',
},
{
assetId: 'baby-object-visual-smoke-puff',
assetKind: 'smoke-puff',
imageSrc: 'data:image/png;base64,smoke',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'smoke',
},
],
},
});
}
test('baby object result publishes with exact edutainment tag', async () => {
const user = userEvent.setup();
const onPublish = vi.fn();
render(
<BabyObjectMatchResultView
draft={createDraft()}
draft={createGeneratedDraft()}
onBack={() => {}}
onPublish={onPublish}
/>,
@@ -90,7 +159,7 @@ test('baby object result exposes save and test run actions', async () => {
render(
<BabyObjectMatchResultView
draft={createDraft()}
draft={createGeneratedDraft()}
onBack={() => {}}
onSaveDraft={onSaveDraft}
onStartTestRun={onStartTestRun}
@@ -103,3 +172,38 @@ test('baby object result exposes save and test run actions', async () => {
expect(onSaveDraft).toHaveBeenCalledTimes(1);
expect(onStartTestRun).toHaveBeenCalledTimes(1);
});
test('baby object result blocks placeholder assets and exposes regeneration', async () => {
const user = userEvent.setup();
const onPublish = vi.fn();
const onStartTestRun = vi.fn();
const onRegenerateAssets = vi.fn();
render(
<BabyObjectMatchResultView
draft={createDraft()}
onBack={() => {}}
onPublish={onPublish}
onStartTestRun={onStartTestRun}
onRegenerateAssets={onRegenerateAssets}
/>,
);
expect(
screen.getByText('当前作品仍是占位资源,请重新生成 image-2 资源后再试玩或发布。'),
).toBeTruthy();
expect(
(screen.getByRole('button', { name: '试玩' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '发布' }) as HTMLButtonElement)
.disabled,
).toBe(true);
await user.click(screen.getByRole('button', { name: '重新生成资源' }));
expect(onRegenerateAssets).toHaveBeenCalledTimes(1);
expect(onPublish).not.toHaveBeenCalled();
expect(onStartTestRun).not.toHaveBeenCalled();
});
@@ -1,4 +1,12 @@
import { ArrowLeft, CheckCircle2, Loader2, Play, Save, Tag } from 'lucide-react';
import {
ArrowLeft,
CheckCircle2,
Loader2,
Play,
RefreshCw,
Save,
Tag,
} from 'lucide-react';
import { useMemo } from 'react';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
@@ -17,6 +25,7 @@ type BabyObjectMatchResultViewProps = {
onSaveDraft?: (draft: BabyObjectMatchDraft) => void;
onPublish?: (draft: BabyObjectMatchDraft) => void;
onStartTestRun?: (draft: BabyObjectMatchDraft) => void;
onRegenerateAssets?: (draft: BabyObjectMatchDraft) => void;
};
function normalizeDraftForAction(draft: BabyObjectMatchDraft) {
@@ -27,6 +36,14 @@ function normalizeDraftForAction(draft: BabyObjectMatchDraft) {
};
}
const REQUIRED_VISUAL_ASSET_KINDS = [
'background',
'ui-frame',
'gift-box',
'basket',
'smoke-puff',
] as const;
export function BabyObjectMatchResultView({
draft,
isBusy = false,
@@ -35,12 +52,29 @@ export function BabyObjectMatchResultView({
onSaveDraft,
onPublish,
onStartTestRun,
onRegenerateAssets,
}: BabyObjectMatchResultViewProps) {
const normalizedDraft = useMemo(() => normalizeDraftForAction(draft), [draft]);
const hasGeneratedAssets =
normalizedDraft.itemAssets.every(
(asset) =>
asset.generationProvider === 'vector-engine-gpt-image-2' &&
asset.imageSrc.startsWith('data:image/png;base64,'),
) &&
Boolean(normalizedDraft.visualPackage) &&
REQUIRED_VISUAL_ASSET_KINDS.every((kind) =>
normalizedDraft.visualPackage!.assets.some(
(asset) =>
asset.assetKind === kind &&
asset.generationProvider === 'vector-engine-gpt-image-2' &&
asset.imageSrc.startsWith('data:image/png;base64,'),
),
);
const publishReady =
normalizedDraft.itemNames.every((itemName) => itemName.trim()) &&
normalizedDraft.itemAssets.every((asset) => asset.imageSrc.trim()) &&
hasBabyObjectMatchRequiredTag(normalizedDraft.themeTags);
hasBabyObjectMatchRequiredTag(normalizedDraft.themeTags) &&
hasGeneratedAssets;
const isPublished = normalizedDraft.publicationStatus === 'published';
return (
@@ -123,9 +157,15 @@ export function BabyObjectMatchResultView({
{error}
</div>
) : null}
{!hasGeneratedAssets ? (
<div className="platform-banner mt-3 rounded-2xl text-sm leading-6">
当前作品仍是占位资源,请重新生成 image-2 资源后再试玩或发布。
</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">
<div className="mt-3 grid shrink-0 gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:grid-cols-4">
<button
type="button"
disabled={isBusy || !onSaveDraft}
@@ -137,7 +177,20 @@ export function BabyObjectMatchResultView({
</button>
<button
type="button"
disabled={isBusy || !onStartTestRun}
disabled={isBusy || !onRegenerateAssets}
onClick={() => onRegenerateAssets?.(normalizedDraft)}
className="platform-button platform-button--secondary justify-center disabled:cursor-not-allowed disabled:opacity-55"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
重新生成资源
</button>
<button
type="button"
disabled={isBusy || !hasGeneratedAssets || !onStartTestRun}
onClick={() => onStartTestRun?.(normalizedDraft)}
className="platform-button platform-button--secondary justify-center disabled:cursor-not-allowed disabled:opacity-55"
>
@@ -0,0 +1,246 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { BabyLoveDrawingRuntimeShell } from './BabyLoveDrawingRuntimeShell';
const saveBabyLoveDrawingMock = vi.fn();
const createBabyLoveDrawingMagicImageMock = vi.fn();
const mocapMock = vi.hoisted(() => ({
command: null as null | {
actions: string[];
leftHand?: {
x: number;
y: number;
state: 'open_palm' | 'grab' | 'unknown';
side: 'left';
} | null;
rightHand?: {
x: number;
y: number;
state: 'open_palm' | 'grab' | 'unknown';
side: 'right';
} | null;
},
}));
vi.mock('../../services/useMocapInput', () => ({
useMocapInput: () => ({
status: 'idle',
latestCommand: mocapMock.command,
rawPacketPreview: null,
error: null,
}),
}));
vi.mock('../../services/edutainment-baby-drawing', () => ({
createBabyLoveDrawingMagicImage: (...args: unknown[]) =>
createBabyLoveDrawingMagicImageMock(...args),
saveBabyLoveDrawing: (...args: unknown[]) => saveBabyLoveDrawingMock(...args),
}));
function installCanvasMock() {
const context = {
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fillRect: vi.fn(),
drawImage: vi.fn(),
set fillStyle(_value: string) {},
set strokeStyle(_value: string) {},
set lineWidth(_value: number) {},
set lineCap(_value: CanvasLineCap) {},
set lineJoin(_value: CanvasLineJoin) {},
set globalCompositeOperation(_value: GlobalCompositeOperation) {},
} as unknown as CanvasRenderingContext2D;
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue(
'data:image/png;base64,original',
);
}
beforeEach(() => {
installCanvasMock();
mocapMock.command = null;
saveBabyLoveDrawingMock.mockReturnValue({
record: {
drawingId: 'baby-love-drawing-local-1',
templateId: 'baby-love-drawing',
templateName: '宝贝爱画',
originalImageSrc: 'data:image/png;base64,original',
magicImageSrc: null,
strokeTrace: [],
saveMode: 'original-only',
themeTags: ['寓教于乐', '宝贝爱画'],
createdAt: '2026-05-13T08:00:00.000Z',
updatedAt: '2026-05-13T08:00:00.000Z',
},
});
createBabyLoveDrawingMagicImageMock.mockResolvedValue({
magicImageSrc: 'data:image/png;base64,magic',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '绘本风格',
});
});
afterEach(() => {
vi.restoreAllMocks();
});
test('renders drawing board, seven colors and tool buttons', () => {
render(<BabyLoveDrawingRuntimeShell />);
expect(screen.getByTestId('baby-love-drawing-runtime')).toBeTruthy();
expect(screen.getByLabelText('画板')).toBeTruthy();
expect(screen.getByLabelText('红')).toBeTruthy();
expect(screen.getByLabelText('紫')).toBeTruthy();
expect(screen.getAllByRole('button')).toHaveLength(11);
expect(screen.getByLabelText('画笔')).toBeTruthy();
expect(screen.getByLabelText('橡皮')).toBeTruthy();
});
test('finish then save stores original drawing in local demo service', () => {
render(<BabyLoveDrawingRuntimeShell />);
fireEvent.click(screen.getByRole('button', { name: '完成' }));
fireEvent.click(screen.getByRole('button', { name: '保存' }));
expect(saveBabyLoveDrawingMock).toHaveBeenCalledWith(
expect.objectContaining({
originalImageSrc: 'data:image/png;base64,original',
magicImageSrc: null,
}),
);
expect(screen.getByText('已保存')).toBeTruthy();
});
test('back button calls onBack callback', () => {
const onBack = vi.fn();
render(<BabyLoveDrawingRuntimeShell onBack={onBack} />);
fireEvent.click(screen.getByLabelText('返回'));
expect(onBack).toHaveBeenCalledTimes(1);
});
test('mocap camera-left hand drives the player right hand brush cursor', () => {
mocapMock.command = {
actions: [],
leftHand: { x: 0.72, y: 0.34, state: 'open_palm', side: 'left' },
rightHand: null,
};
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
const cursor = container.querySelector(
'.baby-love-drawing-runtime__cursor',
) as HTMLElement;
expect(cursor.style.left).toBe('72%');
expect(cursor.style.top).toBe('34%');
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: { x: 0.18, y: 0.82, state: 'grab', side: 'right' },
};
rerender(<BabyLoveDrawingRuntimeShell />);
expect(cursor.style.left).toBe('72%');
expect(cursor.style.top).toBe('34%');
});
test('mocap camera-right hand renders the player left hand color indicator', () => {
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: { x: 0.16, y: 0.42, state: 'open_palm', side: 'right' },
};
const { container } = render(<BabyLoveDrawingRuntimeShell />);
const indicator = container.querySelector(
'.baby-love-drawing-runtime__left-hand-indicator',
) as HTMLElement;
expect(indicator).toBeTruthy();
expect(indicator.style.left).toBe('16%');
expect(indicator.style.top).toBe('42%');
});
test('left hand indicator stays visible through brief mocap hand loss', () => {
vi.useFakeTimers();
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: { x: 0.16, y: 0.42, state: 'open_palm', side: 'right' },
};
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
vi.advanceTimersByTime(120);
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: null,
};
rerender(<BabyLoveDrawingRuntimeShell />);
const indicator = container.querySelector(
'.baby-love-drawing-runtime__left-hand-indicator',
) as HTMLElement;
expect(indicator).toBeTruthy();
expect(indicator.style.left).toBe('16%');
expect(indicator.style.top).toBe('42%');
});
test('player left hand never takes over the right hand brush cursor', () => {
mocapMock.command = {
actions: [],
leftHand: { x: 0.68, y: 0.32, state: 'open_palm', side: 'left' },
rightHand: { x: 0.18, y: 0.78, state: 'open_palm', side: 'right' },
};
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
const cursor = container.querySelector(
'.baby-love-drawing-runtime__cursor',
) as HTMLElement;
expect(cursor.style.left).toBe('68%');
expect(cursor.style.top).toBe('32%');
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: { x: 0.7, y: 0.3, state: 'grab', side: 'right' },
};
rerender(<BabyLoveDrawingRuntimeShell />);
expect(cursor.style.left).toBe('68%');
expect(cursor.style.top).toBe('32%');
});
test('large camera-left jump is rejected to prevent left hand stealing brush', () => {
mocapMock.command = {
actions: [],
leftHand: { x: 0.72, y: 0.34, state: 'open_palm', side: 'left' },
rightHand: null,
};
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
const cursor = container.querySelector(
'.baby-love-drawing-runtime__cursor',
) as HTMLElement;
expect(cursor.style.left).toBe('72%');
expect(cursor.style.top).toBe('34%');
mocapMock.command = {
actions: [],
leftHand: { x: 0.16, y: 0.82, state: 'grab', side: 'left' },
rightHand: null,
};
rerender(<BabyLoveDrawingRuntimeShell />);
expect(cursor.style.left).toBe('72%');
expect(cursor.style.top).toBe('34%');
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,96 @@
import { describe, expect, test } from 'vitest';
import {
appendPointToStroke,
BABY_LOVE_DRAWING_BUTTON_HOVER_MS,
BABY_LOVE_DRAWING_COLOR_HOVER_MS,
createBabyDrawingStroke,
hasHoverCompleted,
isPointInsideBounds,
resolveHoverProgress,
toCanvasPoint,
} from './babyLoveDrawingModel';
describe('babyLoveDrawingModel', () => {
test('completes color hover after 1.5 seconds', () => {
const target = { kind: 'color' as const, id: 'red' };
expect(
hasHoverCompleted(
target,
1000,
1000 + BABY_LOVE_DRAWING_COLOR_HOVER_MS - 1,
),
).toBe(false);
expect(
hasHoverCompleted(
target,
1000,
1000 + BABY_LOVE_DRAWING_COLOR_HOVER_MS,
),
).toBe(true);
});
test('completes button hover after 2 seconds', () => {
const target = { kind: 'button' as const, id: 'finish' };
expect(
hasHoverCompleted(
target,
1000,
1000 + BABY_LOVE_DRAWING_BUTTON_HOVER_MS - 1,
),
).toBe(false);
expect(
hasHoverCompleted(
target,
1000,
1000 + BABY_LOVE_DRAWING_BUTTON_HOVER_MS,
),
).toBe(true);
});
test('clamps hover progress and canvas point into unit bounds', () => {
const bounds = {
left: 0.25,
top: 0.2,
width: 0.5,
height: 0.4,
};
expect(resolveHoverProgress(null, null, 1000)).toBe(0);
expect(resolveHoverProgress({ kind: 'color', id: 'red' }, 0, 999999)).toBe(
1,
);
expect(isPointInsideBounds({ x: 0.4, y: 0.3 }, bounds)).toBe(true);
expect(isPointInsideBounds({ x: 0.1, y: 0.3 }, bounds)).toBe(false);
expect(toCanvasPoint({ x: 0.5, y: 0.4 }, bounds)).toMatchObject({
x: 0.5,
y: 0.5,
});
expect(toCanvasPoint({ x: 0.9, y: 0.9 }, bounds)).toMatchObject({
x: 1,
y: 1,
});
});
test('creates and extends stroke trace without mutating previous stroke', () => {
const stroke = createBabyDrawingStroke('brush', '#ef4444', {
x: 0.1,
y: 0.2,
t: 1,
});
const nextStroke = appendPointToStroke(stroke, {
x: 0.3,
y: 0.4,
t: 2,
});
expect(stroke.points).toHaveLength(1);
expect(nextStroke.points).toHaveLength(2);
expect(nextStroke).toMatchObject({
tool: 'brush',
color: '#ef4444',
});
});
});
@@ -0,0 +1,135 @@
import type {
BabyLoveDrawingPoint,
BabyLoveDrawingStroke,
BabyLoveDrawingTool,
} from '../../../packages/shared/src/contracts/edutainmentBabyDrawing';
import { BABY_LOVE_DRAWING_RAINBOW_COLORS } from '../../../packages/shared/src/contracts/edutainmentBabyDrawing';
export const BABY_LOVE_DRAWING_COLOR_HOVER_MS = 1500;
export const BABY_LOVE_DRAWING_BUTTON_HOVER_MS = 2000;
export const BABY_LOVE_DRAWING_BRUSH_SIZE = 10;
export const BABY_LOVE_DRAWING_ERASER_SIZE = 30;
export type BabyLoveDrawingPhase =
| 'drawing'
| 'finished'
| 'magicPending'
| 'magicReady'
| 'saved';
export type BabyLoveDrawingHoverTarget =
| {
kind: 'color';
id: string;
}
| {
kind: 'button';
id: string;
}
| null;
export type BabyLoveDrawingHandPoint = {
x: number;
y: number;
state: 'open_palm' | 'grab' | 'unknown';
};
export type BabyLoveDrawingBounds = {
left: number;
top: number;
width: number;
height: number;
};
export const BABY_LOVE_DRAWING_DEFAULT_COLOR =
BABY_LOVE_DRAWING_RAINBOW_COLORS[0].value;
export function clampBabyDrawingUnit(value: number) {
if (!Number.isFinite(value)) {
return 0;
}
return Math.max(0, Math.min(1, value));
}
export function normalizeBabyDrawingPoint(
point: Pick<BabyLoveDrawingHandPoint, 'x' | 'y'>,
): BabyLoveDrawingPoint {
return {
x: clampBabyDrawingUnit(point.x),
y: clampBabyDrawingUnit(point.y),
t: Date.now(),
};
}
export function isPointInsideBounds(
point: Pick<BabyLoveDrawingHandPoint, 'x' | 'y'>,
bounds: BabyLoveDrawingBounds,
) {
return (
point.x >= bounds.left &&
point.x <= bounds.left + bounds.width &&
point.y >= bounds.top &&
point.y <= bounds.top + bounds.height
);
}
export function toCanvasPoint(
point: Pick<BabyLoveDrawingHandPoint, 'x' | 'y'>,
bounds: BabyLoveDrawingBounds,
) {
return {
x: clampBabyDrawingUnit((point.x - bounds.left) / bounds.width),
y: clampBabyDrawingUnit((point.y - bounds.top) / bounds.height),
t: Date.now(),
};
}
export function appendPointToStroke(
stroke: BabyLoveDrawingStroke,
point: BabyLoveDrawingPoint,
): BabyLoveDrawingStroke {
return {
...stroke,
points: [...stroke.points, point],
};
}
export function createBabyDrawingStroke(
tool: BabyLoveDrawingTool,
color: string,
point: BabyLoveDrawingPoint,
): BabyLoveDrawingStroke {
return {
strokeId: `baby-drawing-stroke-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`,
tool,
color,
points: [point],
};
}
export function resolveHoverProgress(
target: BabyLoveDrawingHoverTarget,
startedAtMs: number | null,
nowMs: number,
) {
if (!target || startedAtMs === null) {
return 0;
}
const duration =
target.kind === 'color'
? BABY_LOVE_DRAWING_COLOR_HOVER_MS
: BABY_LOVE_DRAWING_BUTTON_HOVER_MS;
return clampBabyDrawingUnit((nowMs - startedAtMs) / duration);
}
export function hasHoverCompleted(
target: BabyLoveDrawingHoverTarget,
startedAtMs: number | null,
nowMs: number,
) {
return resolveHoverProgress(target, startedAtMs, nowMs) >= 1;
}
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ import { afterEach, expect, test, vi } from 'vitest';
import {
derivePlatformCreationTypes,
getVisiblePlatformCreationTypes,
isPlatformCreationTypeOpen,
isPlatformCreationTypeVisible,
} from './platformEntryCreationTypes';
@@ -109,6 +110,9 @@ test('visible platform creation types hide invisible cards and put locked cards
);
expect(isPlatformCreationTypeVisible(cards, 'hidden')).toBe(false);
expect(isPlatformCreationTypeVisible(cards, 'open')).toBe(true);
expect(isPlatformCreationTypeOpen(cards, 'hidden')).toBe(false);
expect(isPlatformCreationTypeOpen(cards, 'locked')).toBe(false);
expect(isPlatformCreationTypeOpen(cards, 'open')).toBe(true);
expect(
cards.every((item) =>
item.imageSrc.startsWith('/creation-type-references/'),
@@ -123,7 +127,7 @@ test('edutainment switch hides baby object match creation entry from database co
title: '宝贝识物',
subtitle: '亲子识物分类',
badge: '可创建',
imageSrc: '/creation-type-references/baby-object-match.webp',
imageSrc: '/child-motion-demo/picture-book-grass-stage.png',
visible: true,
open: true,
sortOrder: 1,
@@ -152,7 +156,7 @@ test('edutainment switch hides baby object match creation entry from database co
title: '宝贝识物',
subtitle: '亲子识物分类',
badge: '可创建',
imageSrc: '/creation-type-references/baby-object-match.webp',
imageSrc: '/child-motion-demo/picture-book-grass-stage.png',
visible: true,
open: true,
sortOrder: 1,
@@ -32,6 +32,15 @@ export function isPlatformCreationTypeVisible(
return creationTypes.some((item) => item.id === id && !item.hidden);
}
export function isPlatformCreationTypeOpen(
creationTypes: readonly PlatformCreationTypeCard[],
id: PlatformCreationTypeId,
) {
return creationTypes.some(
(item) => item.id === id && !item.hidden && !item.locked,
);
}
/**
* 创作入口卡片只做展示派生;配置事实源来自后端 API / SpacetimeDB,前端不再保留入口默认配置。
*/
@@ -41,6 +41,7 @@ export type SelectionStage =
| 'baby-object-match-generating'
| 'baby-object-match-result'
| 'baby-object-match-runtime'
| 'baby-love-drawing-runtime'
| 'puzzle-agent-workspace'
| 'puzzle-generating'
| 'puzzle-onboarding'
@@ -142,6 +142,8 @@ import {
listSquareHoleGallery,
listSquareHoleWorks,
} from '../../services/square-hole-works';
import { listVisualNovelGallery } from '../../services/visual-novel-runtime';
import { listVisualNovelWorks } from '../../services/visual-novel-works';
import { type CustomWorldProfile, WorldType } from '../../types';
import {
AuthUiContext,
@@ -318,6 +320,17 @@ const testCreationEntryConfig = {
sortOrder: 80,
updatedAtMicros: 1,
},
{
id: 'baby-object-match',
title: '宝贝识物',
subtitle: '亲子识物分类',
badge: '可创建',
imageSrc: '/child-motion-demo/picture-book-grass-stage.png',
visible: true,
open: true,
sortOrder: 90,
updatedAtMicros: 1,
},
],
} satisfies CreationEntryConfig;
@@ -526,6 +539,28 @@ vi.mock('../../services/square-hole-works', () => ({
listSquareHoleWorks: vi.fn(),
}));
vi.mock('../../services/visual-novel-runtime', () => ({
listVisualNovelGallery: vi.fn(),
startVisualNovelRun: vi.fn(),
streamVisualNovelRuntimeAction: vi.fn(),
}));
vi.mock('../../services/visual-novel-works', () => ({
deleteVisualNovelWork: vi.fn(),
getVisualNovelWorkDetail: vi.fn(),
listVisualNovelWorks: vi.fn(),
publishVisualNovelWork: vi.fn(),
updateVisualNovelWork: vi.fn(),
}));
vi.mock('../../services/visual-novel-creation', () => ({
compileVisualNovelWorkProfile: vi.fn(),
createVisualNovelSession: vi.fn(),
executeVisualNovelAction: vi.fn(),
getVisualNovelSession: vi.fn(),
streamVisualNovelMessage: vi.fn(),
}));
vi.mock('../../services/creative-agent', () => ({
cancelCreativeAgentSession: vi.fn(),
confirmCreativePuzzleTemplate: vi.fn(),
@@ -1946,6 +1981,8 @@ beforeEach(() => {
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
vi.mocked(listVisualNovelGallery).mockResolvedValue({ works: [] });
vi.mocked(listVisualNovelWorks).mockResolvedValue({ works: [] });
vi.mocked(recordBigFishPlay).mockResolvedValue({ items: [] });
vi.mocked(recordRpgEntryWorldGalleryPlay).mockImplementation(
async (ownerUserId, profileId) => ({
@@ -2804,6 +2841,9 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(
screen.getByRole('tab', { name: '抓大鹅' }).querySelector('img')?.src,
).toContain('/creation-type-references/match3d.webp');
expect(
screen.getByRole('tab', { name: '宝贝识物' }).querySelector('img')?.src,
).toContain('/child-motion-demo/picture-book-grass-stage.png');
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-white'),
).toBeTruthy();
@@ -2815,6 +2855,7 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
expect(screen.queryByRole('tab', { name: /方洞挑战/u })).toBeNull();
expect(screen.getByRole('tab', { name: /抓大鹅/u })).toBeTruthy();
expect(screen.getByRole('tab', { name: /宝贝识物/u })).toBeTruthy();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
@@ -4705,6 +4746,30 @@ test('creation hub clears all private work shelves immediately after logout stat
});
});
test('creation draft hub skips visual novel shelves when entry is not open', async () => {
const user = userEvent.setup();
vi.mocked(fetchCreationEntryConfig).mockResolvedValue({
...testCreationEntryConfig,
creationTypes: testCreationEntryConfig.creationTypes.map((entry) =>
entry.id === 'visual-novel' ? { ...entry, open: false } : entry,
),
});
vi.mocked(listVisualNovelGallery).mockRejectedValue(
new Error('该玩法入口暂不可用'),
);
vi.mocked(listVisualNovelWorks).mockRejectedValue(
new Error('该玩法入口暂不可用'),
);
render(<TestWrapper withAuth />);
await openDraftHub(user);
expect(listVisualNovelGallery).not.toHaveBeenCalled();
expect(listVisualNovelWorks).not.toHaveBeenCalled();
expect(screen.queryByText('该玩法入口暂不可用')).toBeNull();
});
test('published puzzle works appear on home and mobile game category channel', async () => {
const user = userEvent.setup();
const publishedPuzzleWork = {
+46 -2
View File
@@ -15,6 +15,7 @@ import {
LogIn,
MessageCircle,
Pencil,
Palette,
Plus,
Search,
Settings,
@@ -152,6 +153,7 @@ export interface RpgEntryHomeViewProps {
onOpenCreateWorld: () => void;
onOpenCreateTypePicker: () => void;
onOpenGalleryDetail: (entry: PlatformPublicGalleryCard) => void;
onOpenBabyLoveDrawing?: () => void;
onOpenRecommendGalleryDetail?: (entry: PlatformPublicGalleryCard) => void;
recommendRuntimeContent?: ReactNode;
activeRecommendEntryKey?: string | null;
@@ -249,6 +251,11 @@ const EDUTAINMENT_DISCOVER_CHANNEL = {
id: 'edutainment',
label: EDUTAINMENT_WORK_TAG,
} as const;
const BABY_LOVE_DRAWING_DEFAULT_CARD = {
title: '宝贝爱画',
subtitle: '空白画板',
summary: '挥动小手画一张画。',
};
const PLATFORM_RANKING_TABS: Array<{
id: PlatformRankingTab;
@@ -3218,6 +3225,7 @@ export function RpgEntryHomeView({
onResumeSave,
onOpenCreateTypePicker,
onOpenGalleryDetail,
onOpenBabyLoveDrawing,
onOpenRecommendGalleryDetail,
recommendRuntimeContent,
activeRecommendEntryKey = null,
@@ -4735,7 +4743,7 @@ export function RpgEntryHomeView({
<section className="platform-mobile-home-feed">
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : edutainmentFeedEntries.length > 0 ? (
) : edutainmentFeedEntries.length > 0 || onOpenBabyLoveDrawing ? (
<div className="grid min-w-0 gap-3">
{edutainmentFeedEntries.map((entry) => {
const cardKey = buildPublicGalleryCardKey(entry);
@@ -4751,6 +4759,24 @@ export function RpgEntryHomeView({
/>
);
})}
{onOpenBabyLoveDrawing ? (
<button
type="button"
className="platform-edutainment-level-card"
onClick={onOpenBabyLoveDrawing}
>
<span className="platform-edutainment-level-card__icon">
<Palette className="h-7 w-7" />
</span>
<span className="platform-edutainment-level-card__body">
<strong>{BABY_LOVE_DRAWING_DEFAULT_CARD.title}</strong>
<span>{BABY_LOVE_DRAWING_DEFAULT_CARD.subtitle}</span>
</span>
<span className="platform-edutainment-level-card__summary">
{BABY_LOVE_DRAWING_DEFAULT_CARD.summary}
</span>
</button>
) : null}
</div>
) : (
<EmptyShelf text="暂时还没有可展示的作品。" />
@@ -4867,7 +4893,7 @@ export function RpgEntryHomeView({
<SectionHeader title={EDUTAINMENT_WORK_TAG} detail="EDUTAINMENT" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : edutainmentFeedEntries.length > 0 ? (
) : edutainmentFeedEntries.length > 0 || onOpenBabyLoveDrawing ? (
<div className="grid gap-4 xl:grid-cols-3">
{edutainmentFeedEntries.map((entry) => (
<WorldCard
@@ -4878,6 +4904,24 @@ export function RpgEntryHomeView({
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
/>
))}
{onOpenBabyLoveDrawing ? (
<button
type="button"
className="platform-edutainment-level-card"
onClick={onOpenBabyLoveDrawing}
>
<span className="platform-edutainment-level-card__icon">
<Palette className="h-7 w-7" />
</span>
<span className="platform-edutainment-level-card__body">
<strong>{BABY_LOVE_DRAWING_DEFAULT_CARD.title}</strong>
<span>{BABY_LOVE_DRAWING_DEFAULT_CARD.subtitle}</span>
</span>
<span className="platform-edutainment-level-card__summary">
{BABY_LOVE_DRAWING_DEFAULT_CARD.summary}
</span>
</button>
) : null}
</div>
) : (
<EmptyShelf text="暂时还没有可展示的作品。" />
@@ -41,10 +41,9 @@ test('platform work display text limits names and tags by character count', () =
expect(formatPlatformWorkDisplayName('热门高分拼图超长标题')).toBe(
'热门高分拼图超长',
);
expect(formatPlatformWorkDisplayTags(['超长机关标签', '星桥', '超长机关标签'])).toEqual([
'超长机关',
'星桥',
]);
expect(
formatPlatformWorkDisplayTags(['超长机关标签', '星桥', '超长机关标签']),
).toEqual(['超长机关', '星桥']);
});
test('buildPuzzleWorkCoverSlides prefers each level formal image', () => {
@@ -195,6 +194,7 @@ test('maps baby object match draft to edutainment public card', () => {
prompt: '香蕉',
},
],
visualPackage: null,
themeTags: ['寓教于乐', '宝贝识物'],
publicationStatus: 'published',
createdAt: '2026-05-11T10:00:00.000Z',
+799 -28
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -30,6 +30,7 @@ const STAGE_ROUTE_ENTRIES = [
['baby-object-match-generating', '/creation/baby-object-match/generating'],
['baby-object-match-result', '/creation/baby-object-match/result'],
['baby-object-match-runtime', '/runtime/baby-object-match'],
['baby-love-drawing-runtime', '/runtime/baby-love-drawing'],
['puzzle-agent-workspace', '/creation/puzzle/agent'],
['puzzle-result', '/creation/puzzle/result'],
['puzzle-gallery-detail', '/gallery/puzzle/detail'],

Some files were not shown because too many files have changed in this diff Show More