/* @vitest-environment jsdom */ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { beforeEach, expect, test, vi } from 'vitest'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { ApiClientError } from '../../services/apiClient'; import type { AuthUser } from '../../services/authService'; import { createBigFishCreationSession, getBigFishCreationSession, } from '../../services/big-fish-creation'; import { listBigFishGallery } from '../../services/big-fish-gallery'; import { startLocalBigFishRuntimeRun } from '../../services/big-fish-runtime'; import { listBigFishWorks } from '../../services/big-fish-works'; import { createPuzzleAgentSession, getPuzzleAgentSession, } from '../../services/puzzle-agent'; import { getPuzzleGalleryDetail, listPuzzleGallery, } from '../../services/puzzle-gallery'; import { listPuzzleWorks } from '../../services/puzzle-works'; import { createRpgCreationSession, executeRpgCreationAction, getRpgCreationOperation, getRpgCreationResultView, getRpgCreationSession, listRpgCreationWorks, streamRpgCreationMessage, upsertRpgWorldProfile, } from '../../services/rpg-creation'; import { clearRpgProfileBrowseHistory as clearProfileBrowseHistory, getRpgEntryWorldGalleryDetail, getRpgProfileDashboard as getProfileDashboard, listRpgEntryWorldGallery, listRpgEntryWorldLibrary, listRpgProfileBrowseHistory as listProfileBrowseHistory, listRpgProfileSaveArchives as listProfileSaveArchives, resumeRpgProfileSaveArchive as resumeProfileSaveArchive, upsertRpgProfileBrowseHistory as upsertProfileBrowseHistory, } from '../../services/rpg-entry'; import { deleteRpgEntryWorldProfile, getRpgEntryWorldGalleryDetailByCode, } from '../../services/rpg-entry/rpgEntryLibraryClient'; import { AuthUiContext, type PlatformSettingsSection, } from '../auth/AuthUiContext'; import { RpgEntryFlowShell, type RpgEntryFlowShellProps, type SelectionStage, } from './RpgEntryFlowShell'; 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('角色扮演 RPG')).toBeTruthy(); } async function openNewRpgCreation(user: ReturnType) { await openCreationHub(user); await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u })); } function getPlatformTabPanel(tab: string) { const panel = document.getElementById(`platform-tab-panel-${tab}`); if (!panel) { throw new Error(`Missing platform tab panel: ${tab}`); } return panel; } vi.mock('../../services/rpg-creation', () => ({ createRpgCreationSession: vi.fn(), executeRpgCreationAction: vi.fn(), getRpgCreationOperation: vi.fn(), getRpgCreationResultView: vi.fn(), getRpgCreationSession: vi.fn(), listRpgCreationWorks: vi.fn(), streamRpgCreationMessage: vi.fn(), upsertRpgWorldProfile: vi.fn(), })); vi.mock('../../services/rpg-entry', () => ({ clearRpgProfileBrowseHistory: vi.fn(), deleteRpgEntryWorldProfile: vi.fn(), getRpgEntryWorldGalleryDetail: vi.fn(), getRpgProfileDashboard: vi.fn(), listRpgEntryWorldGallery: vi.fn(), listRpgEntryWorldLibrary: vi.fn(), listRpgProfileBrowseHistory: vi.fn(), listRpgProfileSaveArchives: vi.fn(), publishRpgEntryWorldProfile: vi.fn(), resumeRpgProfileSaveArchive: vi.fn(), syncRpgProfileBrowseHistory: vi.fn(), unpublishRpgEntryWorldProfile: vi.fn(), upsertRpgProfileBrowseHistory: vi.fn(), })); vi.mock('../../services/puzzle-works', () => ({ listPuzzleWorks: vi.fn(), })); vi.mock('../../services/puzzle-gallery', () => ({ getPuzzleGalleryDetail: vi.fn(), listPuzzleGallery: vi.fn(), })); vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({ deleteRpgEntryWorldProfile: vi.fn(), getRpgEntryWorldGalleryDetailByCode: vi.fn(), })); vi.mock('../../services/big-fish-creation', () => ({ createBigFishCreationSession: vi.fn(), executeBigFishCreationAction: vi.fn(), getBigFishCreationSession: vi.fn(), streamBigFishCreationMessage: vi.fn(), })); vi.mock('../../services/big-fish-works', () => ({ listBigFishWorks: vi.fn(), })); vi.mock('../../services/big-fish-gallery', () => ({ listBigFishGallery: vi.fn(), })); vi.mock('../../services/big-fish-runtime', () => ({ advanceLocalBigFishRuntimeRun: vi.fn((run) => run), startLocalBigFishRuntimeRun: vi.fn(), })); vi.mock('../../services/puzzle-agent', () => ({ createPuzzleAgentSession: vi.fn(), executePuzzleAgentAction: vi.fn(), getPuzzleAgentSession: vi.fn(), streamPuzzleAgentMessage: vi.fn(), })); vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({ PuzzleAgentWorkspace: ({ session, onBack, }: { session: { sessionId: string; messages: Array<{ text: string }> } | null; onBack: () => void; }) => (
拼图工作区:{session?.sessionId ?? 'missing-session'}
{session?.messages.map((message) => (
{message.text}
))}
), })); vi.mock('../puzzle-result/PuzzleResultView', () => ({ PuzzleResultView: ({ session, onBack, }: { session: { draft?: { levelName: string } | null }; onBack: () => void; }) => (
拼图结果页
), })); vi.mock('../puzzle-gallery/PuzzleGalleryDetailView', () => ({ PuzzleGalleryDetailView: ({ item, onBack, onStartGame, }: { item: { levelName: string }; onBack: () => void; onStartGame: () => void; }) => (
{item.levelName}
), })); vi.mock('../big-fish-creation/BigFishAgentWorkspace', () => ({ BigFishAgentWorkspace: ({ session, }: { session: { sessionId: string; messages: Array<{ text: string }> } | null; }) => (
大鱼吃小鱼工作区:{session?.sessionId ?? 'missing-session'}
{session?.messages.map((message) => (
{message.text}
))}
), })); vi.mock('../big-fish-result/BigFishResultView', () => ({ BigFishResultView: ({ session, onBack, onExecuteAction, }: { session: { draft?: { title: string } | null }; onBack: () => void; onExecuteAction: (payload: { action: string }) => void; }) => (
大鱼吃小鱼结果页
{session.draft?.title ?? '缺少草稿标题'}
), })); 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: '被海雾吞没的旧航路群岛,灯塔与禁航令共同决定谁能穿过死潮,体验压抑、潮湿、悬疑。', playerFantasy: '玩家是被迫返乡的守灯人继承者,追查沉船夜与假航灯的关系,风险是失去家族最后一条可信航线。', themeBoundary: '压抑、悬疑;潮湿群岛、冷雾港口;避免轻喜冒险。', playerEntryPoint: '玩家以返乡守灯人继承者身份切入,回港首夜撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。', coreConflict: '守灯会与航运公会争夺航路解释权,有人在借假航灯持续清洗旧案证据,玩家返乡当夜就被卷进封航冲突。', keyRelationships: '玩家与沈砺旧友互疑,沈砺知道沉船夜的另一半真相。', hiddenLines: '沉船夜与假航灯骗局属于同一操盘链条,表面像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。', iconicElements: '假航灯、沉钟回响、旧灯塔、禁航碑;错误航灯会把船引进必死水域。', }, 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: '测试玩家', publicUserCode: 'user-tester', phoneNumberMasked: null, loginMethod: 'password', bindingStatus: 'active', wechatBound: false, }; const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = { ...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, }, ], resultPreview: { source: 'session_preview', preview: { id: 'agent-draft-custom-world-agent-session-1', settingText: '被海雾吞没的旧航路群岛', name: '潮雾列岛', subtitle: '旧灯塔与失控航路', summary: '第一版世界底稿已经整理完成。', tone: '压抑、潮湿、悬疑', playerGoal: '查清沉船与禁航区异动的真相。', templateWorldType: 'WUXIA', majorFactions: ['守灯会', '航运公会'], coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], playableNpcs: [ { id: 'playable-1', name: '沈砺', title: '旧航路引路人', role: '关键同行者', description: '最熟悉旧航路的人。', backstory: '曾在沉船夜里带着半支船队逃出海雾。', personality: '表面沉稳,心里一直在算退路。', motivation: '想赶在守灯会封航前查清真相。', combatStyle: '借地形和潮路换位,先拉扯再压近。', initialAffinity: 18, relationshipHooks: ['旧友', '沉船旧案'], tags: ['潮路', '引路'], }, ], storyNpcs: [ { id: 'story-1', name: '顾潮音', title: '守灯会值夜人', role: '场景关键角色', description: '夜里巡灯与封锁禁航区的人。', backstory: '在失控海雾第一次吞船那夜,她是最后一个还留在灯塔顶的人。', personality: '冷静克制,但提到旧灯册时会显得过分警觉。', motivation: '想守住灯塔记录,也想找到谁先改动了禁航信号。', combatStyle: '借塔顶视角和风向压制,再用灯火错位扰乱。', initialAffinity: 8, relationshipHooks: ['禁航记录', '灯塔值夜'], tags: ['守灯会', '灯塔'], }, ], items: [], landmarks: [ { id: 'landmark-1', name: '回潮旧灯塔', description: '旧灯塔是整片群岛最先看见异动的地方。', sceneNpcIds: ['story-1'], connections: [], }, ], generationMode: 'full', generationStatus: 'complete', sessionId: 'custom-world-agent-session-1', }, generatedAt: '2026-04-14T12:00:00.000Z', qualityFindings: [], blockers: [], publishReady: true, canEnterWorld: false, }, }; function buildResultViewForSession( session: CustomWorldAgentSessionSnapshot, ): RpgCreationResultView { const profile = session.resultPreview?.preview ?? null; const isResultStage = session.stage === 'object_refining' || session.stage === 'visual_refining' || session.stage === 'long_tail_review' || session.stage === 'ready_to_publish' || session.stage === 'published'; return { session, profile, profileSource: profile ? 'result_preview' : 'none', targetStage: profile && isResultStage ? 'custom-world-result' : session.stage === 'error' ? 'custom-world-generating' : 'agent-workspace', generationViewSource: session.stage === 'error' ? 'agent-draft-foundation' : null, resultViewSource: profile && isResultStage ? 'agent-draft' : null, canAutosaveLibrary: Boolean(profile && isResultStage), canSyncResultProfile: session.stage === 'object_refining' || session.stage === 'visual_refining' || session.stage === 'long_tail_review' || session.stage === 'ready_to_publish', publishReady: Boolean(session.resultPreview?.publishReady), canEnterWorld: Boolean(session.resultPreview?.canEnterWorld), blockerCount: session.resultPreview?.blockers?.length ?? 0, recoveryAction: profile && isResultStage ? 'open_result' : session.stage === 'error' ? 'resume_generation' : 'continue_agent', recoveryReason: null, }; } type TestAuthValue = { user: AuthUser | null; canAccessProtectedData: boolean; 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, canAccessProtectedData: true, 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, onSelectWorld, }: { withAuth?: boolean; authValue?: TestAuthValue; onContinueGame?: (snapshot?: HydratedSavedGameSnapshot | null) => void; onSelectWorld?: RpgEntryFlowShellProps['handleCustomWorldSelect']; } = {}) { const [selectionStage, setSelectionStage] = useState(() => window.location.pathname === '/creation/rpg/agent' ? 'agent-workspace' : 'platform', ); const content = ( {})} handleStartNewGame={() => {}} handleCustomWorldSelect={onSelectWorld ?? (() => {})} /> ); if (!withAuth && !authValue) { return content; } return ( {content} ); } beforeEach(() => { vi.resetAllMocks(); 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(listRpgEntryWorldLibrary).mockResolvedValue([]); vi.mocked(listRpgEntryWorldGallery).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 HydratedSavedGameSnapshot, }); vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]); vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]); vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]); vi.mocked(getRpgEntryWorldGalleryDetailByCode).mockRejectedValue( new Error('未找到公开作品'), ); vi.mocked(upsertRpgWorldProfile).mockResolvedValue({ entry: { ownerUserId: 'user-1', profileId: 'agent-draft-custom-world-agent-session-1', publicWorkCode: null, authorPublicUserCode: null, 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(createRpgCreationSession).mockResolvedValue({ session: mockSession, }); vi.mocked(getRpgCreationResultView).mockImplementation(async () => buildResultViewForSession(mockSession), ); vi.mocked(createBigFishCreationSession).mockResolvedValue({ session: { sessionId: 'big-fish-session-1', currentTurn: 0, progressPercent: 0, stage: 'clarifying', anchorPack: { gameplayPromise: { key: 'gameplay_promise', label: '核心玩法', value: '', status: 'missing', }, ecologyVisualTheme: { key: 'ecology_visual_theme', label: '生态视觉', value: '', status: 'missing', }, growthLadder: { key: 'growth_ladder', label: '成长阶梯', value: '', status: 'missing', }, riskTempo: { key: 'risk_tempo', label: '风险节奏', value: '', status: 'missing', }, }, draft: null, assetSlots: [], assetCoverage: { levelMainImageReadyCount: 0, levelMotionReadyCount: 0, backgroundReady: false, requiredLevelCount: 0, publishReady: false, blockers: [], }, messages: [], lastAssistantReply: '先说说你想要什么样的大鱼生态。', publishReady: false, updatedAt: '2026-04-22T12:00:00.000Z', }, }); vi.mocked(getBigFishCreationSession).mockResolvedValue({ session: { sessionId: 'big-fish-session-1', currentTurn: 2, progressPercent: 90, stage: 'draft_ready', anchorPack: { gameplayPromise: { key: 'gameplay_promise', label: '核心玩法', value: '机械微生物吞并进化', status: 'confirmed', }, ecologyVisualTheme: { key: 'ecology_visual_theme', label: '生态视觉', value: '深海机械浮游生态', status: 'confirmed', }, growthLadder: { key: 'growth_ladder', label: '成长阶梯', value: '从微光孢子到深海巨鲲', status: 'confirmed', }, riskTempo: { key: 'risk_tempo', label: '风险节奏', value: '快节奏吞并,后段压迫感增强', status: 'confirmed', }, }, draft: { title: '机械深海 大鱼吃小鱼', subtitle: '机械微生物吞并进化 · 偏爽快节奏', coreFun: '吞并更小机械生命并持续合体成长', ecologyTheme: '深海机械浮游生态', levels: [ { level: 1, name: '微光孢子', oneLineFantasy: '像发光尘埃一样在深海漂浮。', textDescription: '微光孢子是机械深海生态中的起始个体,体型最小,会先漂浮试探并寻找可吞并目标。', silhouetteDirection: '圆润微型机械球', sizeRatio: 1, visualDescription: '带有浅色发光核心的微型机械鱼苗或孢子体,轮廓圆润,表现出弱小但灵动的初始形象。', visualPromptSeed: 'deep sea glowing mechanical spore', idleMotionDescription: '待机时轻轻漂浮,身体和尾部做小幅摆动,像在适应深海水流。', moveMotionDescription: '移动时核心前探,尾部快速摆动推进,带出轻盈的游动轨迹。', motionPromptSeed: 'soft floating mechanical spore', mergeSourceLevel: null, preyWindow: [1], threatWindow: [2], isFinalLevel: false, }, ], background: { theme: '机械深海', colorMood: '冷青色与暗金反光', foregroundHints: '漂浮齿轮碎片', midgroundComposition: '珊瑚状机械群落', backgroundDepth: '深海远景光柱', safePlayAreaHint: '中心区域留空', spawnEdgeHint: '边缘暗流刷怪', backgroundPromptSeed: 'mechanical deep sea arena', }, runtimeParams: { levelCount: 8, mergeCountPerUpgrade: 3, spawnTargetCount: 28, leaderMoveSpeed: 1.2, followerCatchUpSpeed: 1, offscreenCullSeconds: 8, preySpawnDeltaLevels: [-2, -1], threatSpawnDeltaLevels: [1, 2], winLevel: 8, }, }, assetSlots: [], assetCoverage: { levelMainImageReadyCount: 0, levelMotionReadyCount: 0, backgroundReady: false, requiredLevelCount: 8, publishReady: false, blockers: ['仍有主图、动作和背景未生成'], }, messages: [ { id: 'big-fish-message-1', role: 'assistant', kind: 'chat', text: '先说说你想要什么样的大鱼生态。', createdAt: '2026-04-22T12:00:00.000Z', }, { id: 'big-fish-message-2', role: 'user', kind: 'chat', text: '我想做机械深海里微生物互相吞并进化。', createdAt: '2026-04-22T12:01:00.000Z', }, ], lastAssistantReply: '大鱼结果页草稿已经生成,可以补正式资产。', publishReady: false, updatedAt: '2026-04-22T12:10:00.000Z', }, }); vi.mocked(createPuzzleAgentSession).mockResolvedValue({ session: { sessionId: 'puzzle-session-1', currentTurn: 0, progressPercent: 0, stage: 'collecting_anchors', anchorPack: { themePromise: { key: 'theme_promise', label: '主题承诺', value: '', status: 'missing', }, visualSubject: { key: 'visual_subject', label: '视觉主体', value: '', status: 'missing', }, visualMood: { key: 'visual_mood', label: '视觉气质', value: '', status: 'missing', }, compositionHooks: { key: 'composition_hooks', label: '构图钩子', value: '', status: 'missing', }, tagsAndForbidden: { key: 'tags_and_forbidden', label: '标签与禁区', value: '', status: 'missing', }, }, draft: null, messages: [], lastAssistantReply: '先说一个你最想做成拼图的画面。', publishedProfileId: null, suggestedActions: [], resultPreview: null, updatedAt: '2026-04-22T12:00:00.000Z', }, }); vi.mocked(getPuzzleAgentSession).mockResolvedValue({ session: { sessionId: 'puzzle-session-1', currentTurn: 3, progressPercent: 88, stage: 'draft_ready', anchorPack: { themePromise: { key: 'theme_promise', label: '主题承诺', value: '雨夜遗迹探索', status: 'confirmed', }, visualSubject: { key: 'visual_subject', label: '视觉主体', value: '发光猫咪站在遗迹台阶上', status: 'confirmed', }, visualMood: { key: 'visual_mood', label: '视觉气质', value: '潮湿、梦幻、轻悬疑', status: 'confirmed', }, compositionHooks: { key: 'composition_hooks', label: '构图钩子', value: '台阶透视、倒影、门洞', status: 'confirmed', }, tagsAndForbidden: { key: 'tags_and_forbidden', label: '标签与禁区', value: '雨夜、猫咪、遗迹;禁止文字水印', status: 'confirmed', }, }, draft: { levelName: '雨夜猫塔', summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。', themeTags: ['雨夜', '猫咪', '遗迹'], forbiddenDirectives: ['文字水印'], creatorIntent: null, anchorPack: { themePromise: { key: 'theme_promise', label: '主题承诺', value: '雨夜遗迹探索', status: 'confirmed', }, visualSubject: { key: 'visual_subject', label: '视觉主体', value: '发光猫咪站在遗迹台阶上', status: 'confirmed', }, visualMood: { key: 'visual_mood', label: '视觉气质', value: '潮湿、梦幻、轻悬疑', status: 'confirmed', }, compositionHooks: { key: 'composition_hooks', label: '构图钩子', value: '台阶透视、倒影、门洞', status: 'confirmed', }, tagsAndForbidden: { key: 'tags_and_forbidden', label: '标签与禁区', value: '雨夜、猫咪、遗迹;禁止文字水印', status: 'confirmed', }, }, candidates: [], selectedCandidateId: null, coverImageSrc: null, coverAssetId: null, generationStatus: 'idle', }, messages: [ { id: 'puzzle-message-1', role: 'assistant', kind: 'chat', text: '先说一个你最想做成拼图的画面。', createdAt: '2026-04-22T12:00:00.000Z', }, { id: 'puzzle-message-2', role: 'user', kind: 'chat', text: '雨夜里有一只会发光的猫站在遗迹台阶上。', createdAt: '2026-04-22T12:01:00.000Z', }, ], lastAssistantReply: '拼图结果页草稿已经生成,可以开始出图并确认标签。', publishedProfileId: null, suggestedActions: [], resultPreview: { draft: { levelName: '雨夜猫塔', summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。', themeTags: ['雨夜', '猫咪', '遗迹'], forbiddenDirectives: ['文字水印'], creatorIntent: null, anchorPack: { themePromise: { key: 'theme_promise', label: '主题承诺', value: '雨夜遗迹探索', status: 'confirmed', }, visualSubject: { key: 'visual_subject', label: '视觉主体', value: '发光猫咪站在遗迹台阶上', status: 'confirmed', }, visualMood: { key: 'visual_mood', label: '视觉气质', value: '潮湿、梦幻、轻悬疑', status: 'confirmed', }, compositionHooks: { key: 'composition_hooks', label: '构图钩子', value: '台阶透视、倒影、门洞', status: 'confirmed', }, tagsAndForbidden: { key: 'tags_and_forbidden', label: '标签与禁区', value: '雨夜、猫咪、遗迹;禁止文字水印', status: 'confirmed', }, }, candidates: [], selectedCandidateId: null, coverImageSrc: null, coverAssetId: null, generationStatus: 'idle', }, blockers: [ { id: 'missing-cover-image', code: 'MISSING_COVER_IMAGE', message: '正式拼图图片尚未确定', }, ], qualityFindings: [], publishReady: false, }, updatedAt: '2026-04-22T12:10:00.000Z', }, }); vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession); vi.mocked(listRpgCreationWorks).mockResolvedValue([]); vi.mocked(listBigFishWorks).mockResolvedValue({ items: [], }); vi.mocked(listBigFishGallery).mockResolvedValue({ items: [], }); vi.mocked(startLocalBigFishRuntimeRun).mockReturnValue({ runId: 'big-fish-run-1', sessionId: 'big-fish-session-public-1', status: 'running', tick: 0, playerLevel: 1, winLevel: 8, leaderEntityId: 'owned-1', ownedEntities: [ { entityId: 'owned-1', level: 1, position: { x: 0, y: 0 }, radius: 12, offscreenSeconds: 0, }, ], wildEntities: [], cameraCenter: { x: 0, y: 0 }, lastInput: { x: 0, y: 0 }, eventLog: ['机械鱼群开始巡游。'], updatedAt: '2026-04-25T12:12:00.000Z', }); vi.mocked(listPuzzleWorks).mockResolvedValue({ items: [], }); vi.mocked(listPuzzleGallery).mockResolvedValue({ items: [], }); vi.mocked(executeRpgCreationAction).mockResolvedValue({ operation: { operationId: 'operation-draft-foundation-1', type: 'draft_foundation', status: 'queued', phaseLabel: '已接收请求', phaseDetail: '正在准备生成世界底稿。', progress: 10, error: null, }, }); vi.mocked(getRpgCreationOperation).mockResolvedValue({ operationId: 'operation-draft-foundation-1', type: 'draft_foundation', status: 'running', phaseLabel: '生成世界底稿', phaseDetail: '正在根据已确认锚点编译第一版世界结构。', progress: 38, error: null, }); vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession); vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession); }); test('create hub exposes direct template entry, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => { const user = userEvent.setup(); render(); await openCreationHub(user); 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(createRpgCreationSession).toHaveBeenCalledTimes(1); }); expect( await screen.findByText( 'Agent工作区:custom-world-agent-session-1', {}, { timeout: 5000 }, ), ).toBeTruthy(); }); test('platform create hub does not prefetch hidden big fish platform data', async () => { const user = userEvent.setup(); render(); await openCreationHub(user); expect( await screen.findByRole('button', { name: /角色扮演 RPG/u }), ).toBeTruthy(); expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull(); expect(listBigFishWorks).not.toHaveBeenCalled(); expect(listBigFishGallery).not.toHaveBeenCalled(); }); test('opening RPG agent workspace does not refetch session snapshot in a render loop', async () => { const user = userEvent.setup(); render(); await openNewRpgCreation(user); expect( await screen.findByText( 'Agent工作区:custom-world-agent-session-1', {}, { timeout: 5000 }, ), ).toBeTruthy(); await new Promise((resolve) => { window.setTimeout(resolve, 120); }); expect(getRpgCreationSession).toHaveBeenCalledTimes(1); }); test('create tab opens compiled agent draft in result refinement page', async () => { const user = userEvent.setup(); vi.mocked(listRpgCreationWorks).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, }, ]); vi.mocked(getRpgCreationSession).mockResolvedValue(compiledAgentDraftSession); vi.mocked(getRpgCreationResultView).mockResolvedValue( buildResultViewForSession(compiledAgentDraftSession), ); render(); await openCreationHub(user); expect(await screen.findByRole('button', { name: /继续完善/u })).toBeTruthy(); await user.click(await screen.findByRole('button', { name: /继续完善/u })); await waitFor( () => { expect(screen.queryByText('正在加载世界编辑器...')).toBeNull(); }, { timeout: 5000 }, ); expect( await screen.findByText('世界档案', {}, { timeout: 5000 }), ).toBeTruthy(); expect( screen.queryByText('Agent工作区:custom-world-agent-session-1'), ).toBeNull(); expect(screen.getByRole('button', { name: /返回创作/u })).toBeTruthy(); }, 10000); test('create tab resumes agent workspace when draft has no compiled result yet', async () => { const user = userEvent.setup(); vi.mocked(listRpgCreationWorks).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: 'clarifying', stageLabel: '补齐关键锚点', playableNpcCount: 0, landmarkCount: 0, roleVisualReadyCount: 0, roleAnimationReadyCount: 0, roleAssetSummaryLabel: null, sessionId: 'custom-world-agent-session-1', profileId: null, canResume: true, canEnterWorld: false, }, ]); render(); await openCreationHub(user); expect(await screen.findByRole('button', { name: /继续创作/u })).toBeTruthy(); await user.click(await screen.findByRole('button', { name: /继续创作/u })); expect( await screen.findByText('Agent工作区:custom-world-agent-session-1'), ).toBeTruthy(); expect(screen.queryByText('世界档案')).toBeNull(); }); test('create tab resumes agent workspace when session has no draft profile even if summary counts look compiled', async () => { const user = userEvent.setup(); vi.mocked(listRpgCreationWorks).mockResolvedValue([ { workId: 'draft:custom-world-agent-session-1', sourceType: 'agent_session', status: 'draft', title: '潮雾列岛', subtitle: '待完善草稿', summary: '作品卡摘要仍带着旧对象数量,但服务端还没有草稿 profile。', coverImageSrc: null, coverRenderMode: 'image', coverCharacterImageSrcs: [], updatedAt: '2026-04-20T10:00:00.000Z', publishedAt: null, stage: 'clarifying', stageLabel: '补齐关键锚点', playableNpcCount: 2, landmarkCount: 1, roleVisualReadyCount: 0, roleAnimationReadyCount: 0, roleAssetSummaryLabel: null, sessionId: 'custom-world-agent-session-1', profileId: null, canResume: true, canEnterWorld: false, }, ]); vi.mocked(getRpgCreationSession).mockResolvedValue({ ...mockSession, stage: 'clarifying', draftProfile: null, }); vi.mocked(getRpgCreationResultView).mockResolvedValue( buildResultViewForSession({ ...mockSession, stage: 'clarifying', draftProfile: null, }), ); render(); await openCreationHub(user); expect(await screen.findByRole('button', { name: /继续完善/u })).toBeTruthy(); await user.click(await screen.findByRole('button', { name: /继续完善/u })); expect( await screen.findByText('Agent工作区:custom-world-agent-session-1'), ).toBeTruthy(); expect(screen.queryByText('世界档案')).toBeNull(); }); test('opening a compiled draft with a missing agent session falls back to create hub', async () => { const user = userEvent.setup(); vi.mocked(listRpgCreationWorks) .mockResolvedValueOnce([ { workId: 'draft:custom-world-agent-session-missing', sourceType: 'agent_session', status: 'draft', title: '潮雾列岛', subtitle: '世界底稿已生成', summary: '这是一份已经整理过首版结果页的草稿。', coverImageSrc: null, coverRenderMode: 'image', coverCharacterImageSrcs: [], updatedAt: '2026-04-20T11:00:00.000Z', publishedAt: null, stage: 'object_refining', stageLabel: '整理关键对象', playableNpcCount: 1, landmarkCount: 1, roleVisualReadyCount: 0, roleAnimationReadyCount: 0, roleAssetSummaryLabel: null, sessionId: 'custom-world-agent-session-missing', profileId: null, canResume: true, canEnterWorld: false, }, ]) .mockResolvedValueOnce([]); const missingSessionError = new ApiClientError({ message: 'custom world agent session not found', status: 404, code: 'NOT_FOUND', }); vi.mocked(getRpgCreationSession).mockRejectedValueOnce(missingSessionError); vi.mocked(getRpgCreationResultView).mockRejectedValueOnce(missingSessionError); render(); await openCreationHub(user); await user.click(await screen.findByRole('button', { name: /继续完善/u })); await waitFor(() => { expect( within(getPlatformTabPanel('create')).getByText( '这份共创草稿已失效,已为你返回创作中心,请重新开始创作。', ), ).toBeTruthy(); }); expect(window.location.search).toBe(''); expect(listRpgCreationWorks).toHaveBeenCalledTimes(2); expect(screen.getByText('还没有作品')).toBeTruthy(); expect( screen.queryByText('Agent工作区:custom-world-agent-session-missing'), ).toBeNull(); }); test('clicking a public work while logged out routes through requireAuth', async () => { const user = userEvent.setup(); const requireAuth = vi.fn(); vi.mocked(listRpgEntryWorldGallery).mockResolvedValue([ { ownerUserId: 'author-1', profileId: 'world-public-1', publicWorkCode: 'work-public-1', authorPublicUserCode: 'author-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(getRpgEntryWorldGalleryDetail).not.toHaveBeenCalled(); }); test('creation hub clears all private work shelves immediately after logout state', async () => { const user = userEvent.setup(); const loggedInAuth = createAuthValue(); const loggedOutAuth = createAuthValue({ user: null, canAccessProtectedData: false, openLoginModal: () => {}, requireAuth: () => {}, }); vi.mocked(listRpgCreationWorks).mockResolvedValue([ { workId: 'draft:rpg-logout-cache-1', sourceType: 'agent_session', status: 'draft', title: 'RPG 退出缓存作品', subtitle: '登出后不应继续可见', summary: '这条 RPG 私有作品只能在登录态展示。', coverImageSrc: null, coverRenderMode: 'image', coverCharacterImageSrcs: [], updatedAt: '2026-04-25T10:00:00.000Z', publishedAt: null, stage: 'clarifying', stageLabel: '补齐关键锚点', playableNpcCount: 0, landmarkCount: 0, roleVisualReadyCount: 0, roleAnimationReadyCount: 0, roleAssetSummaryLabel: null, sessionId: 'rpg-logout-cache-session', profileId: null, canResume: true, canEnterWorld: false, }, ]); vi.mocked(listBigFishWorks).mockResolvedValue({ items: [ { workId: 'big-fish-logout-cache-1', sourceSessionId: 'big-fish-logout-cache-session', ownerUserId: 'user-1', title: '大鱼退出缓存作品', subtitle: '登出后不应继续可见', summary: '这条大鱼私有作品只能在登录态展示。', coverImageSrc: null, status: 'draft', updatedAt: '2026-04-25T10:05:00.000Z', publishReady: false, levelCount: 8, levelMainImageReadyCount: 0, levelMotionReadyCount: 0, backgroundReady: false, }, ], }); vi.mocked(listPuzzleWorks).mockResolvedValue({ items: [ { workId: 'puzzle-logout-cache-1', profileId: 'puzzle-logout-cache-profile', ownerUserId: 'user-1', sourceSessionId: 'puzzle-logout-cache-session', authorDisplayName: '测试玩家', levelName: '拼图退出缓存作品', summary: '这条拼图私有作品只能在登录态展示。', themeTags: ['退出态'], coverImageSrc: null, publicationStatus: 'draft', updatedAt: '2026-04-25T10:10:00.000Z', publishedAt: null, playCount: 0, publishReady: false, }, ], }); const { rerender } = render(); await openCreationHub(user); const createPanel = getPlatformTabPanel('create'); expect(await within(createPanel).findByText('RPG 退出缓存作品')).toBeTruthy(); expect(within(createPanel).queryByText('大鱼退出缓存作品')).toBeNull(); expect(await within(createPanel).findByText('拼图退出缓存作品')).toBeTruthy(); rerender(); await waitFor(() => { expect(within(createPanel).queryByText('RPG 退出缓存作品')).toBeNull(); expect(within(createPanel).queryByText('拼图退出缓存作品')).toBeNull(); }); expect(within(createPanel).getByText('还没有作品')).toBeTruthy(); }); test('published puzzle works appear on home and category public shelves', async () => { const user = userEvent.setup(); const publishedPuzzleWork = { workId: 'puzzle-work-public-1', profileId: 'puzzle-profile-public-1', ownerUserId: 'user-2', sourceSessionId: 'puzzle-session-public-1', authorDisplayName: '拼图作者', levelName: '星桥机关', summary: '旋转碎片并接通星桥机关。', themeTags: ['机关', '星桥'], coverImageSrc: null, coverAssetId: null, publicationStatus: 'published', updatedAt: '2026-04-25T09:00:00.000Z', publishedAt: '2026-04-25T09:00:00.000Z', playCount: 3, publishReady: true, } satisfies PuzzleWorkSummary; vi.mocked(listPuzzleGallery).mockResolvedValue({ items: [publishedPuzzleWork], }); vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({ item: publishedPuzzleWork, }); render(); await waitFor(() => { expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0); }); await user.click(screen.getByRole('button', { name: '分类' })); const categoryPanel = getPlatformTabPanel('category'); expect(within(categoryPanel).getAllByText('星桥机关').length).toBeGreaterThan( 0, ); expect( within(categoryPanel).getAllByRole('button', { name: /机关/u }).length, ).toBeGreaterThan(0); }); test('published big fish works stay hidden from platform home and category shelves', async () => { const user = userEvent.setup(); const publishedBigFishWork: BigFishWorkSummary = { workId: 'big-fish-work-public-1', sourceSessionId: 'big-fish-session-public-1', ownerUserId: 'user-2', title: '机械深海 大鱼吃小鱼', subtitle: '机械微生物吞并进化', summary: '从微光孢子一路吞并成长到深海巨鲲。', coverImageSrc: null, status: 'published', updatedAt: '2026-04-25T10:30:00.000Z', publishReady: true, levelCount: 8, levelMainImageReadyCount: 8, levelMotionReadyCount: 16, backgroundReady: true, }; vi.mocked(listBigFishGallery).mockResolvedValue({ items: [publishedBigFishWork], }); render(); await waitFor(() => { expect(listBigFishGallery).not.toHaveBeenCalled(); }); expect(screen.queryByText('机械深海 大鱼吃小鱼')).toBeNull(); await user.click(screen.getByRole('button', { name: '分类' })); const categoryPanel = getPlatformTabPanel('category'); expect(within(categoryPanel).queryByText('机械深海 大鱼吃小鱼')).toBeNull(); expect( within(categoryPanel).queryAllByRole('button', { name: /大鱼/u }).length, ).toBe(0); }); test('published puzzle detail returns to the source platform tab', async () => { const user = userEvent.setup(); const publishedPuzzleWork = { workId: 'puzzle-work-public-1', profileId: 'puzzle-profile-public-1', ownerUserId: 'user-2', sourceSessionId: null, authorDisplayName: '拼图作者', levelName: '星桥机关', summary: '旋转碎片并接通星桥机关。', themeTags: ['机关', '星桥'], coverImageSrc: null, coverAssetId: null, publicationStatus: 'published', updatedAt: '2026-04-25T09:00:00.000Z', publishedAt: '2026-04-25T09:00:00.000Z', playCount: 3, publishReady: true, } satisfies PuzzleWorkSummary; vi.mocked(listPuzzleGallery).mockResolvedValue({ items: [publishedPuzzleWork], }); vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({ item: publishedPuzzleWork, }); render(); await user.click(await screen.findByRole('button', { name: '分类' })); await waitFor(() => { expect(document.getElementById('platform-tab-panel-category')).toBeTruthy(); }); await waitFor(() => { const categoryPanel = getPlatformTabPanel('category'); expect( within(categoryPanel).getAllByText('星桥机关').length, ).toBeGreaterThan(0); }); const categoryPanel = getPlatformTabPanel('category'); await user.click( within(categoryPanel).getByRole('button', { name: /拼图关卡.*星桥机关/u, }), ); expect( await screen.findByRole('button', { name: '进入第 1 关' }), ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回' })); await waitFor(() => { const returnedCategoryPanel = getPlatformTabPanel('category'); expect(returnedCategoryPanel.getAttribute('aria-hidden')).toBe('false'); expect( within(returnedCategoryPanel).getAllByText('星桥机关').length, ).toBeGreaterThan(0); }); }); 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(createRpgCreationSession).not.toHaveBeenCalled(); }); test('restoring an agent workspace while logged out opens login modal before loading the protected session', async () => { const openLoginModal = vi.fn(); window.history.replaceState( null, '', '/?customWorldSessionId=custom-world-agent-session-1', ); render( , ); await waitFor(() => { expect(openLoginModal).toHaveBeenCalledTimes(1); }); expect(openLoginModal).toHaveBeenCalledWith(expect.any(Function)); expect(getRpgCreationSession).not.toHaveBeenCalled(); }); test('restoring an agent workspace ignores a stored session owned by another user', async () => { window.sessionStorage.setItem( 'genarrative.custom-world-agent-ui.v1', JSON.stringify({ activeSessionId: 'custom-world-agent-session-other-user', activeOperationId: null, ownerUserId: 'user-other', }), ); render(); await waitFor(() => { expect( window.sessionStorage.getItem('genarrative.custom-world-agent-ui.v1'), ).toBeNull(); }); expect(getRpgCreationSession).not.toHaveBeenCalled(); expect(window.location.search).toBe(''); }); test('restoring an agent workspace ignores explicit session pointer without local owner after login', async () => { window.history.replaceState( null, '', '/?customWorldSessionId=custom-world-agent-session-legacy', ); render(); await waitFor(() => { expect(window.location.search).toBe(''); }); expect(getRpgCreationSession).not.toHaveBeenCalled(); }); test('refreshing platform home ignores stored agent workspace pointer without explicit restore path', async () => { window.sessionStorage.setItem( 'genarrative.custom-world-agent-ui.v1', JSON.stringify({ activeSessionId: 'custom-world-agent-session-1', activeOperationId: null, ownerUserId: 'user-1', }), ); render(); expect(await screen.findByRole('button', { name: '创作' })).toBeTruthy(); expect(screen.queryByText(/Agent工作区/u)).toBeNull(); expect(getRpgCreationSession).not.toHaveBeenCalled(); expect(window.location.pathname).toBe('/'); }); test('refreshing RPG agent path restores stored agent workspace pointer', async () => { window.history.replaceState(null, '', '/creation/rpg/agent'); window.sessionStorage.setItem( 'genarrative.custom-world-agent-ui.v1', JSON.stringify({ activeSessionId: 'custom-world-agent-session-1', activeOperationId: null, ownerUserId: 'user-1', }), ); render(); await waitFor(() => { expect(getRpgCreationSession).toHaveBeenCalledWith( 'custom-world-agent-session-1', ); }); expect( await screen.findByText('Agent工作区:custom-world-agent-session-1'), ).toBeTruthy(); }); test('new creation entry maps raw bearer token errors to user-facing auth copy', async () => { const user = userEvent.setup(); vi.mocked(createRpgCreationSession).mockRejectedValueOnce( new ApiClientError({ message: '缺少 Authorization Bearer Token', status: 401, code: 'UNAUTHORIZED', }), ); render(); await openCreationHub(user); await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u })); await waitFor(() => { expect( within(getPlatformTabPanel('create')).getByText( '当前登录状态已失效,请重新登录后继续。', ), ).toBeTruthy(); }); expect(listPuzzleWorks).toHaveBeenCalled(); expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull(); }); test('hidden big fish creation entry does not render in platform create hub', async () => { const user = userEvent.setup(); render(); await openCreationHub(user); expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull(); expect(createBigFishCreationSession).not.toHaveBeenCalled(); }); test('puzzle creation timeout exits busy state and shows a readable error', async () => { const user = userEvent.setup(); vi.mocked(createPuzzleAgentSession).mockRejectedValueOnce( Object.assign(new Error('请求超时:15000ms'), { name: 'TimeoutError', }), ); render(); await openCreationHub(user); const button = screen.getByRole('button', { name: /拼图玩法/u }); await user.click(button); await waitFor(() => { expect( within(getPlatformTabPanel('create')).getAllByText( '开启拼图创作工作台超时,请确认运行时后端已启动后重试。', ).length, ).toBeGreaterThan(0); }); expect((button as HTMLButtonElement).disabled).toBe(false); expect(screen.queryByText(/正在准备拼图共创工作区/u)).toBeNull(); }); test('puzzle draft card restores the bound agent session and opens the result view', async () => { const user = userEvent.setup(); vi.mocked(listPuzzleWorks).mockResolvedValue({ items: [ { workId: 'puzzle-work-session-1', profileId: 'puzzle-profile-session-1', ownerUserId: 'user-1', sourceSessionId: 'puzzle-session-1', authorDisplayName: '测试玩家', levelName: '雨夜猫塔', summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。', themeTags: ['雨夜', '猫咪', '遗迹'], coverImageSrc: null, coverAssetId: null, publicationStatus: 'draft', updatedAt: '2026-04-22T12:10:00.000Z', publishedAt: null, playCount: 0, publishReady: false, }, ], }); render(); await openCreationHub(user); expect(await screen.findByRole('button', { name: /继续创作/u })).toBeTruthy(); await user.click(await screen.findByRole('button', { name: /继续创作/u })); await waitFor(() => { expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1'); }); expect(await screen.findByText('拼图结果页')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回' })); expect( await screen.findByText('雨夜里有一只会发光的猫站在遗迹台阶上。'), ).toBeTruthy(); expect(screen.queryByText('拼图玩法共创')).toBeNull(); }); test('published puzzle work card restores its source session for editing', async () => { const user = userEvent.setup(); vi.mocked(listPuzzleWorks).mockResolvedValue({ items: [ { workId: 'puzzle-work-session-1', profileId: 'puzzle-profile-session-1', ownerUserId: 'user-1', sourceSessionId: 'puzzle-session-1', authorDisplayName: '测试玩家', levelName: '雨夜猫塔', summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。', themeTags: ['雨夜', '猫咪', '遗迹'], coverImageSrc: null, coverAssetId: null, publicationStatus: 'published', updatedAt: '2026-04-25T12:10:00.000Z', publishedAt: '2026-04-25T12:10:00.000Z', playCount: 8, publishReady: true, }, ], }); render(); await openCreationHub(user); expect(await screen.findByRole('button', { name: /继续创作/u })).toBeTruthy(); await user.click(await screen.findByRole('button', { name: /继续创作/u })); await waitFor(() => { expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1'); }); expect(getPuzzleGalleryDetail).not.toHaveBeenCalledWith( 'puzzle-profile-session-1', ); expect(await screen.findByText('拼图结果页')).toBeTruthy(); expect(screen.getByDisplayValue('雨夜猫塔')).toBeTruthy(); }); test('public code search opens a published puzzle by PZ code', async () => { const user = userEvent.setup(); const puzzleWork: PuzzleWorkSummary = { workId: 'puzzle-work-public-1', profileId: 'puzzle-profile-public-1', ownerUserId: 'user-2', sourceSessionId: null, authorDisplayName: '拼图作者', levelName: '雨夜猫塔', summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。', themeTags: ['雨夜', '猫咪', '遗迹'], coverImageSrc: null, coverAssetId: null, publicationStatus: 'published', updatedAt: '2026-04-25T12:10:00.000Z', publishedAt: '2026-04-25T12:10:00.000Z', playCount: 8, publishReady: true, }; vi.mocked(listPuzzleGallery).mockResolvedValue({ items: [puzzleWork], }); vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({ item: puzzleWork, }); render(); const searchInput = await screen.findByPlaceholderText( '输入 SY / CW / BF / PZ 编号', ); await user.type(searchInput, 'PZ-EPUBLIC1'); await user.click(screen.getByRole('button', { name: '搜索' })); await waitFor(() => { expect(getPuzzleGalleryDetail).toHaveBeenCalledWith( 'puzzle-profile-public-1', ); }); expect(await screen.findByText('进入第 1 关')).toBeTruthy(); expect(screen.getByText('雨夜猫塔')).toBeTruthy(); expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled(); }); test('public code search opens a published big fish work by BF code', async () => { const user = userEvent.setup(); const bigFishWork: BigFishWorkSummary = { workId: 'big-fish-work-public-1', sourceSessionId: 'big-fish-session-public-1', ownerUserId: 'user-2', title: '机械深海 大鱼吃小鱼', subtitle: '机械微生物吞并进化', summary: '从微光孢子一路吞并成长到深海巨鲲。', coverImageSrc: null, status: 'published', updatedAt: '2026-04-25T10:30:00.000Z', publishReady: true, levelCount: 8, levelMainImageReadyCount: 8, levelMotionReadyCount: 16, backgroundReady: true, }; vi.mocked(listBigFishGallery).mockResolvedValue({ items: [bigFishWork], }); render(); const searchInput = await screen.findByPlaceholderText( '输入 SY / CW / BF / PZ 编号', ); await user.type(searchInput, 'BF-NPUBLIC1'); await user.click(screen.getByRole('button', { name: '搜索' })); await waitFor(() => { expect(startLocalBigFishRuntimeRun).toHaveBeenCalledWith({ work: expect.objectContaining({ sourceSessionId: 'big-fish-session-public-1', }), }); }); expect(await screen.findByText('Lv.1/8 · 进行中')).toBeTruthy(); expect(getBigFishCreationSession).not.toHaveBeenCalledWith( 'big-fish-session-public-1', ); expect(getRpgEntryWorldGalleryDetailByCode).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(executeRpgCreationAction).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('refresh restores running draft generation progress instead of agent workspace', async () => { window.history.replaceState( null, '', '/?customWorldSessionId=custom-world-agent-session-1&customWorldOperationId=operation-draft-foundation-1&customWorldGenerationSource=agent-draft-foundation', ); vi.mocked(getRpgCreationOperation).mockResolvedValue({ operationId: 'operation-draft-foundation-1', type: 'draft_foundation', status: 'running', phaseLabel: '生成世界底稿', phaseDetail: '正在根据已确认锚点编译第一版世界结构。', progress: 38, error: null, }); render(); expect(await screen.findByText('世界草稿生成进度')).toBeTruthy(); expect(screen.queryByText(/Agent工作区/u)).toBeNull(); expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0); }); test('failed draft work continues on generation progress view instead of agent workspace', async () => { const user = userEvent.setup(); vi.mocked(listRpgCreationWorks).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: 'clarifying', stageLabel: '生成失败待处理', playableNpcCount: 0, landmarkCount: 0, roleVisualReadyCount: 0, roleAnimationReadyCount: 0, roleAssetSummaryLabel: null, sessionId: 'custom-world-agent-session-1', profileId: null, canResume: true, canEnterWorld: false, }, ]); vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession); vi.mocked(getRpgCreationResultView).mockResolvedValue({ ...buildResultViewForSession({ ...mockSession, stage: 'error', }), targetStage: 'custom-world-generating', generationViewSource: 'agent-draft-foundation', recoveryAction: 'resume_generation', }); render(); await openCreationHub(user); expect(await screen.findByText('失败中的潮雾列岛')).toBeTruthy(); await user.click(await screen.findByRole('button', { name: /继续创作/u })); expect(await screen.findByText('世界草稿生成进度')).toBeTruthy(); expect(screen.queryByText(/Agent工作区/u)).toBeNull(); }); test('existing draft sessions open result page refinement instead of agent dialog', async () => { const user = userEvent.setup(); vi.mocked(getRpgCreationOperation).mockResolvedValue({ operationId: 'operation-draft-foundation-1', type: 'draft_foundation', status: 'completed', phaseLabel: '世界底稿已生成', phaseDetail: '第一版世界底稿和 4 张草稿卡已经整理完成。', progress: 100, error: null, }); vi.mocked(getRpgCreationSession).mockResolvedValue(compiledAgentDraftSession); vi.mocked(getRpgCreationResultView).mockResolvedValue( buildResultViewForSession(compiledAgentDraftSession), ); render(); await openNewRpgCreation(user); await waitFor( async () => { expect(await screen.findByText('世界档案')).toBeTruthy(); expect(screen.getByText('已自动保存')).toBeTruthy(); expect(screen.getByRole('button', { name: '作品测试' })).toBeTruthy(); expect(screen.getByRole('button', { name: '发布' })).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(); await user.click(screen.getByRole('button', { name: /顾潮音/u })); expect(await screen.findByText(/编辑场景角色:顾潮音/u)).toBeTruthy(); expect(screen.getByRole('button', { name: /AI生成/u })).toBeTruthy(); }); test('agent result view shows publish blocker dialog before publish action when preview gate is not ready', async () => { const user = userEvent.setup(); vi.mocked(getRpgCreationOperation).mockResolvedValue({ operationId: 'operation-draft-foundation-1', type: 'draft_foundation', status: 'completed', phaseLabel: '世界底稿已生成', phaseDetail: '第一版世界底稿和 4 张草稿卡已经整理完成。', progress: 100, error: null, }); const blockedSession = { ...compiledAgentDraftSession, resultPreview: { ...compiledAgentDraftSession.resultPreview!, publishReady: false, blockers: [ { id: 'publish-role-assets-incomplete', code: 'publish_role_assets_incomplete', message: '仍有角色缺少正式主图或动作资产,发布前需要先补齐。', }, ], }, } satisfies CustomWorldAgentSessionSnapshot; vi.mocked(getRpgCreationSession).mockResolvedValue(blockedSession); vi.mocked(getRpgCreationResultView).mockResolvedValue( buildResultViewForSession(blockedSession), ); render(); await openNewRpgCreation(user); const actionButton = await screen.findByRole( 'button', { name: '发布' }, { timeout: 5000 }, ); expect((actionButton as HTMLButtonElement).disabled).toBe(false); const publishWorldCallCountBeforeClick = vi .mocked(executeRpgCreationAction) .mock.calls.filter( ([sessionId, payload]) => sessionId === 'custom-world-agent-session-1' && payload?.action === 'publish_world', ).length; await user.click(actionButton); expect(await screen.findByRole('dialog', { name: '发布作品' })).toBeTruthy(); expect(screen.getByText('发布检查')).toBeTruthy(); expect(screen.getByText('封面设置')).toBeTruthy(); expect(screen.getByText(/仍有角色缺少正式主图或动作资产/u)).toBeTruthy(); const publishWorldCallCountAfterClick = vi .mocked(executeRpgCreationAction) .mock.calls.filter( ([sessionId, payload]) => sessionId === 'custom-world-agent-session-1' && payload?.action === 'publish_world', ).length; expect(publishWorldCallCountAfterClick).toBe( publishWorldCallCountBeforeClick, ); }); test('agent draft result publishes to gallery from publish panel', async () => { const user = userEvent.setup(); const handleCustomWorldSelect = vi.fn(); const publishReadyDraftSession = { ...compiledAgentDraftSession, stage: 'ready_to_publish' as const, resultPreview: { ...compiledAgentDraftSession.resultPreview!, publishReady: true, canEnterWorld: false, blockers: [], }, } satisfies CustomWorldAgentSessionSnapshot; const publishedSession = { ...publishReadyDraftSession, stage: 'published' as const, resultPreview: { ...publishReadyDraftSession.resultPreview!, publishReady: true, canEnterWorld: true, blockers: [], preview: { ...publishReadyDraftSession.resultPreview!.preview, id: 'agent-draft-custom-world-agent-session-1', name: '潮雾列岛·已发布', summary: '发布完成后应直接使用已发布预览进入世界。', }, }, } satisfies CustomWorldAgentSessionSnapshot; let hasPublishedWorld = false; vi.mocked(createRpgCreationSession).mockResolvedValue({ session: publishReadyDraftSession, }); vi.mocked(getRpgCreationOperation).mockResolvedValueOnce({ operationId: 'operation-publish-world-1', type: 'publish_world', status: 'completed', phaseLabel: '世界已发布', phaseDetail: '正式世界档案已写入作品库。', progress: 100, error: null, }); vi.mocked(executeRpgCreationAction).mockImplementation(async (_, payload) => { if (payload.action === 'publish_world') { hasPublishedWorld = true; } return { operation: { operationId: 'operation-publish-world-1', type: 'publish_world', status: 'queued', phaseLabel: '执行发布校验', phaseDetail: '正在检查角色资产、场景图和主线草稿是否满足发布门槛。', progress: 28, error: null, }, }; }); vi.mocked(getRpgCreationSession).mockImplementation(async () => hasPublishedWorld ? publishedSession : publishReadyDraftSession, ); vi.mocked(getRpgCreationResultView).mockImplementation(async () => buildResultViewForSession( hasPublishedWorld ? publishedSession : publishReadyDraftSession, ), ); function PublishFlowWrapper() { const [selectionStage, setSelectionStage] = useState('platform'); return ( {}} handleStartNewGame={() => {}} handleCustomWorldSelect={handleCustomWorldSelect} /> ); } render(); await openNewRpgCreation(user); const actionButton = await screen.findByRole( 'button', { name: '发布', }, { timeout: 5000 }, ); await user.click(actionButton); await user.click(await screen.findByRole('button', { name: '发布到广场' })); await waitFor(() => { expect(executeRpgCreationAction).toHaveBeenCalledWith( 'custom-world-agent-session-1', expect.objectContaining({ action: 'publish_world', }), ); }); expect(handleCustomWorldSelect).not.toHaveBeenCalled(); }); test('agent draft result test button enters current draft without publish gate', async () => { const user = userEvent.setup(); const handleCustomWorldSelect = vi.fn(); vi.mocked(getRpgCreationOperation).mockResolvedValue({ operationId: 'operation-draft-foundation-1', type: 'draft_foundation', status: 'completed', phaseLabel: '世界底稿已生成', phaseDetail: '第一版世界底稿和 4 张草稿卡已经整理完成。', progress: 100, error: null, }); const testDraftSession = { ...compiledAgentDraftSession, stage: 'ready_to_publish', resultPreview: { ...compiledAgentDraftSession.resultPreview!, publishReady: false, canEnterWorld: false, blockers: [ { id: 'missing-cover-image', code: 'MISSING_COVER_IMAGE', message: '发布前需要补齐作品封面。', }, ], }, } satisfies CustomWorldAgentSessionSnapshot; vi.mocked(getRpgCreationSession).mockResolvedValue(testDraftSession); vi.mocked(getRpgCreationResultView).mockResolvedValue( buildResultViewForSession(testDraftSession), ); function TestDraftWrapper() { const [selectionStage, setSelectionStage] = useState('platform'); return ( {}} handleStartNewGame={() => {}} handleCustomWorldSelect={handleCustomWorldSelect} /> ); } render(); await openNewRpgCreation(user); await user.click(await screen.findByRole('button', { name: '作品测试' })); await waitFor(() => { expect(handleCustomWorldSelect).toHaveBeenCalledWith( expect.objectContaining({ name: '潮雾列岛' }), expect.objectContaining({ mode: 'play', disablePersistence: true, returnStage: 'custom-world-result', }), ); }); expect( vi .mocked(executeRpgCreationAction) .mock.calls.some( ([sessionId, payload]) => sessionId === 'custom-world-agent-session-1' && payload?.action === 'publish_world', ), ).toBe(false); }); test('agent result view does not keep legacy publish blockers when preview uses anchorContent and sceneChapterBlueprints', async () => { const user = userEvent.setup(); vi.mocked(listRpgCreationWorks).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: 'ready_to_publish', stageLabel: '待发布草稿', playableNpcCount: 3, landmarkCount: 1, roleVisualReadyCount: 1, roleAnimationReadyCount: 0, roleAssetSummaryLabel: null, sessionId: 'custom-world-agent-session-1', profileId: null, canResume: true, canEnterWorld: false, }, ]); vi.mocked(getRpgCreationOperation).mockResolvedValue({ operationId: 'operation-draft-foundation-1', type: 'draft_foundation', status: 'completed', phaseLabel: '世界底稿已生成', phaseDetail: '第一版世界底稿和 4 张草稿卡已经整理完成。', progress: 100, error: null, }); const publishGateSession = { ...compiledAgentDraftSession, stage: 'ready_to_publish', resultPreview: { ...compiledAgentDraftSession.resultPreview!, publishReady: true, blockers: [], preview: { ...compiledAgentDraftSession.resultPreview!.preview, settingText: '被海雾吞没的旧航路群岛', anchorContent: { worldPromise: '被海雾吞没的旧航路群岛,灯塔与禁航令共同决定谁能穿过死潮,体验压抑、潮湿、悬疑。', playerFantasy: '玩家是被迫返乡的守灯人继承者,追查沉船夜与假航灯的关系,风险是失去家族最后一条可信航线。', themeBoundary: '压抑、悬疑;潮湿群岛、冷雾港口;避免轻喜冒险。', playerEntryPoint: '玩家以返乡守灯人继承者身份切入,回港首夜撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。', coreConflict: '守灯会与航运公会争夺航路解释权,有人在借假航灯持续清洗旧案证据,玩家返乡当夜就被卷进封航冲突。', keyRelationships: null, hiddenLines: '沉船夜与假航灯骗局属于同一操盘链条,表面像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。', iconicElements: '假航灯、沉钟回响、旧灯塔、禁航碑;错误航灯会把船引进必死水域。', }, creatorIntent: { sourceMode: 'card', rawSettingText: '', worldHook: '被海雾吞没的旧航路群岛', themeKeywords: ['海雾', '旧航路'], toneDirectives: ['压抑', '悬疑'], playerPremise: '玩家回到群岛调查沉船真相。', openingSituation: '首夜就有陌生船只闯入禁航区。', coreConflicts: ['航运公会与守灯会争夺航路控制权'], keyFactions: [], keyCharacters: [], keyLandmarks: [], iconicElements: ['会移动的海雾'], forbiddenDirectives: [], }, sceneChapterBlueprints: [ { id: 'scene-chapter-1', sceneId: 'landmark-1', title: '沉钟栈桥章节', summary: '围绕沉钟栈桥推进的三幕结构。', linkedThreadIds: [], linkedLandmarkIds: ['landmark-1'], acts: [ { id: 'scene-act-1', sceneId: 'landmark-1', title: '潮声逼近', summary: '第一幕先把潮声与旧钟压上来。', stageCoverage: ['opening'], encounterNpcIds: ['story-1'], primaryNpcId: 'story-1', linkedThreadIds: [], advanceRule: 'after_primary_contact', actGoal: '接住首幕压力', transitionHook: '继续逼近钟楼深处。', }, ], }, ], }, }, } satisfies CustomWorldAgentSessionSnapshot; vi.mocked(getRpgCreationSession).mockResolvedValue(publishGateSession); vi.mocked(getRpgCreationResultView).mockResolvedValue( buildResultViewForSession(publishGateSession), ); render(); await openCreationHub(user); expect(await screen.findByRole('button', { name: /继续完善/u })).toBeTruthy(); await user.click(await screen.findByRole('button', { name: /继续完善/u })); await waitFor(() => { expect(screen.getByRole('button', { name: '作品测试' })).toBeTruthy(); expect(screen.getByRole('button', { name: '发布' })).toBeTruthy(); }); expect(screen.queryByText(/当前还有 4 个发布阻断项/u)).toBeNull(); const actionButton = screen.getByRole('button', { name: '发布', }); expect((actionButton as HTMLButtonElement).disabled).toBe(false); }); test('agent draft result back button returns to creation hub without syncing result profile', async () => { const user = userEvent.setup(); 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, }, ], resultPreview: { source: 'session_preview' as const, preview: { 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', sessionId: 'custom-world-agent-session-1', }, generatedAt: '2026-04-20T12:00:00.000Z', qualityFindings: [], blockers: [], publishReady: false, canEnterWorld: false, }, } satisfies CustomWorldAgentSessionSnapshot; vi.mocked(getRpgCreationSession).mockResolvedValue(resultSession); vi.mocked(getRpgCreationResultView).mockResolvedValue( buildResultViewForSession(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('角色扮演 RPG')).toBeTruthy(); }); expect( vi .mocked(executeRpgCreationAction) .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 syncs result profile before persisting backend result view', async () => { const user = userEvent.setup(); 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, }, ], resultPreview: { source: 'session_preview' as const, preview: { 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', sessionId: 'custom-world-agent-session-1', }, generatedAt: '2026-04-20T12:00:00.000Z', qualityFindings: [], blockers: [], publishReady: false, canEnterWorld: false, }, } satisfies CustomWorldAgentSessionSnapshot; vi.mocked(getRpgCreationSession).mockResolvedValue(syncedSession); vi.mocked(getRpgCreationResultView).mockResolvedValue( buildResultViewForSession(syncedSession), ); vi.mocked(executeRpgCreationAction).mockImplementation(async (_, payload) => ({ operation: { operationId: payload.action === 'sync_result_profile' ? 'operation-sync-result-profile-1' : 'operation-draft-foundation-1', type: payload.action, status: 'queued', phaseLabel: '已接收请求', phaseDetail: payload.action === 'sync_result_profile' ? '正在同步结果页档案。' : '正在准备生成世界底稿。', progress: 10, error: null, }, })); vi.mocked(getRpgCreationOperation).mockResolvedValue({ operationId: 'operation-sync-result-profile-1', type: 'sync_result_profile', status: 'completed', phaseLabel: '结果页档案已同步', phaseDetail: '服务端已根据最新结果页档案刷新会话预览。', progress: 100, error: null, }); render(); await openNewRpgCreation(user); await waitFor( async () => { expect(await screen.findByText('世界档案')).toBeTruthy(); expect(screen.getByText('已自动保存')).toBeTruthy(); }, { timeout: 2500 }, ); await waitFor(() => { expect(upsertRpgWorldProfile).toHaveBeenCalled(); }); const latestSavedProfile = vi .mocked(upsertRpgWorldProfile) .mock.calls.at(-1)?.[0]; const latestSaveRequest = vi .mocked(upsertRpgWorldProfile) .mock.calls.at(-1)?.[1]; expect(latestSavedProfile?.name).toBe('潮雾列岛·session最新版'); expect(latestSavedProfile?.summary).toBe( '作品库应该保存这份同步后的最新快照。', ); expect(latestSaveRequest).toEqual({ sourceAgentSessionId: 'custom-world-agent-session-1', }); expect( vi .mocked(executeRpgCreationAction) .mock.calls.some( ([sessionId, payload]) => sessionId === 'custom-world-agent-session-1' && payload?.action === 'sync_result_profile', ), ).toBe(true); }); test('agent draft result can open from server result preview without embedded legacyResultProfile', async () => { const user = userEvent.setup(); const previewOnlySession = { ...compiledAgentDraftSession, draftProfile: { ...compiledAgentDraftSession.draftProfile, playableNpcs: [], storyNpcs: [], landmarks: [], }, resultPreview: { source: 'session_preview' as const, preview: { id: 'agent-draft-custom-world-agent-session-1', settingText: '被海雾吞没的旧航路群岛', name: '潮雾列岛·服务端预览', subtitle: '结果页改为优先消费 session.resultPreview', summary: '即使 draft 中没有 legacyResultProfile,也应该正常打开结果页。', tone: '压抑、潮湿、悬疑', playerGoal: '查清沉船与禁航区异动的真相。', templateWorldType: 'WUXIA', majorFactions: ['守灯会', '航运公会'], coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], playableNpcs: [], storyNpcs: [], items: [], landmarks: [], generationMode: 'full', generationStatus: 'complete', sessionId: 'custom-world-agent-session-1', }, generatedAt: '2026-04-20T12:00:00.000Z', qualityFindings: [], blockers: [], }, } satisfies CustomWorldAgentSessionSnapshot; vi.mocked(getRpgCreationSession).mockResolvedValue(previewOnlySession); vi.mocked(getRpgCreationResultView).mockResolvedValue( buildResultViewForSession(previewOnlySession), ); render(); await openNewRpgCreation(user); await waitFor( async () => { expect(await screen.findByText('世界档案')).toBeTruthy(); expect(screen.getByText('潮雾列岛·服务端预览')).toBeTruthy(); expect( screen.getAllByText('结果页改为优先消费 session.resultPreview').length, ).toBeGreaterThan(0); }, { timeout: 2500 }, ); }); 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('manual tab switch is preserved after platform bootstrap requests finish', async () => { const user = userEvent.setup(); let resolveGalleryRequest!: (value: []) => void; const delayedGalleryRequest = new Promise<[]>((resolve) => { resolveGalleryRequest = resolve; }); vi.mocked(listRpgEntryWorldGallery).mockReturnValueOnce( delayedGalleryRequest as Promise<[]>, ); render(); await clickFirstButtonByName(user, '创作'); expect(await screen.findByText('角色扮演 RPG')).toBeTruthy(); resolveGalleryRequest([]); await waitFor(() => { expect( within(getPlatformTabPanel('create')).getByText('角色扮演 RPG'), ).toBeTruthy(); }); expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe( 'false', ); expect(getPlatformTabPanel('home').getAttribute('aria-hidden')).toBe('true'); }); 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 HydratedSavedGameSnapshot, }); render(); await clickFirstAsyncButtonByName(user, /潮雾列岛/u); await waitFor(() => { expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1'); expect(handleContinueGame).toHaveBeenCalledTimes(1); }); }); test('creation hub published work can open detail view before deleting from detail page', 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', publicWorkCode: 'work-delete-1', authorPublicUserCode: 'user-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(listRpgCreationWorks) .mockResolvedValueOnce([publishedWork]) .mockResolvedValue([]); vi.mocked(listRpgEntryWorldLibrary) .mockResolvedValueOnce([publishedLibraryEntry]) .mockResolvedValue([]); vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]); render(); await openCreationHub(user); await user.click(await screen.findByRole('button', { name: /查看详情/u })); await user.click(await screen.findByRole('button', { name: '删除作品' })); await waitFor(() => { expect(deleteRpgEntryWorldProfile).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(listRpgCreationWorks).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(listRpgEntryWorldLibrary).mockResolvedValue([ { ownerUserId: 'user-1', profileId: 'world-public-1', publicWorkCode: 'work-public-1', authorPublicUserCode: 'user-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(await screen.findByRole('button', { name: /查看详情/u })); expect(await screen.findByText('世界信息')).toBeTruthy(); expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy(); expect(screen.getByText('已发布')).toBeTruthy(); }); test('creation hub published work experience button enters world directly', async () => { const user = userEvent.setup(); const handleCustomWorldSelect = vi.fn(); vi.mocked(listRpgCreationWorks).mockResolvedValue([ { workId: 'published:world-experience-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-experience-1', canResume: false, canEnterWorld: true, }, ]); vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([ { ownerUserId: 'user-1', profileId: 'world-experience-1', publicWorkCode: 'work-experience-1', authorPublicUserCode: 'user-1', profile: { id: 'world-experience-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(await screen.findByRole('button', { name: '体验' })); await waitFor(() => { expect(handleCustomWorldSelect).toHaveBeenCalledWith( expect.objectContaining({ id: 'world-experience-1', name: '潮雾列岛', }), ); }); expect(screen.queryByText('世界信息')).toBeNull(); }); test('creation hub published work delete button removes the work directly from card list', async () => { const user = userEvent.setup(); vi.spyOn(window, 'confirm').mockReturnValue(true); const publishedWork = { workId: 'published:world-card-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-card-delete-1', canResume: false, canEnterWorld: true, }; const publishedLibraryEntry = { ownerUserId: 'user-1', profileId: 'world-card-delete-1', publicWorkCode: 'work-card-delete-1', authorPublicUserCode: 'user-1', profile: { id: 'world-card-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(listRpgCreationWorks) .mockResolvedValueOnce([publishedWork]) .mockResolvedValue([]); vi.mocked(listRpgEntryWorldLibrary) .mockResolvedValueOnce([publishedLibraryEntry]) .mockResolvedValue([]); vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]); render(); await openCreationHub(user); await user.click(await screen.findByRole('button', { name: '删除' })); await waitFor(() => { expect(deleteRpgEntryWorldProfile).toHaveBeenCalledWith( 'world-card-delete-1', ); }); await waitFor(() => { expect(screen.getByText('还没有作品')).toBeTruthy(); }); });