476 lines
15 KiB
TypeScript
476 lines
15 KiB
TypeScript
/** @vitest-environment jsdom */
|
||
|
||
import { act, render } from '@testing-library/react';
|
||
import { describe, expect, it, vi } from 'vitest';
|
||
|
||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||
import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime';
|
||
import { type CustomWorldProfile, WorldType } from '../../types';
|
||
import { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
|
||
|
||
function buildProfile(params: {
|
||
id: string;
|
||
name: string;
|
||
imageSrc: string;
|
||
}): CustomWorldProfile {
|
||
return {
|
||
id: params.id,
|
||
settingText: params.name,
|
||
name: params.name,
|
||
subtitle: params.name,
|
||
summary: params.name,
|
||
tone: '测试',
|
||
playerGoal: '测试',
|
||
templateWorldType: WorldType.WUXIA,
|
||
compatibilityTemplateWorldType: WorldType.WUXIA,
|
||
majorFactions: [],
|
||
coreConflicts: [],
|
||
attributeSchema: {
|
||
id: `${params.id}-attribute-schema`,
|
||
worldId: params.id,
|
||
schemaVersion: 1,
|
||
generatedFrom: {
|
||
worldType: WorldType.CUSTOM,
|
||
worldName: params.name,
|
||
settingSummary: params.name,
|
||
tone: '测试',
|
||
conflictCore: '测试',
|
||
},
|
||
slots: [],
|
||
},
|
||
playableNpcs: [
|
||
{
|
||
id: `${params.id}-role`,
|
||
name: '可扮演角色',
|
||
title: '测试角色',
|
||
role: '主角',
|
||
description: '测试角色',
|
||
backstory: '测试背景',
|
||
personality: '测试性格',
|
||
motivation: '测试动机',
|
||
combatStyle: '测试战斗风格',
|
||
initialAffinity: 18,
|
||
relationshipHooks: [],
|
||
tags: [],
|
||
backstoryReveal: {
|
||
publicSummary: '测试角色',
|
||
privateChatUnlockAffinity: 60,
|
||
chapters: [],
|
||
},
|
||
skills: [],
|
||
initialItems: [],
|
||
imageSrc: params.imageSrc,
|
||
},
|
||
],
|
||
storyNpcs: [],
|
||
items: [],
|
||
landmarks: [],
|
||
generationMode: 'full',
|
||
generationStatus: 'complete',
|
||
};
|
||
}
|
||
|
||
function buildSession(
|
||
stage: CustomWorldAgentSessionSnapshot['stage'] = 'ready_to_publish',
|
||
): CustomWorldAgentSessionSnapshot {
|
||
return {
|
||
sessionId: 'session-1',
|
||
currentTurn: 1,
|
||
anchorContent: {
|
||
worldPromise: null,
|
||
playerFantasy: null,
|
||
themeBoundary: null,
|
||
playerEntryPoint: null,
|
||
coreConflict: null,
|
||
keyRelationships: null,
|
||
hiddenLines: null,
|
||
iconicElements: null,
|
||
},
|
||
progressPercent: 100,
|
||
lastAssistantReply: '',
|
||
stage,
|
||
focusCardId: null,
|
||
creatorIntent: null,
|
||
creatorIntentReadiness: {
|
||
isReady: true,
|
||
completedKeys: [],
|
||
missingKeys: [],
|
||
},
|
||
anchorPack: null,
|
||
lockState: null,
|
||
draftProfile: null,
|
||
messages: [],
|
||
draftCards: [],
|
||
pendingClarifications: [],
|
||
suggestedActions: [],
|
||
recommendedReplies: [],
|
||
qualityFindings: [],
|
||
assetCoverage: {
|
||
roleAssets: [],
|
||
sceneAssets: [],
|
||
allRoleAssetsReady: true,
|
||
allSceneAssetsReady: true,
|
||
},
|
||
resultPreview: null,
|
||
updatedAt: '2026-04-25T00:00:00.000Z',
|
||
};
|
||
}
|
||
|
||
function buildResultView(params: {
|
||
stage?: CustomWorldAgentSessionSnapshot['stage'];
|
||
profile: CustomWorldProfile | null;
|
||
canEnterWorld?: boolean;
|
||
}): RpgCreationResultView {
|
||
const stage = params.stage ?? 'ready_to_publish';
|
||
const profileRecord = params.profile
|
||
? (structuredClone(params.profile) as unknown as CustomWorldProfileRecord)
|
||
: null;
|
||
return {
|
||
session: buildSession(stage),
|
||
profile: profileRecord,
|
||
profileSource: profileRecord ? 'result_preview' : 'none',
|
||
targetStage: 'custom-world-result',
|
||
generationViewSource: null,
|
||
resultViewSource: profileRecord ? 'agent-draft' : null,
|
||
canAutosaveLibrary: true,
|
||
canSyncResultProfile: stage !== 'published',
|
||
publishReady: true,
|
||
canEnterWorld: params.canEnterWorld ?? stage === 'published',
|
||
blockerCount: 0,
|
||
recoveryAction: 'open_result',
|
||
};
|
||
}
|
||
|
||
describe('useRpgCreationEnterWorld', () => {
|
||
it('Agent 草稿测试进入游戏时优先使用结果页当前 profile,而不是回退到会话快照', async () => {
|
||
const resultProfile = buildProfile({
|
||
id: 'draft-profile',
|
||
name: '结果页真相源',
|
||
imageSrc: '/generated-characters/draft-role/portrait.png',
|
||
});
|
||
const handleCustomWorldSelect = vi.fn();
|
||
const setGeneratedCustomWorldProfile = vi.fn();
|
||
const executePublishWorld = vi.fn(async () => buildSession());
|
||
const syncAgentCreationResultView = vi.fn();
|
||
const syncAgentDraftResultProfile = vi.fn(async () => ({
|
||
profile: resultProfile,
|
||
view: null,
|
||
}));
|
||
|
||
function Harness() {
|
||
const { enterWorldForTestFromCurrentResult } = useRpgCreationEnterWorld({
|
||
isAgentDraftResultView: true,
|
||
activeAgentSessionId: 'session-1',
|
||
generatedCustomWorldProfile: resultProfile,
|
||
handleCustomWorldSelect,
|
||
syncAgentDraftResultProfile,
|
||
executePublishWorld,
|
||
syncAgentCreationResultView,
|
||
setGeneratedCustomWorldProfile,
|
||
});
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => void enterWorldForTestFromCurrentResult()}
|
||
>
|
||
进入
|
||
</button>
|
||
);
|
||
}
|
||
|
||
const { getByText } = render(<Harness />);
|
||
await act(async () => {
|
||
getByText('进入').click();
|
||
});
|
||
|
||
expect(executePublishWorld).not.toHaveBeenCalled();
|
||
expect(handleCustomWorldSelect).toHaveBeenCalledWith(resultProfile, {
|
||
mode: 'play',
|
||
disablePersistence: true,
|
||
returnStage: 'custom-world-result',
|
||
});
|
||
expect(setGeneratedCustomWorldProfile).toHaveBeenCalledWith(resultProfile);
|
||
expect(
|
||
handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc,
|
||
).toBe('/generated-characters/draft-role/portrait.png');
|
||
});
|
||
|
||
it('Agent 草稿发布时先保存当前结果页 profile,再发送 publish_world 并回读结果页', async () => {
|
||
const resultProfile = buildProfile({
|
||
id: 'draft-profile',
|
||
name: '发布前填写内容',
|
||
imageSrc: '/generated-characters/draft-role/portrait.png',
|
||
});
|
||
const syncedProfile = buildProfile({
|
||
id: 'draft-profile',
|
||
name: '已保存的填写内容',
|
||
imageSrc: '/generated-characters/draft-role/synced.png',
|
||
});
|
||
const publishedProfile = buildProfile({
|
||
id: 'draft-profile',
|
||
name: '已发布世界',
|
||
imageSrc: '/generated-characters/draft-role/published.png',
|
||
});
|
||
const callOrder: string[] = [];
|
||
const handleCustomWorldSelect = vi.fn();
|
||
const setGeneratedCustomWorldProfile = vi.fn();
|
||
const syncAgentDraftResultProfile = vi.fn(async () => {
|
||
callOrder.push('save');
|
||
return {
|
||
profile: syncedProfile,
|
||
view: buildResultView({
|
||
stage: 'ready_to_publish',
|
||
profile: syncedProfile,
|
||
canEnterWorld: false,
|
||
}),
|
||
};
|
||
});
|
||
const executePublishWorld = vi.fn(async () => {
|
||
callOrder.push('publish');
|
||
return buildSession('published');
|
||
});
|
||
const syncAgentCreationResultView = vi.fn(async () => {
|
||
callOrder.push('reload');
|
||
return buildResultView({
|
||
stage: 'published',
|
||
profile: publishedProfile,
|
||
canEnterWorld: true,
|
||
});
|
||
});
|
||
|
||
function Harness() {
|
||
const { publishCurrentResult } = useRpgCreationEnterWorld({
|
||
isAgentDraftResultView: true,
|
||
activeAgentSessionId: 'session-1',
|
||
currentAgentSessionStage: 'ready_to_publish',
|
||
generatedCustomWorldProfile: resultProfile,
|
||
handleCustomWorldSelect,
|
||
syncAgentDraftResultProfile,
|
||
executePublishWorld,
|
||
syncAgentCreationResultView,
|
||
setGeneratedCustomWorldProfile,
|
||
});
|
||
|
||
return (
|
||
<button type="button" onClick={() => void publishCurrentResult()}>
|
||
发布
|
||
</button>
|
||
);
|
||
}
|
||
|
||
const { getByText } = render(<Harness />);
|
||
await act(async () => {
|
||
getByText('发布').click();
|
||
});
|
||
|
||
expect(callOrder).toEqual(['save', 'publish', 'reload']);
|
||
expect(syncAgentDraftResultProfile).toHaveBeenCalledWith(resultProfile);
|
||
expect(executePublishWorld).toHaveBeenCalledTimes(1);
|
||
expect(syncAgentCreationResultView).toHaveBeenCalledWith('session-1');
|
||
expect(setGeneratedCustomWorldProfile).toHaveBeenCalledWith(syncedProfile);
|
||
expect(
|
||
setGeneratedCustomWorldProfile.mock.calls.at(-1)?.[0]?.id,
|
||
).toBe('draft-profile');
|
||
expect(
|
||
setGeneratedCustomWorldProfile.mock.calls.at(-1)?.[0]?.playableNpcs[0]
|
||
?.imageSrc,
|
||
).toBe('/generated-characters/draft-role/published.png');
|
||
expect(handleCustomWorldSelect).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('Agent 会话已发布后点击进入世界不再重复发送 publish_world', async () => {
|
||
const resultProfile = buildProfile({
|
||
id: 'published-profile',
|
||
name: '已发布世界',
|
||
imageSrc: '/generated-characters/published-role/portrait.png',
|
||
});
|
||
const publishedView = buildResultView({
|
||
stage: 'published',
|
||
profile: resultProfile,
|
||
canEnterWorld: true,
|
||
});
|
||
const handleCustomWorldSelect = vi.fn();
|
||
const setGeneratedCustomWorldProfile = vi.fn();
|
||
const executePublishWorld = vi.fn(async () => buildSession('published'));
|
||
const syncAgentCreationResultView = vi.fn(async () => publishedView);
|
||
const syncAgentDraftResultProfile = vi.fn(async () => ({
|
||
profile: resultProfile,
|
||
view: null,
|
||
}));
|
||
|
||
function Harness() {
|
||
const { enterWorldFromCurrentResult } = useRpgCreationEnterWorld({
|
||
isAgentDraftResultView: true,
|
||
activeAgentSessionId: 'session-1',
|
||
currentAgentSessionStage: 'published',
|
||
generatedCustomWorldProfile: resultProfile,
|
||
handleCustomWorldSelect,
|
||
syncAgentDraftResultProfile,
|
||
executePublishWorld,
|
||
syncAgentCreationResultView,
|
||
setGeneratedCustomWorldProfile,
|
||
});
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => void enterWorldFromCurrentResult()}
|
||
>
|
||
进入世界
|
||
</button>
|
||
);
|
||
}
|
||
|
||
const { getByText } = render(<Harness />);
|
||
await act(async () => {
|
||
getByText('进入世界').click();
|
||
});
|
||
|
||
expect(syncAgentDraftResultProfile).not.toHaveBeenCalled();
|
||
expect(executePublishWorld).not.toHaveBeenCalled();
|
||
expect(syncAgentCreationResultView).toHaveBeenCalledWith('session-1');
|
||
expect(setGeneratedCustomWorldProfile).toHaveBeenCalledTimes(1);
|
||
expect(setGeneratedCustomWorldProfile.mock.calls[0]?.[0]?.id).toBe(
|
||
'published-profile',
|
||
);
|
||
expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1);
|
||
expect(handleCustomWorldSelect.mock.calls[0]?.[0]?.id).toBe(
|
||
'published-profile',
|
||
);
|
||
});
|
||
|
||
|
||
it('正式进入世界回读结果页字段更少时不降级当前完整 profile', async () => {
|
||
const resultProfile = {
|
||
...buildProfile({
|
||
id: 'draft-profile-rich-assets',
|
||
name: '星砂废都',
|
||
imageSrc: '/generated-characters/draft-role/portrait.png',
|
||
}),
|
||
cover: {
|
||
sourceType: 'generated' as const,
|
||
imageSrc: '/generated-custom-world-covers/star-waste/cover.webp',
|
||
characterRoleIds: ['draft-profile-rich-assets-role'],
|
||
},
|
||
openingCg: {
|
||
id: 'opening-cg-stardust',
|
||
status: 'ready' as const,
|
||
storyboardImageSrc: '/generated-custom-world-scenes/opening/storyboard.png',
|
||
videoSrc: '/generated-custom-world-scenes/opening/opening.mp4',
|
||
imageModel: 'gpt-image-2' as const,
|
||
videoModel: 'doubao-seedance-2-0-fast-260128',
|
||
aspectRatio: '16:9' as const,
|
||
imageSize: '2k' as const,
|
||
videoResolution: '480p' as const,
|
||
durationSeconds: 15 as const,
|
||
pointCost: 80 as const,
|
||
estimatedWaitMinutes: 10 as const,
|
||
updatedAt: '2026-05-21T00:00:00.000Z',
|
||
},
|
||
sceneChapterBlueprints: [
|
||
{
|
||
id: 'scene-chapter-stardust',
|
||
sceneId: 'landmark-stardust',
|
||
title: '钟楼第一夜',
|
||
summary: '钟楼第一夜。',
|
||
sceneTaskDescription: '进入钟楼。',
|
||
linkedThreadIds: [],
|
||
linkedLandmarkIds: ['landmark-stardust'],
|
||
acts: [
|
||
{
|
||
id: 'act-stardust-opening',
|
||
sceneId: 'landmark-stardust',
|
||
title: '第一幕',
|
||
summary: '砂眠带玩家进入坠星钟楼。',
|
||
stageCoverage: ['opening' as const],
|
||
backgroundImageSrc:
|
||
'/assets/custom-world/act-stardust-opening.png',
|
||
backgroundAssetId: 'asset-act-stardust-opening',
|
||
encounterNpcIds: ['draft-profile-rich-assets-role'],
|
||
primaryNpcId: 'draft-profile-rich-assets-role',
|
||
oppositeNpcId: 'draft-profile-rich-assets-role',
|
||
eventDescription: '钟楼旧铃忽然自鸣。',
|
||
linkedThreadIds: [],
|
||
advanceRule: 'after_primary_contact' as const,
|
||
actGoal: '进入钟楼。',
|
||
transitionHook: '星砂开始倒流。',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
} satisfies CustomWorldProfile;
|
||
const stalePublishedProfile = {
|
||
...resultProfile,
|
||
name: '星砂废都',
|
||
cover: null,
|
||
openingCg: null,
|
||
playableNpcs: [],
|
||
sceneChapterBlueprints: null,
|
||
} satisfies CustomWorldProfile;
|
||
const handleCustomWorldSelect = vi.fn();
|
||
const setGeneratedCustomWorldProfile = vi.fn();
|
||
const syncAgentDraftResultProfile = vi.fn(async () => ({
|
||
profile: resultProfile,
|
||
view: buildResultView({
|
||
stage: 'ready_to_publish',
|
||
profile: resultProfile,
|
||
canEnterWorld: false,
|
||
}),
|
||
}));
|
||
const executePublishWorld = vi.fn(async () => buildSession('published'));
|
||
const syncAgentCreationResultView = vi.fn(async () =>
|
||
buildResultView({
|
||
stage: 'published',
|
||
profile: stalePublishedProfile,
|
||
canEnterWorld: true,
|
||
}),
|
||
);
|
||
|
||
function Harness() {
|
||
const { enterWorldFromCurrentResult } = useRpgCreationEnterWorld({
|
||
isAgentDraftResultView: true,
|
||
activeAgentSessionId: 'session-1',
|
||
currentAgentSessionStage: 'ready_to_publish',
|
||
generatedCustomWorldProfile: resultProfile,
|
||
handleCustomWorldSelect,
|
||
syncAgentDraftResultProfile,
|
||
executePublishWorld,
|
||
syncAgentCreationResultView,
|
||
setGeneratedCustomWorldProfile,
|
||
});
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => void enterWorldFromCurrentResult()}
|
||
>
|
||
进入世界
|
||
</button>
|
||
);
|
||
}
|
||
|
||
const { getByText } = render(<Harness />);
|
||
await act(async () => {
|
||
getByText('进入世界').click();
|
||
});
|
||
|
||
const launchedProfile = handleCustomWorldSelect.mock.calls[0]?.[0];
|
||
expect(launchedProfile?.id).toBe('draft-profile-rich-assets');
|
||
expect(launchedProfile?.cover?.imageSrc).toBe(
|
||
'/generated-custom-world-covers/star-waste/cover.webp',
|
||
);
|
||
expect(launchedProfile?.openingCg?.videoSrc).toBe(
|
||
'/generated-custom-world-scenes/opening/opening.mp4',
|
||
);
|
||
expect(launchedProfile?.playableNpcs[0]?.imageSrc).toBe(
|
||
'/generated-characters/draft-role/portrait.png',
|
||
);
|
||
expect(
|
||
launchedProfile?.sceneChapterBlueprints?.[0]?.acts[0]
|
||
?.backgroundImageSrc,
|
||
).toBe('/assets/custom-world/act-stardust-opening.png');
|
||
});
|
||
});
|