/* @vitest-environment jsdom */ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { beforeEach, expect, test, vi } from 'vitest'; import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { createCustomWorldAgentSession, executeCustomWorldAgentAction, getCustomWorldAgentOperation, getCustomWorldAgentSession, listCustomWorldWorks, streamCustomWorldAgentMessage, } from '../../services/aiService'; import type { AuthUser } from '../../services/authService'; import { clearProfileBrowseHistory, deleteCustomWorldProfile, getCustomWorldGalleryDetail, getProfileDashboard, listCustomWorldGallery, listCustomWorldLibrary, listProfileBrowseHistory, listProfileSaveArchives, resumeProfileSaveArchive, upsertCustomWorldProfile, upsertProfileBrowseHistory, } from '../../services/storageService'; import type { GameState } from '../../types'; import { AuthUiContext, type PlatformSettingsSection, } from '../auth/AuthUiContext'; import { PreGameSelectionFlow, type SelectionStage, } from './PreGameSelectionFlow'; async function clickFirstButtonByName( user: ReturnType, name: string | RegExp, ) { const buttons = screen.getAllByRole('button', { name }); await user.click(buttons[0]!); } async function clickFirstAsyncButtonByName( user: ReturnType, name: string | RegExp, ) { const buttons = await screen.findAllByRole('button', { name }); await user.click(buttons[0]!); } async function openCreationHub(user: ReturnType) { await clickFirstButtonByName(user, '创作'); expect(await screen.findByText('创作中心')).toBeTruthy(); } async function openNewRpgCreation( user: ReturnType, ) { await openCreationHub(user); const createButtons = await screen.findAllByRole('button', { name: /新建作品/u, }); await user.click(createButtons.at(-1)!); expect(screen.getByText('选择创作类型')).toBeTruthy(); await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u })); } vi.mock('../../services/aiService', () => ({ createCustomWorldAgentSession: vi.fn(), executeCustomWorldAgentAction: vi.fn(), generateCustomWorldProfile: vi.fn(), getCustomWorldAgentOperation: vi.fn(), getCustomWorldAgentSession: vi.fn(), listCustomWorldWorks: vi.fn(), streamCustomWorldAgentMessage: vi.fn(), })); vi.mock('../../services/storageService', () => ({ clearProfileBrowseHistory: vi.fn(), deleteCustomWorldProfile: vi.fn(), getCustomWorldGalleryDetail: vi.fn(), getProfileDashboard: vi.fn(), listCustomWorldGallery: vi.fn(), listCustomWorldLibrary: vi.fn(), listProfileBrowseHistory: vi.fn(), listProfileSaveArchives: vi.fn(), publishCustomWorldProfile: vi.fn(), resumeProfileSaveArchive: vi.fn(), syncProfileBrowseHistory: vi.fn(), unpublishCustomWorldProfile: vi.fn(), upsertProfileBrowseHistory: vi.fn(), upsertCustomWorldProfile: vi.fn(), })); vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({ CustomWorldAgentWorkspace: ({ session, onExecuteAction, }: { session: CustomWorldAgentSessionSnapshot | null; onExecuteAction: (payload: { action: string }) => void; }) => (
Agent工作区:{session?.sessionId ?? 'missing-session'}
), })); const mockSession: CustomWorldAgentSessionSnapshot = { sessionId: 'custom-world-agent-session-1', currentTurn: 0, anchorContent: { worldPromise: { hook: '被海雾吞没的旧航路群岛。', differentiator: '灯塔与禁航令共同决定谁能穿过死潮。', desiredExperience: '压抑、潮湿、悬疑', }, playerFantasy: { playerRole: '玩家是被迫返乡的守灯人继承者。', corePursuit: '查清沉船夜与假航灯的关系。', fearOfLoss: '失去家族最后一条可信航线。', }, themeBoundary: { toneKeywords: ['压抑', '悬疑'], aestheticDirectives: ['潮湿群岛', '冷雾港口'], forbiddenDirectives: ['轻喜冒险'], }, playerEntryPoint: { openingIdentity: '返乡守灯人继承者', openingProblem: '回港首夜撞见禁航区假航灯重亮', entryMotivation: '阻止更多船只误入死潮', }, coreConflict: { surfaceConflicts: ['守灯会与航运公会争夺航路解释权'], hiddenCrisis: '有人在借假航灯持续清洗旧案证据', firstTouchedConflict: '玩家返乡当夜就被卷进封航冲突', }, keyRelationships: [ { pairs: '玩家 vs 沈砺', relationshipType: '旧友互疑', secretOrCost: '他知道沉船夜的另一半真相', }, ], hiddenLines: { hiddenTruths: ['沉船夜与假航灯骗局属于同一操盘链条'], misdirectionHints: ['表面像海雾自然失控'], revealPacing: '先见异常,再见旧案,再见操盘者', }, iconicElements: { iconicMotifs: ['假航灯', '沉钟回响'], institutionsOrArtifacts: ['旧灯塔', '禁航碑'], hardRules: ['错误航灯会把船引进必死水域'], }, }, progressPercent: 0, lastAssistantReply: '先告诉我你想做一个怎样的 RPG 世界。', stage: 'clarifying', focusCardId: null, creatorIntent: {}, creatorIntentReadiness: { isReady: false, completedKeys: ['world_hook'], missingKeys: [ 'player_premise', 'theme_and_tone', 'core_conflict', 'relationship_seed', 'iconic_element', ], }, anchorPack: {}, lockState: {}, draftProfile: null, messages: [ { id: 'message-1', role: 'assistant', kind: 'summary', text: '先告诉我你想做一个怎样的 RPG 世界。', createdAt: '2026-04-14T12:00:00.000Z', relatedOperationId: null, }, ], draftCards: [], pendingClarifications: [], suggestedActions: [], recommendedReplies: [], qualityFindings: [], assetCoverage: { roleAssets: [], sceneAssets: [], allRoleAssetsReady: false, allSceneAssetsReady: false, }, updatedAt: '2026-04-14T12:00:00.000Z', }; const mockAuthUser: AuthUser = { id: 'user-1', username: 'tester', displayName: '测试玩家', phoneNumberMasked: null, loginMethod: 'password', bindingStatus: 'active', wechatBound: false, }; type TestAuthValue = { user: AuthUser | null; openLoginModal: (postLoginAction?: (() => void) | null) => void; requireAuth: (action: () => void) => void; openSettingsModal: (section?: PlatformSettingsSection) => void; openAccountModal: () => void; logout: () => Promise; musicVolume: number; setMusicVolume: (value: number) => void; platformTheme: 'light' | 'dark'; setPlatformTheme: (theme: 'light' | 'dark') => void; isHydratingSettings: boolean; isPersistingSettings: boolean; settingsError: string | null; }; function createAuthValue(overrides: Partial = {}): TestAuthValue { return { user: mockAuthUser, openLoginModal: () => {}, requireAuth: (action) => action(), openSettingsModal: () => {}, openAccountModal: () => {}, logout: async () => {}, musicVolume: 0.42, setMusicVolume: () => {}, platformTheme: 'light', setPlatformTheme: () => {}, isHydratingSettings: false, isPersistingSettings: false, settingsError: null, ...overrides, }; } function TestWrapper({ withAuth = false, authValue, onContinueGame, }: { withAuth?: boolean; authValue?: TestAuthValue; onContinueGame?: (snapshot?: HydratedSavedGameSnapshot | null) => void; } = {}) { const [selectionStage, setSelectionStage] = useState('platform'); const content = ( {})} handleStartNewGame={() => {}} handleCustomWorldSelect={() => {}} /> ); if (!withAuth && !authValue) { return content; } return ( {content} ); } beforeEach(() => { vi.clearAllMocks(); window.history.replaceState(null, '', '/'); window.sessionStorage.clear(); window.localStorage.clear(); vi.mocked(getProfileDashboard).mockResolvedValue({ walletBalance: 0, totalPlayTimeMs: 0, playedWorldCount: 0, updatedAt: '2026-04-16T12:00:00.000Z', }); vi.mocked(listCustomWorldLibrary).mockResolvedValue([]); vi.mocked(listCustomWorldGallery).mockResolvedValue([]); vi.mocked(listProfileBrowseHistory).mockResolvedValue([]); vi.mocked(listProfileSaveArchives).mockResolvedValue([]); vi.mocked(resumeProfileSaveArchive).mockResolvedValue({ entry: { worldKey: 'custom:world-archive-1', ownerUserId: null, profileId: 'world-archive-1', worldType: 'CUSTOM', worldName: '潮雾列岛', subtitle: '旧灯塔与失控航路', summaryText: '回到旧灯塔继续推进调查。', coverImageSrc: null, lastPlayedAt: '2026-04-19T12:00:00.000Z', }, snapshot: { version: 2, savedAt: '2026-04-19T12:00:00.000Z', bottomTab: 'adventure', currentStory: null, gameState: {} as GameState, } as HydratedSavedGameSnapshot, }); vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]); vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]); vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]); vi.mocked(upsertCustomWorldProfile).mockResolvedValue({ entry: { ownerUserId: 'user-1', profileId: 'agent-draft-custom-world-agent-session-1', profile: { id: 'agent-draft-custom-world-agent-session-1', name: '潮雾列岛', } as never, visibility: 'draft', publishedAt: null, updatedAt: '2026-04-14T12:00:00.000Z', authorDisplayName: '玩家', worldName: '潮雾列岛', subtitle: '旧灯塔与失控航路', summaryText: '第一版世界底稿已经整理完成。', coverImageSrc: null, themeMode: 'tide', playableNpcCount: 1, landmarkCount: 1, }, entries: [], }); vi.mocked(createCustomWorldAgentSession).mockResolvedValue({ session: mockSession, }); vi.mocked(listCustomWorldWorks).mockResolvedValue([]); vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({ operation: { operationId: 'operation-draft-foundation-1', type: 'draft_foundation', status: 'queued', phaseLabel: '已接收请求', phaseDetail: '正在准备生成世界底稿。', progress: 10, error: null, }, }); vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({ operationId: 'operation-draft-foundation-1', type: 'draft_foundation', status: 'running', phaseLabel: '生成世界底稿', phaseDetail: '正在根据已确认锚点编译第一版世界结构。', progress: 38, error: null, }); vi.mocked(getCustomWorldAgentSession).mockResolvedValue(mockSession); vi.mocked(streamCustomWorldAgentMessage).mockResolvedValue(mockSession); }); test('create tab opens game type modal, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => { const user = userEvent.setup(); render(); await openCreationHub(user); const createButtons = await screen.findAllByRole('button', { name: /新建作品/u, }); await user.click(createButtons.at(-1)!); expect(screen.getByText('选择创作类型')).toBeTruthy(); const airpButton = screen.getByRole('button', { name: /AIRP/u }); const visualNovelButton = screen.getByRole('button', { name: /视觉小说/u, }); expect((airpButton as HTMLButtonElement).disabled).toBe(true); expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true); await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u })); await waitFor(() => { expect(createCustomWorldAgentSession).toHaveBeenCalledTimes(1); }); expect( await screen.findByText('Agent工作区:custom-world-agent-session-1'), ).toBeTruthy(); }); test('create tab uses unified creation hub and can resume an agent draft', async () => { const user = userEvent.setup(); vi.mocked(listCustomWorldWorks).mockResolvedValue([ { workId: 'draft:custom-world-agent-session-1', sourceType: 'agent_session', status: 'draft', title: '潮雾列岛', subtitle: '精修对象', summary: '玩家是失职返乡的守灯人。', coverImageSrc: null, coverRenderMode: 'image', coverCharacterImageSrcs: [], updatedAt: '2026-04-20T10:00:00.000Z', publishedAt: null, stage: 'object_refining', stageLabel: '精修对象', playableNpcCount: 3, landmarkCount: 4, roleVisualReadyCount: 1, roleAnimationReadyCount: 0, roleAssetSummaryLabel: '沈砺 · 主图已生成', sessionId: 'custom-world-agent-session-1', profileId: null, canResume: true, canEnterWorld: false, }, ]); render(); await openCreationHub(user); expect( screen.getByRole('button', { name: /继续精修/u }), ).toBeTruthy(); expect(screen.getByRole('button', { name: /继续精修/u })).toBeTruthy(); await user.click(screen.getByRole('button', { name: /继续精修/u })); expect( await screen.findByText('Agent工作区:custom-world-agent-session-1'), ).toBeTruthy(); }); test('clicking a public work while logged out routes through requireAuth', async () => { const user = userEvent.setup(); const requireAuth = vi.fn(); vi.mocked(listCustomWorldGallery).mockResolvedValue([ { ownerUserId: 'author-1', profileId: 'world-public-1', visibility: 'published', publishedAt: '2026-04-16T12:00:00.000Z', updatedAt: '2026-04-16T12:00:00.000Z', worldName: '潮雾列岛', subtitle: '旧灯塔与失控航路', summaryText: '最近公开发布的世界。', coverImageSrc: null, themeMode: 'tide', authorDisplayName: '潮汐作者', playableNpcCount: 3, landmarkCount: 4, }, ]); render( {}, requireAuth, })} />, ); const workCards = await screen.findAllByRole('button', { name: /潮雾列岛/u, }); await user.click(workCards[0]!); expect(requireAuth).toHaveBeenCalledTimes(1); expect(getCustomWorldGalleryDetail).not.toHaveBeenCalled(); }); test('selecting RPG creation while logged out routes through requireAuth', async () => { const user = userEvent.setup(); const requireAuth = vi.fn(); render( {}, requireAuth, })} />, ); await openNewRpgCreation(user); expect(requireAuth).toHaveBeenCalledTimes(1); expect(createCustomWorldAgentSession).not.toHaveBeenCalled(); }); test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => { const user = userEvent.setup(); render(); await openNewRpgCreation(user); expect( await screen.findByText('Agent工作区:custom-world-agent-session-1'), ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '开始生成草稿' })); await waitFor(() => { expect(executeCustomWorldAgentAction).toHaveBeenCalledWith( 'custom-world-agent-session-1', { action: 'draft_foundation', }, ); }); expect(await screen.findByText('世界草稿生成进度')).toBeTruthy(); expect(screen.queryByText(/Agent工作区/u)).toBeNull(); expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0); expect(screen.getByText('当前世界信息')).toBeTruthy(); expect(screen.queryByText('回到工作区')).toBeNull(); expect(screen.getByText('世界承诺')).toBeTruthy(); expect(screen.getByText(/灯塔与禁航令共同决定谁能穿过死潮/u)).toBeTruthy(); expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull(); }); test('existing draft sessions enter the agent preview layout without opening legacy editor', async () => { const user = userEvent.setup(); vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({ operationId: 'operation-draft-foundation-1', type: 'draft_foundation', status: 'completed', phaseLabel: '世界底稿已生成', phaseDetail: '第一版世界底稿和 4 张草稿卡已经整理完成。', progress: 100, error: null, }); vi.mocked(getCustomWorldAgentSession).mockResolvedValue({ ...mockSession, stage: 'object_refining', creatorIntent: { sourceMode: 'card', worldHook: '被海雾吞没的旧航路群岛', playerPremise: '玩家回到群岛调查沉船真相。', themeKeywords: ['海雾', '旧航路'], toneDirectives: ['压抑', '悬疑'], openingSituation: '首夜就有陌生船只闯入禁航区。', coreConflicts: ['航运公会与守灯会争夺航路控制权'], keyFactions: [], keyCharacters: [], keyLandmarks: [], iconicElements: ['会移动的海雾'], forbiddenDirectives: [], rawSettingText: '', }, draftProfile: { name: '潮雾列岛', subtitle: '旧灯塔与失控航路', summary: '第一版世界底稿已经整理完成。', tone: '压抑、潮湿、悬疑', playerGoal: '查清沉船与禁航区异动的真相。', majorFactions: ['守灯会', '航运公会'], coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], playableNpcs: [ { id: 'playable-1', name: '沈砺', title: '旧航路引路人', role: '关键同行者', publicIdentity: '最熟悉旧航路的人。', publicMask: '看上去像可靠旧友。', currentPressure: '他必须在两股势力间站队。', hiddenHook: '暗中替沉船商盟引路。', relationToPlayer: '旧友兼潜在背叛者', threadIds: ['thread-1'], summary: '他像旧友,但也像一把始终没收回鞘的刀。', }, ], storyNpcs: [ { id: 'story-1', name: '顾潮音', title: '守灯会值夜人', role: '场景关键角色', publicIdentity: '负责夜间巡灯与封锁。', publicMask: '对外一直冷静克制。', currentPressure: '她知道更多禁航区真相。', hiddenHook: '曾亲眼见过失控海雾吞船。', relationToPlayer: '最早愿意交换线索的人', threadIds: ['thread-1'], summary: '她总像比所有人更早知道海雾会往哪一侧压下来。', }, ], landmarks: [ { id: 'landmark-1', name: '回潮旧灯塔', purpose: '观察雾潮与往来船只', mood: '潮湿、压抑、风声不止', importance: '开局核心场景', characterIds: ['story-1'], threadIds: ['thread-1'], summary: '旧灯塔是整片群岛最先看见异动的地方。', }, ], factions: [], threads: [], chapters: [], worldHook: '被海雾吞没的旧航路群岛', playerPremise: '玩家回到群岛调查沉船真相。', openingSituation: '首夜就有陌生船只闯入禁航区。', iconicElements: ['会移动的海雾'], sourceAnchorSummary: '海雾、旧灯塔、失控航路。', }, draftCards: [ { id: 'world-foundation', kind: 'world', title: '潮雾列岛', subtitle: '旧灯塔与失控航路', summary: '第一版世界底稿已经整理完成。', status: 'warning', linkedIds: ['playable-1', 'story-1', 'landmark-1'], warningCount: 0, }, ], }); render(); await openNewRpgCreation(user); await waitFor( async () => { expect(await screen.findByText('世界档案')).toBeTruthy(); expect(screen.getByText('已自动保存')).toBeTruthy(); expect(screen.getByRole('button', { name: /进入世界/u })).toBeTruthy(); }, { timeout: 2500 }, ); expect(screen.queryByText(/Agent工作区/u)).toBeNull(); expect(screen.queryByRole('button', { name: /^锚点/u })).toBeNull(); expect(screen.getByText(/基本设定/u)).toBeTruthy(); expect(screen.queryByRole('button', { name: /新增场景角色/u })).toBeNull(); await user.click(screen.getByRole('button', { name: /场景角色/u })); expect(screen.getByRole('button', { name: /顾潮音/u })).toBeTruthy(); expect(screen.queryByText(/编辑场景角色:顾潮音/u)).toBeNull(); expect(screen.queryByRole('button', { name: /AI生成/u })).toBeNull(); expect(screen.queryByText('技能')).toBeNull(); }); test('agent draft result back button returns to workspace without redundant sync when session is already latest', async () => { const user = userEvent.setup(); vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({ operation: { operationId: 'operation-sync-result-profile-1', type: 'sync_result_profile', status: 'queued', phaseLabel: '同步结果页快照', phaseDetail: '正在把结果页里的最新世界结构写回当前草稿。', progress: 24, error: null, }, }); vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({ operationId: 'operation-sync-result-profile-1', type: 'sync_result_profile', status: 'completed', phaseLabel: '结果页快照已同步', phaseDetail: '当前结果页编辑内容已经并回 Agent 草稿主链。', progress: 100, error: null, }); const resultSession = { ...mockSession, stage: 'object_refining' as const, creatorIntent: { sourceMode: 'card', worldHook: '被海雾吞没的旧航路群岛', playerPremise: '玩家回到群岛调查沉船真相。', themeKeywords: ['海雾', '旧航路'], toneDirectives: ['压抑', '悬疑'], openingSituation: '首夜就有陌生船只闯入禁航区。', coreConflicts: ['航运公会与守灯会争夺航路控制权'], keyFactions: [], keyCharacters: [], keyLandmarks: [], iconicElements: ['会移动的海雾'], forbiddenDirectives: [], rawSettingText: '', }, draftProfile: { name: '潮雾列岛', subtitle: '旧灯塔与失控航路', summary: '第一版世界底稿已经整理完成。', tone: '压抑、潮湿、悬疑', playerGoal: '查清沉船与禁航区异动的真相。', majorFactions: ['守灯会', '航运公会'], coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], playableNpcs: [ { id: 'playable-1', name: '沈砺', title: '旧航路引路人', role: '关键同行者', publicIdentity: '最熟悉旧航路的人。', publicMask: '看上去像可靠旧友。', currentPressure: '他必须在两股势力间站队。', hiddenHook: '暗中替沉船商盟引路。', relationToPlayer: '旧友兼潜在背叛者', threadIds: ['thread-1'], summary: '他像旧友,但也像一把始终没收回鞘的刀。', }, ], storyNpcs: [ { id: 'story-1', name: '顾潮音', title: '守灯会值夜人', role: '场景关键角色', publicIdentity: '负责夜间巡灯与封锁。', publicMask: '对外一直冷静克制。', currentPressure: '她知道更多禁航区真相。', hiddenHook: '曾亲眼见过失控海雾吞船。', relationToPlayer: '最早愿意交换线索的人', threadIds: ['thread-1'], summary: '她总像比所有人更早知道海雾会往哪一侧压下来。', }, ], landmarks: [ { id: 'landmark-1', name: '回潮旧灯塔', purpose: '观察雾潮与往来船只', mood: '潮湿、压抑、风声不止', importance: '开局核心场景', characterIds: ['story-1'], threadIds: ['thread-1'], summary: '旧灯塔是整片群岛最先看见异动的地方。', }, ], factions: [], threads: [], chapters: [], worldHook: '被海雾吞没的旧航路群岛', playerPremise: '玩家回到群岛调查沉船真相。', openingSituation: '首夜就有陌生船只闯入禁航区。', iconicElements: ['会移动的海雾'], sourceAnchorSummary: '海雾、旧灯塔、失控航路。', legacyResultProfile: { id: 'agent-draft-custom-world-agent-session-1', settingText: '被海雾吞没的旧航路群岛', name: '潮雾列岛·同步后', subtitle: '旧灯塔与失控航路', summary: '同步后的结果页快照已经回写到 session。', tone: '压抑、潮湿、悬疑', playerGoal: '查清沉船与禁航区异动的真相。', templateWorldType: 'WUXIA', majorFactions: ['守灯会', '航运公会'], coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], playableNpcs: [], storyNpcs: [], items: [], landmarks: [], generationMode: 'full', generationStatus: 'complete', }, }, draftCards: [ { id: 'world-foundation', kind: 'world', title: '潮雾列岛', subtitle: '旧灯塔与失控航路', summary: '第一版世界底稿已经整理完成。', status: 'warning', linkedIds: ['playable-1', 'story-1', 'landmark-1'], warningCount: 0, }, ], } satisfies CustomWorldAgentSessionSnapshot; vi.mocked(getCustomWorldAgentSession).mockResolvedValue(resultSession); render(); await openNewRpgCreation(user); await waitFor( async () => { expect(await screen.findByText('世界档案')).toBeTruthy(); expect(screen.getByRole('button', { name: /返回创作/u })).toBeTruthy(); }, { timeout: 2500 }, ); await user.click(screen.getByRole('button', { name: /返回创作/u })); await waitFor(() => { expect( screen.getByText('Agent工作区:custom-world-agent-session-1'), ).toBeTruthy(); }); expect( vi.mocked(executeCustomWorldAgentAction).mock.calls.some( ([sessionId, payload]) => sessionId === 'custom-world-agent-session-1' && payload?.action === 'sync_result_profile', ), ).toBe(false); expect(screen.queryByText('世界档案')).toBeNull(); }); test('agent draft result auto-save persists the latest profile rebuilt from synced session', async () => { const user = userEvent.setup(); vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({ operation: { operationId: 'operation-sync-result-profile-2', type: 'sync_result_profile', status: 'queued', phaseLabel: '同步结果页快照', phaseDetail: '正在把结果页里的最新世界结构写回当前草稿。', progress: 24, error: null, }, }); vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({ operationId: 'operation-sync-result-profile-2', type: 'sync_result_profile', status: 'completed', phaseLabel: '结果页快照已同步', phaseDetail: '当前结果页编辑内容已经并回 Agent 草稿主链。', progress: 100, error: null, }); const syncedSession = { ...mockSession, stage: 'object_refining' as const, creatorIntent: { sourceMode: 'card', worldHook: '被海雾吞没的旧航路群岛', playerPremise: '玩家回到群岛调查沉船真相。', themeKeywords: ['海雾', '旧航路'], toneDirectives: ['压抑', '悬疑'], openingSituation: '首夜就有陌生船只闯入禁航区。', coreConflicts: ['航运公会与守灯会争夺航路控制权'], keyFactions: [], keyCharacters: [], keyLandmarks: [], iconicElements: ['会移动的海雾'], forbiddenDirectives: [], rawSettingText: '', }, draftProfile: { name: '潮雾列岛', subtitle: '旧灯塔与失控航路', summary: '第一版世界底稿已经整理完成。', tone: '压抑、潮湿、悬疑', playerGoal: '查清沉船与禁航区异动的真相。', majorFactions: ['守灯会', '航运公会'], coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], playableNpcs: [], storyNpcs: [], landmarks: [], factions: [], threads: [], chapters: [], worldHook: '被海雾吞没的旧航路群岛', playerPremise: '玩家回到群岛调查沉船真相。', openingSituation: '首夜就有陌生船只闯入禁航区。', iconicElements: ['会移动的海雾'], sourceAnchorSummary: '海雾、旧灯塔、失控航路。', legacyResultProfile: { id: 'agent-draft-custom-world-agent-session-1', settingText: '被海雾吞没的旧航路群岛', name: '潮雾列岛·session最新版', subtitle: '旧灯塔与失控航路', summary: '作品库应该保存这份同步后的最新快照。', tone: '压抑、潮湿、悬疑', playerGoal: '查清沉船与禁航区异动的真相。', templateWorldType: 'WUXIA', majorFactions: ['守灯会', '航运公会'], coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], playableNpcs: [], storyNpcs: [], items: [], landmarks: [], generationMode: 'full', generationStatus: 'complete', }, }, draftCards: [ { id: 'world-foundation', kind: 'world', title: '潮雾列岛', subtitle: '旧灯塔与失控航路', summary: '第一版世界底稿已经整理完成。', status: 'warning', linkedIds: [], warningCount: 0, }, ], } satisfies CustomWorldAgentSessionSnapshot; vi.mocked(getCustomWorldAgentSession).mockResolvedValue(syncedSession); render(); await openNewRpgCreation(user); await waitFor( async () => { expect(await screen.findByText('世界档案')).toBeTruthy(); expect(screen.getByText('已自动保存')).toBeTruthy(); }, { timeout: 2500 }, ); await waitFor(() => { expect(upsertCustomWorldProfile).toHaveBeenCalled(); }); const latestSavedProfile = vi.mocked(upsertCustomWorldProfile).mock.calls.at(-1)?.[0]; expect(latestSavedProfile?.name).toBe('潮雾列岛·session最新版'); expect(latestSavedProfile?.summary).toBe( '作品库应该保存这份同步后的最新快照。', ); }); test('authenticated users with save archives default into the saves tab', async () => { vi.mocked(listProfileSaveArchives).mockResolvedValue([ { worldKey: 'custom:world-1', ownerUserId: null, profileId: 'world-1', worldType: 'CUSTOM', worldName: '潮雾列岛', subtitle: '旧灯塔与失控航路', summaryText: '回到旧灯塔继续推进调查。', coverImageSrc: null, lastPlayedAt: '2026-04-19T12:00:00.000Z', }, ]); render(); expect((await screen.findAllByText('全部存档')).length).toBeGreaterThan(0); expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0); expect(screen.getAllByText('最近更新时间排序').length).toBeGreaterThan(0); expect(screen.queryByText('SAVE ARCHIVE')).toBeNull(); }); test('save tab can resume a selected archive directly into the game', async () => { const user = userEvent.setup(); const handleContinueGame = vi.fn(); vi.mocked(listProfileSaveArchives).mockResolvedValue([ { worldKey: 'custom:world-1', ownerUserId: null, profileId: 'world-1', worldType: 'CUSTOM', worldName: '潮雾列岛', subtitle: '旧灯塔与失控航路', summaryText: '回到旧灯塔继续推进调查。', coverImageSrc: null, lastPlayedAt: '2026-04-19T12:00:00.000Z', }, ]); vi.mocked(resumeProfileSaveArchive).mockResolvedValue({ entry: { worldKey: 'custom:world-1', ownerUserId: null, profileId: 'world-1', worldType: 'CUSTOM', worldName: '潮雾列岛', subtitle: '旧灯塔与失控航路', summaryText: '回到旧灯塔继续推进调查。', coverImageSrc: null, lastPlayedAt: '2026-04-19T12:00:00.000Z', }, snapshot: { version: 2, savedAt: '2026-04-19T12:00:00.000Z', bottomTab: 'adventure', currentStory: null, gameState: { worldType: 'CUSTOM', } as GameState, } as HydratedSavedGameSnapshot, }); render(); await clickFirstAsyncButtonByName(user, /潮雾列岛/u); await waitFor(() => { expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1'); expect(handleContinueGame).toHaveBeenCalledTimes(1); }); }); test('owned world detail can delete a work and return to the create tab list', async () => { const user = userEvent.setup(); vi.spyOn(window, 'confirm').mockReturnValue(true); const publishedWork = { workId: 'published:world-delete-1', sourceType: 'published_profile' as const, status: 'published' as const, title: '潮雾列岛', subtitle: '旧灯塔与失控航路', summary: '用于测试删除流程的作品。', coverImageSrc: null, coverRenderMode: 'image' as const, coverCharacterImageSrcs: [], updatedAt: '2026-04-16T12:00:00.000Z', publishedAt: '2026-04-16T12:00:00.000Z', stage: null, stageLabel: '已发布', playableNpcCount: 0, landmarkCount: 0, roleVisualReadyCount: 0, roleAnimationReadyCount: 0, roleAssetSummaryLabel: null, sessionId: null, profileId: 'world-delete-1', canResume: false, canEnterWorld: true, }; const publishedLibraryEntry = { ownerUserId: 'user-1', profileId: 'world-delete-1', profile: { id: 'world-delete-1', name: '潮雾列岛', subtitle: '旧灯塔与失控航路', summary: '用于测试删除流程的作品。', tone: '压抑、潮湿、悬疑', playerGoal: '查清旧案。', majorFactions: ['守灯会'], coreConflicts: ['雾潮正在逼近港口'], playableNpcs: [], storyNpcs: [], landmarks: [], } as never, visibility: 'published' as const, publishedAt: '2026-04-16T12:00:00.000Z', updatedAt: '2026-04-16T12:00:00.000Z', authorDisplayName: '测试玩家', worldName: '潮雾列岛', subtitle: '旧灯塔与失控航路', summaryText: '用于测试删除流程的作品。', coverImageSrc: null, themeMode: 'tide' as const, playableNpcCount: 0, landmarkCount: 0, }; vi.mocked(listCustomWorldWorks) .mockResolvedValueOnce([publishedWork]) .mockResolvedValue([]); vi.mocked(listCustomWorldLibrary) .mockResolvedValueOnce([publishedLibraryEntry]) .mockResolvedValue([]); vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]); render(); await openCreationHub(user); await user.click(screen.getByRole('button', { name: /进入世界/u })); await user.click(await screen.findByRole('button', { name: '删除作品' })); await waitFor(() => { expect(deleteCustomWorldProfile).toHaveBeenCalledWith('world-delete-1'); }); await waitFor(() => { expect(screen.queryByRole('button', { name: '删除作品' })).toBeNull(); }); await waitFor(() => { expect(screen.getByText('还没有作品')).toBeTruthy(); }); }); test('creation hub published work enters existing detail view', async () => { const user = userEvent.setup(); vi.mocked(listCustomWorldWorks).mockResolvedValue([ { workId: 'published:world-public-1', sourceType: 'published_profile', status: 'published', title: '潮雾列岛', subtitle: '旧灯塔与失控航路', summary: '已经发布的群岛世界作品。', coverImageSrc: null, coverRenderMode: 'image', coverCharacterImageSrcs: [], updatedAt: '2026-04-20T10:00:00.000Z', publishedAt: '2026-04-20T10:00:00.000Z', stage: null, stageLabel: '已发布', playableNpcCount: 3, landmarkCount: 4, roleVisualReadyCount: 1, roleAnimationReadyCount: 0, roleAssetSummaryLabel: null, sessionId: null, profileId: 'world-public-1', canResume: false, canEnterWorld: true, }, ]); vi.mocked(listCustomWorldLibrary).mockResolvedValue([ { ownerUserId: 'user-1', profileId: 'world-public-1', profile: { id: 'world-public-1', name: '潮雾列岛', subtitle: '旧灯塔与失控航路', summary: '已经发布的群岛世界作品。', tone: '压抑、潮湿、悬疑', playerGoal: '查清群岛旧案。', majorFactions: ['守灯会'], coreConflicts: ['假航灯正在扰乱航线'], playableNpcs: [], storyNpcs: [], landmarks: [], } as never, visibility: 'published', publishedAt: '2026-04-20T10:00:00.000Z', updatedAt: '2026-04-20T10:00:00.000Z', authorDisplayName: '测试玩家', worldName: '潮雾列岛', subtitle: '旧灯塔与失控航路', summaryText: '已经发布的群岛世界作品。', coverImageSrc: null, themeMode: 'tide', playableNpcCount: 3, landmarkCount: 4, }, ]); render(); await openCreationHub(user); await user.click(screen.getByRole('button', { name: /进入世界/u })); expect(await screen.findByText('世界信息')).toBeTruthy(); expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy(); expect(screen.getByText('已发布')).toBeTruthy(); });