/** @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'); }); });