/** @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 (
);
}
const { getByText } = render();
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 (
);
}
const { getByText } = render();
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 (
);
}
const { getByText } = render();
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 (
);
}
const { getByText } = render();
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');
});
});