feat: add edutainment drawing and visual package flows
This commit is contained in:
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 = {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
Reference in New Issue
Block a user