/** @vitest-environment jsdom */ import { act, render } from '@testing-library/react'; import { useEffect, useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary'; import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import { executeRpgCreationAction, getRpgCreationOperation, upsertRpgWorldProfile, } from '../../services/rpg-creation'; import { type CustomWorldProfile, WorldType } from '../../types'; import { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave'; import { useRpgEntryLibraryDetail } from './useRpgEntryLibraryDetail'; vi.mock('../../services/rpg-creation', () => ({ executeRpgCreationAction: vi.fn(), getRpgCreationOperation: vi.fn(), upsertRpgWorldProfile: vi.fn(), })); vi.mock('../../services/rpg-entry', () => ({ deleteRpgEntryWorldProfile: vi.fn(), getRpgEntryWorldGalleryDetail: vi.fn(), listRpgEntryWorldLibrary: vi.fn(), publishRpgEntryWorldProfile: vi.fn(), unpublishRpgEntryWorldProfile: vi.fn(), })); function buildProfile(name: string): CustomWorldProfile { return { id: `profile-${name}`, settingText: name, name, subtitle: name, summary: name, tone: '测试', playerGoal: '测试', templateWorldType: WorldType.WUXIA, compatibilityTemplateWorldType: WorldType.WUXIA, majorFactions: [], coreConflicts: [], attributeSchema: { id: `schema-${name}`, worldId: `profile-${name}`, schemaVersion: 1, generatedFrom: { worldType: WorldType.CUSTOM, worldName: name, settingSummary: name, tone: '测试', conflictCore: '测试', }, slots: [], }, playableNpcs: [], storyNpcs: [], items: [], landmarks: [], generationMode: 'full', generationStatus: 'complete', }; } function buildLibraryEntry( profile: CustomWorldProfile, ): CustomWorldLibraryEntry { return { ownerUserId: 'user-1', profileId: profile.id, publicWorkCode: null, authorPublicUserCode: null, profile, visibility: 'published' as const, publishedAt: '2026-04-25T00:00:00.000Z', updatedAt: '2026-04-25T00:00:00.000Z', authorDisplayName: '测试玩家', worldName: profile.name, subtitle: profile.subtitle, summaryText: profile.summary, coverImageSrc: null, themeMode: 'tide' as const, playableNpcCount: profile.playableNpcs.length, landmarkCount: profile.landmarks.length, likeCount: 0, }; } function buildSession( overrides: Partial = {}, ): CustomWorldAgentSessionSnapshot { return { sessionId: 'agent-session-1', currentTurn: 1, anchorContent: { worldPromise: null, playerFantasy: null, themeBoundary: null, playerEntryPoint: null, coreConflict: null, keyRelationships: null, hiddenLines: null, iconicElements: null, }, progressPercent: 20, lastAssistantReply: '继续补齐世界草稿。', stage: 'clarifying', focusCardId: null, creatorIntent: null, creatorIntentReadiness: { isReady: false, completedKeys: [], missingKeys: [], }, anchorPack: null, lockState: null, draftProfile: null, messages: [], draftCards: [], pendingClarifications: [], suggestedActions: [], recommendedReplies: [], qualityFindings: [], assetCoverage: { roleAssets: [], sceneAssets: [], allRoleAssetsReady: false, allSceneAssetsReady: false, }, resultPreview: null, updatedAt: '2026-04-25T00:00:00.000Z', ...overrides, }; } function buildResultView( overrides: Partial = {}, ): RpgCreationResultView { const session = overrides.session ?? buildSession(); return { session, profile: null, profileSource: 'none', targetStage: 'agent-workspace', generationViewSource: null, resultViewSource: null, canAutosaveLibrary: false, canSyncResultProfile: false, publishReady: false, canEnterWorld: false, blockerCount: 0, recoveryAction: 'continue_agent', recoveryReason: null, ...overrides, }; } describe('RPG Agent 草稿恢复', () => { beforeEach(() => { vi.clearAllMocks(); }); it('作品摘要已有对象数量但 session 没有 draftProfile 时恢复 Agent 页面', async () => { const syncAgentCreationResultView = vi.fn(async () => buildResultView({ session: buildSession({ stage: 'clarifying', draftProfile: null, }), targetStage: 'agent-workspace', recoveryAction: 'continue_agent', }), ); const setSelectionStage = vi.fn(); const persistAgentUiState = vi.fn(); const setGeneratedCustomWorldProfile = vi.fn(); const setCustomWorldResultViewSource = vi.fn(); const suppressAgentDraftResultAutoOpen = vi.fn(); let openWork: | ((work: CustomWorldWorkSummary) => Promise) | null = null; function Harness() { openWork = useRpgEntryLibraryDetail({ userId: 'user-1', selectedDetailEntry: null, setSelectedDetailEntry: vi.fn(), savedCustomWorldEntries: [], setSavedCustomWorldEntries: vi.fn(), setGeneratedCustomWorldProfile, setCustomWorldError: vi.fn(), setCustomWorldAutoSaveError: vi.fn(), setCustomWorldAutoSaveState: vi.fn(), setCustomWorldGenerationViewSource: vi.fn(), setCustomWorldResultViewSource, setSelectionStage, setPlatformTabToCreate: vi.fn(), setPlatformTabToDraft: vi.fn(), setPlatformError: vi.fn(), appendBrowseHistoryEntry: vi.fn(async () => {}), refreshCustomWorldWorks: vi.fn(async () => []), refreshPublishedGallery: vi.fn(async () => []), persistAgentUiState, syncAgentCreationResultView, buildDraftResultProfile: (view) => (view?.profile as CustomWorldProfile | null) ?? null, suppressAgentDraftResultAutoOpen, releaseAgentDraftResultAutoOpenSuppression: vi.fn(), resetAutoSaveTrackingToIdle: vi.fn(), markAutoSavedProfile: vi.fn(), }).handleOpenCreationWork; return null; } render(); await act(async () => { await openWork?.({ workId: 'draft:agent-session-1', sourceType: 'agent_session', status: 'draft', title: '未生成草稿作品', subtitle: '', summary: '', updatedAt: '2026-04-25T00:00:00.000Z', stage: 'clarifying', stageLabel: '澄清中', playableNpcCount: 2, landmarkCount: 3, sessionId: 'agent-session-1', canResume: true, canEnterWorld: false, }); }); expect(syncAgentCreationResultView).toHaveBeenCalledWith('agent-session-1'); expect(suppressAgentDraftResultAutoOpen).toHaveBeenCalled(); expect(persistAgentUiState).toHaveBeenCalledWith('agent-session-1', null); expect(setGeneratedCustomWorldProfile).toHaveBeenLastCalledWith(null); expect(setCustomWorldResultViewSource).toHaveBeenLastCalledWith(null); expect(setSelectionStage).toHaveBeenLastCalledWith('agent-workspace'); expect(setSelectionStage).not.toHaveBeenCalledWith('custom-world-result'); }); it('作品详情已加载完整编辑资产时列表摘要不能覆盖 selectedDetailEntry', async () => { const fullProfile: CustomWorldProfile = { ...buildProfile('星砂废都'), id: 'profile-stardust', cover: { sourceType: 'default', imageSrc: null, characterRoleIds: ['playable-shamian'], }, playableNpcs: [ { id: 'playable-shamian', name: '砂眠', title: '废都引路人', role: '主角代理', description: '追查旧约的人。', backstory: '从星砂潮汐里醒来。', personality: '冷静。', motivation: '找到旧约。', combatStyle: '踏砂突进。', initialAffinity: 45, relationshipHooks: [], tags: [], relations: [], backstoryReveal: { publicSummary: '废都引路人。', privateChatUnlockAffinity: 60, chapters: [], }, skills: [ { id: 'skill-star-step', name: '星砂步', summary: '踏砂突进。', style: 'mobility', actionPreviewConfig: { folder: 'characters/shamian', prefix: 'skill_', frames: 8, basePath: '/assets/custom-world/shamian/skill', previewVideoPath: '/assets/custom-world/shamian/skill.mp4', }, }, ], initialItems: [ { id: 'item-sand-compass', name: '星砂罗盘', category: '专属物品', quantity: 1, rarity: 'rare', description: '能指出旧约埋藏方向。', tags: [], iconSrc: '/assets/custom-world/items/sand-compass.png', }, ], imageSrc: '/assets/custom-world/playable-shamian.png', attributeProfile: { schemaId: 'schema-星砂废都', values: { axis_a: 8 }, topTraits: ['星砂共鸣'], evidence: [ { slotId: 'axis_a', reason: '能听见星砂潮汐。' }, ], }, }, ], sceneChapterBlueprints: [ { id: 'scene-chapter-clocktower', sceneId: 'landmark-clocktower', title: '钟楼第一夜', summary: '钟楼第一夜。', sceneTaskDescription: '进入钟楼。', linkedThreadIds: [], linkedLandmarkIds: ['landmark-clocktower'], acts: [ { id: 'act-clocktower-opening', sceneId: 'landmark-clocktower', title: '第一幕', summary: '砂眠带玩家进入坠星钟楼。', stageCoverage: ['opening'], backgroundImageSrc: '/assets/custom-world/act-clocktower-opening.png', backgroundAssetId: 'asset-act-clocktower-opening', encounterNpcIds: ['playable-shamian'], primaryNpcId: 'playable-shamian', oppositeNpcId: 'playable-shamian', eventDescription: '钟楼旧铃忽然自鸣。', linkedThreadIds: ['thread-old-vow'], advanceRule: 'after_primary_contact', actGoal: '进入钟楼。', transitionHook: '星砂开始倒流。', }, ], }, ], }; const summaryProfile: CustomWorldProfile = { ...fullProfile, cover: null, playableNpcs: [ { ...fullProfile.playableNpcs[0]!, skills: [ { id: 'skill-star-step', name: '星砂步', summary: '踏砂突进。', style: 'mobility', }, ], initialItems: [ { ...fullProfile.playableNpcs[0]!.initialItems[0]!, iconSrc: undefined, }, ], imageSrc: undefined, attributeProfile: undefined, }, ], sceneChapterBlueprints: [ { ...fullProfile.sceneChapterBlueprints![0]!, acts: [ { ...fullProfile.sceneChapterBlueprints![0]!.acts[0]!, backgroundImageSrc: undefined, backgroundAssetId: undefined, linkedThreadIds: [], actGoal: '', transitionHook: '', }, ], }, ], }; const detailEntry = buildLibraryEntry(fullProfile); const summaryEntry = buildLibraryEntry(summaryProfile); const selectedEntries: CustomWorldLibraryEntry[] = []; function Harness() { const [selectedDetailEntry, setSelectedDetailEntry] = useState< CustomWorldLibraryEntry | null >(detailEntry); useEffect(() => { if (selectedDetailEntry) { selectedEntries.push(selectedDetailEntry); } }, [selectedDetailEntry]); useRpgEntryLibraryDetail({ userId: 'user-1', selectedDetailEntry, setSelectedDetailEntry, savedCustomWorldEntries: [summaryEntry], setSavedCustomWorldEntries: vi.fn(), setGeneratedCustomWorldProfile: vi.fn(), setCustomWorldError: vi.fn(), setCustomWorldAutoSaveError: vi.fn(), setCustomWorldAutoSaveState: vi.fn(), setCustomWorldGenerationViewSource: vi.fn(), setCustomWorldResultViewSource: vi.fn(), setSelectionStage: vi.fn(), setPlatformTabToCreate: vi.fn(), setPlatformTabToDraft: vi.fn(), setPlatformError: vi.fn(), appendBrowseHistoryEntry: vi.fn(async () => {}), refreshCustomWorldWorks: vi.fn(async () => []), refreshPublishedGallery: vi.fn(async () => []), persistAgentUiState: vi.fn(), syncAgentCreationResultView: vi.fn(), buildDraftResultProfile: () => null, suppressAgentDraftResultAutoOpen: vi.fn(), releaseAgentDraftResultAutoOpenSuppression: vi.fn(), resetAutoSaveTrackingToIdle: vi.fn(), markAutoSavedProfile: vi.fn(), }); return null; } render(); await act(async () => {}); const lastSelected = selectedEntries.at(-1); expect(lastSelected?.profile.cover?.characterRoleIds).toEqual([ 'playable-shamian', ]); expect( lastSelected?.profile.playableNpcs[0]?.skills[0]?.actionPreviewConfig ?.previewVideoPath, ).toBe('/assets/custom-world/shamian/skill.mp4'); expect(lastSelected?.profile.playableNpcs[0]?.initialItems[0]?.iconSrc).toBe( '/assets/custom-world/items/sand-compass.png', ); expect(lastSelected?.profile.playableNpcs[0]?.attributeProfile).toBeTruthy(); expect( lastSelected?.profile.sceneChapterBlueprints?.[0]?.acts[0] ?.backgroundImageSrc, ).toBe('/assets/custom-world/act-clocktower-opening.png'); }); it('默认封面和角色编辑结构差异也不能被列表摘要覆盖', async () => { const fullRole = { id: 'playable-shamian', name: '砂眠', title: '废都引路人', role: '主角代理', description: '追查旧约的人。', backstory: '从星砂潮汐里醒来。', personality: '冷静。', motivation: '找到旧约。', combatStyle: '踏砂突进。', initialAffinity: 45, relationshipHooks: [], tags: [], relations: [], backstoryReveal: { publicSummary: '废都引路人。', privateChatUnlockAffinity: 60, chapters: [], }, skills: [ { id: 'skill-star-step', name: '星砂步', summary: '踏砂突进。', style: 'mobility', actionPreviewConfig: { folder: 'characters/shamian', prefix: 'skill_', frames: 8, basePath: '/assets/custom-world/shamian/skill', previewVideoPath: '/assets/custom-world/shamian/skill.mp4', }, }, ], initialItems: [ { id: 'item-sand-compass', name: '星砂罗盘', category: '专属物品', quantity: 1, rarity: 'rare', description: '能指出旧约埋藏方向。', tags: [], iconSrc: '/assets/custom-world/items/sand-compass.png', }, ], attributeProfile: { schemaId: 'schema-星砂废都', values: { axis_a: 8 }, topTraits: ['星砂共鸣'], evidence: [ { slotId: 'axis_a', reason: '能听见星砂潮汐。' }, ], }, } satisfies CustomWorldProfile['playableNpcs'][number]; const fullProfile: CustomWorldProfile = { ...buildProfile('星砂废都'), id: 'profile-stardust-structure', cover: { sourceType: 'default', imageSrc: null, characterRoleIds: ['playable-shamian'], }, playableNpcs: [fullRole], }; const summaryProfile: CustomWorldProfile = { ...fullProfile, cover: null, playableNpcs: [ { ...fullRole, skills: [ { id: 'skill-star-step', name: '星砂步', summary: '踏砂突进。', style: 'mobility', }, ], initialItems: [ { ...fullRole.initialItems[0]!, iconSrc: undefined, }, ], attributeProfile: undefined, }, ], }; const detailEntry = buildLibraryEntry(fullProfile); const summaryEntry = buildLibraryEntry(summaryProfile); const selectedEntries: CustomWorldLibraryEntry[] = []; function Harness() { const [selectedDetailEntry, setSelectedDetailEntry] = useState< CustomWorldLibraryEntry | null >(detailEntry); useEffect(() => { if (selectedDetailEntry) { selectedEntries.push(selectedDetailEntry); } }, [selectedDetailEntry]); useRpgEntryLibraryDetail({ userId: 'user-1', selectedDetailEntry, setSelectedDetailEntry, savedCustomWorldEntries: [summaryEntry], setSavedCustomWorldEntries: vi.fn(), setGeneratedCustomWorldProfile: vi.fn(), setCustomWorldError: vi.fn(), setCustomWorldAutoSaveError: vi.fn(), setCustomWorldAutoSaveState: vi.fn(), setCustomWorldGenerationViewSource: vi.fn(), setCustomWorldResultViewSource: vi.fn(), setSelectionStage: vi.fn(), setPlatformTabToCreate: vi.fn(), setPlatformTabToDraft: vi.fn(), setPlatformError: vi.fn(), appendBrowseHistoryEntry: vi.fn(async () => {}), refreshCustomWorldWorks: vi.fn(async () => []), refreshPublishedGallery: vi.fn(async () => []), persistAgentUiState: vi.fn(), syncAgentCreationResultView: vi.fn(), buildDraftResultProfile: () => null, suppressAgentDraftResultAutoOpen: vi.fn(), releaseAgentDraftResultAutoOpenSuppression: vi.fn(), resetAutoSaveTrackingToIdle: vi.fn(), markAutoSavedProfile: vi.fn(), }); return null; } render(); await act(async () => {}); const lastSelected = selectedEntries.at(-1); expect(lastSelected?.profile.cover?.characterRoleIds).toEqual([ 'playable-shamian', ]); expect( lastSelected?.profile.playableNpcs[0]?.skills[0]?.actionPreviewConfig ?.previewVideoPath, ).toBe('/assets/custom-world/shamian/skill.mp4'); expect(lastSelected?.profile.playableNpcs[0]?.initialItems[0]?.iconSrc).toBe( '/assets/custom-world/items/sand-compass.png', ); expect(lastSelected?.profile.playableNpcs[0]?.attributeProfile?.values).toEqual( { axis_a: 8 }, ); }); it('Agent 结果页自动保存先回写 session,再保存后端 result-view profile', async () => { const oldProfile = buildProfile('旧前端快照'); const latestProfile = { ...buildProfile('服务端草稿快照'), summary: '自动保存应保存这份 session 最新草稿。', }; const latestSession = buildSession({ stage: 'object_refining', draftProfile: latestProfile as unknown as Record, }); const syncAgentSessionSnapshot = vi.fn(async () => latestSession); const syncAgentCreationResultView = vi.fn(async () => buildResultView({ session: latestSession, profile: latestProfile, profileSource: 'result_preview', targetStage: 'custom-world-result', resultViewSource: 'agent-draft', canAutosaveLibrary: true, canSyncResultProfile: true, recoveryAction: 'open_result', }), ); vi.mocked(executeRpgCreationAction).mockResolvedValue({ operation: { operationId: 'operation-sync-result', type: 'sync_result_profile', status: 'running', phaseLabel: '结果页同步中', phaseDetail: '正在同步结果页。', progress: 50, }, }); vi.mocked(getRpgCreationOperation).mockResolvedValue({ operationId: 'operation-sync-result', type: 'sync_result_profile', status: 'completed', phaseLabel: '结果页已同步', phaseDetail: '结果页已同步。', progress: 100, }); vi.mocked(upsertRpgWorldProfile).mockResolvedValue({ entry: { ownerUserId: 'user-1', profileId: latestProfile.id, publicWorkCode: null, authorPublicUserCode: null, profile: latestProfile, visibility: 'draft', publishedAt: null, updatedAt: '2026-04-25T00:00:00.000Z', authorDisplayName: '测试玩家', worldName: latestProfile.name, subtitle: latestProfile.subtitle, summaryText: latestProfile.summary, coverImageSrc: null, themeMode: 'tide', playableNpcCount: 0, landmarkCount: 0, likeCount: 0, }, entries: [], }); function Harness() { useRpgCreationResultAutosave({ selectionStage: 'custom-world-result', activeAgentSessionId: 'agent-session-1', generatedCustomWorldProfile: oldProfile, isAgentDraftResultView: true, userId: 'user-1', setGeneratedCustomWorldProfile: vi.fn(), setAgentOperation: vi.fn(), setSavedCustomWorldEntries: vi.fn(), setSelectedDetailEntry: vi.fn(), refreshCustomWorldWorks: vi.fn(async () => []), persistAgentUiState: vi.fn(), syncAgentSessionSnapshot, syncAgentCreationResultView, buildDraftResultProfile: (view) => (view?.profile as CustomWorldProfile | null) ?? null, }); return null; } vi.useFakeTimers(); render(); await act(async () => { await vi.advanceTimersByTimeAsync(650); }); vi.useRealTimers(); expect(syncAgentSessionSnapshot).toHaveBeenCalledWith('agent-session-1'); expect(syncAgentCreationResultView).toHaveBeenCalledWith('agent-session-1'); expect(executeRpgCreationAction).toHaveBeenCalledWith('agent-session-1', { action: 'sync_result_profile', profile: expect.objectContaining({ id: oldProfile.id, name: oldProfile.name, }), }); expect(upsertRpgWorldProfile).toHaveBeenCalledWith( expect.objectContaining({ id: latestProfile.id, name: latestProfile.name, summary: latestProfile.summary, }), { sourceAgentSessionId: 'agent-session-1', }, ); }); });