/* @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, CustomWorldWorkSummary, } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView'; import type { CustomWorldGalleryCard, CustomWorldLibraryEntry, } from '../../../packages/shared/src/contracts/runtime'; 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 { recordBigFishPlay, startLocalBigFishRuntimeRun, } from '../../services/big-fish-runtime'; import { listBigFishWorks } from '../../services/big-fish-works'; import { match3dCreationClient } from '../../services/match3d-creation'; import { clickMatch3DItem, finishMatch3DTimeUp, restartMatch3DRun, startMatch3DRun, stopMatch3DRun, } from '../../services/match3d-runtime'; import { deleteMatch3DWork, getMatch3DWorkDetail, listMatch3DGallery, listMatch3DWorks, } from '../../services/match3d-works'; import { createPuzzleAgentSession, getPuzzleAgentSession, } from '../../services/puzzle-agent'; import { getPuzzleGalleryDetail, listPuzzleGallery, remixPuzzleGalleryWork, } from '../../services/puzzle-gallery'; import { advanceLocalPuzzleNextLevel, advancePuzzleNextLevel, getPuzzleRun, startPuzzleRun, submitPuzzleLeaderboard, updatePuzzleRunPause, usePuzzleRuntimeProp, } from '../../services/puzzle-runtime'; import { dragLocalPuzzlePiece, swapLocalPuzzlePieces, } from '../../services/puzzle-runtime/puzzleLocalRuntime'; 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, getRpgEntryWorldGalleryDetail as getRpgEntryWorldGalleryDetailFromClient, getRpgEntryWorldGalleryDetailByCode, recordRpgEntryWorldGalleryPlay, remixRpgEntryWorldGallery, } from '../../services/rpg-entry/rpgEntryLibraryClient'; import { type CustomWorldProfile, WorldType } from '../../types'; 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('角色扮演')).toBeTruthy(); } async function openExistingRpgDraft( user: ReturnType, actionName: string | RegExp = /继续(?:完善|创作)/u, ) { await openCreationHub(user); await user.click(await screen.findByRole('button', { name: actionName })); } function getPlatformTabPanel(tab: string) { const panel = document.getElementById(`platform-tab-panel-${tab}`); if (!panel) { throw new Error(`Missing platform tab panel: ${tab}`); } return panel; } const rpgCreationServiceMocks = vi.hoisted(() => ({ createRpgCreationSession: vi.fn(), deleteRpgCreationAgentSession: vi.fn(), executeRpgCreationAction: vi.fn(), getRpgCreationOperation: vi.fn(), getRpgCreationResultView: vi.fn(), getRpgCreationSession: vi.fn(), listRpgCreationWorks: vi.fn(), streamRpgCreationMessage: vi.fn(), upsertRpgWorldProfile: vi.fn(), })); const rpgEntryLibraryServiceMocks = vi.hoisted(() => ({ deleteRpgEntryWorldProfile: vi.fn(), getRpgEntryWorldGalleryDetail: vi.fn(), getRpgEntryWorldGalleryDetailByCode: vi.fn(), getRpgEntryWorldLibraryDetail: vi.fn(), listRpgEntryWorldGallery: vi.fn(), listRpgEntryWorldLibrary: vi.fn(), publishRpgEntryWorldProfile: vi.fn(), recordRpgEntryWorldGalleryPlay: vi.fn(), remixRpgEntryWorldGallery: vi.fn(), unpublishRpgEntryWorldProfile: vi.fn(), upsertRpgEntryWorldProfile: vi.fn(), })); vi.mock('../../services/rpg-creation', () => ({ ...rpgCreationServiceMocks, })); vi.mock('../../services/rpg-creation/index', () => ({ ...rpgCreationServiceMocks, })); vi.mock('../../services/rpg-entry', () => ({ clearRpgProfileBrowseHistory: vi.fn(), deleteRpgEntryWorldProfile: rpgEntryLibraryServiceMocks.deleteRpgEntryWorldProfile, getRpgEntryWorldGalleryDetail: rpgEntryLibraryServiceMocks.getRpgEntryWorldGalleryDetail, getRpgProfileDashboard: vi.fn(), listRpgEntryWorldGallery: rpgEntryLibraryServiceMocks.listRpgEntryWorldGallery, listRpgEntryWorldLibrary: rpgEntryLibraryServiceMocks.listRpgEntryWorldLibrary, listRpgProfileBrowseHistory: vi.fn(), listRpgProfileSaveArchives: vi.fn(), publishRpgEntryWorldProfile: rpgEntryLibraryServiceMocks.publishRpgEntryWorldProfile, recordRpgEntryWorldGalleryPlay: rpgEntryLibraryServiceMocks.recordRpgEntryWorldGalleryPlay, resumeRpgProfileSaveArchive: vi.fn(), remixRpgEntryWorldGallery: rpgEntryLibraryServiceMocks.remixRpgEntryWorldGallery, syncRpgProfileBrowseHistory: vi.fn(), unpublishRpgEntryWorldProfile: rpgEntryLibraryServiceMocks.unpublishRpgEntryWorldProfile, upsertRpgProfileBrowseHistory: vi.fn(), })); vi.mock('../../services/puzzle-works', () => ({ listPuzzleWorks: vi.fn(), })); vi.mock('../../services/puzzle-gallery', () => ({ getPuzzleGalleryDetail: vi.fn(), listPuzzleGallery: vi.fn(), remixPuzzleGalleryWork: vi.fn(), })); vi.mock('../../services/puzzle-runtime', () => ({ advanceLocalPuzzleNextLevel: vi.fn(), advancePuzzleNextLevel: vi.fn(), getPuzzleRun: vi.fn(), startPuzzleRun: vi.fn(), swapPuzzlePieces: vi.fn(), submitPuzzleLeaderboard: vi.fn(), updatePuzzleRunPause: vi.fn(), usePuzzleRuntimeProp: vi.fn(), })); vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({ ...rpgEntryLibraryServiceMocks, })); 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), recordBigFishPlay: vi.fn(() => Promise.resolve()), startLocalBigFishRuntimeRun: vi.fn(), })); vi.mock('../../services/match3d-creation', () => ({ match3dCreationClient: { createSession: vi.fn(), executeAction: vi.fn(), getSession: vi.fn(), streamMessage: vi.fn(), }, })); vi.mock('../../services/match3d-works', () => ({ deleteMatch3DWork: vi.fn(), getMatch3DWorkDetail: vi.fn(), listMatch3DGallery: vi.fn(), listMatch3DWorks: vi.fn(), })); vi.mock('../../services/match3d-runtime', () => ({ clickMatch3DItem: vi.fn(), finishMatch3DTimeUp: vi.fn(), restartMatch3DRun: vi.fn(), startMatch3DRun: vi.fn(), stopMatch3DRun: vi.fn(), })); vi.mock('../../services/puzzle-runtime/puzzleLocalRuntime', async () => { const actual = await vi.importActual< typeof import('../../services/puzzle-runtime/puzzleLocalRuntime') >('../../services/puzzle-runtime/puzzleLocalRuntime'); return { ...actual, dragLocalPuzzlePiece: vi.fn(actual.dragLocalPuzzlePiece), swapLocalPuzzlePieces: vi.fn(actual.swapLocalPuzzlePieces), }; }); vi.mock('../../services/puzzle-agent', () => ({ createPuzzleAgentSession: vi.fn(), executePuzzleAgentAction: vi.fn(), getPuzzleAgentSession: vi.fn(), streamPuzzleAgentMessage: vi.fn(), })); vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({ PuzzleAgentWorkspace: ({ session, isBusy, error, onBack, onCreateFromForm, }: { session: { sessionId: string; messages: Array<{ text: string }> } | null; isBusy?: boolean; error?: string | null; onBack: () => void; onCreateFromForm?: (payload: { seedText: string; workTitle: string; workDescription: string; pictureDescription: string; referenceImageSrc: string | null; }) => void; }) => (
拼图工作区:{session?.sessionId ?? 'missing-session'}
{session?.messages.map((message) => (
{message.text}
))} {error ?
{error}
: null}
), })); 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('../match3d-runtime/Match3DRuntimeShell', () => ({ Match3DRuntimeShell: ({ run, onBack, }: { run: Match3DRunSnapshot | null; onBack: () => void; }) => (
抓大鹅运行态:{run?.runId ?? 'missing-run'}
), })); 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: '测试玩家', avatarUrl: null, publicUserCode: 'user-tester', phoneNumberMasked: null, loginMethod: 'password', bindingStatus: 'active', wechatBound: false, createdAt: new Date().toISOString(), }; function buildMockPuzzleRun( profileId: string, levelName: string, ): PuzzleRunSnapshot { const gridSize = 3 as const; return { runId: `run-${profileId}`, entryProfileId: profileId, clearedLevelCount: 0, currentLevelIndex: 1, currentGridSize: gridSize, playedProfileIds: [profileId], previousLevelTags: ['机关'], recommendedNextProfileId: null, leaderboardEntries: [], currentLevel: { runId: `run-${profileId}`, levelIndex: 1, levelId: 'puzzle-level-1', gridSize, profileId, levelName, authorDisplayName: '拼图作者', themeTags: ['机关'], coverImageSrc: null, status: 'playing', startedAtMs: 1_000, clearedAtMs: null, elapsedMs: null, timeLimitMs: 300_000, remainingMs: 300_000, pausedAccumulatedMs: 0, pauseStartedAtMs: null, freezeAccumulatedMs: 0, freezeStartedAtMs: null, freezeUntilMs: null, leaderboardEntries: [], board: { rows: 3, cols: 3, selectedPieceId: null, allTilesResolved: false, mergedGroups: [], pieces: Array.from({ length: 9 }, (_, index) => ({ pieceId: `piece-${index}`, correctRow: Math.floor(index / 3), correctCol: index % 3, currentRow: Math.floor(index / 3), currentCol: index % 3, mergedGroupId: null, })), }, }, }; } function buildClearedPuzzleRun(params: { runId: string; entryProfileId: string; profileId: string; levelName: string; levelIndex: number; elapsedMs: number; recommendedNextProfileId?: string | null; leaderboardEntries?: PuzzleRunSnapshot['leaderboardEntries']; }): PuzzleRunSnapshot { const baseRun = buildMockPuzzleRun(params.profileId, params.levelName); const currentLevel = baseRun.currentLevel!; const leaderboardEntries = params.leaderboardEntries ?? []; return { ...baseRun, runId: params.runId, entryProfileId: params.entryProfileId, currentLevelIndex: params.levelIndex, clearedLevelCount: params.levelIndex, currentGridSize: currentLevel.gridSize, playedProfileIds: params.entryProfileId === params.profileId ? [params.entryProfileId] : [params.entryProfileId, params.profileId], recommendedNextProfileId: params.recommendedNextProfileId ?? null, leaderboardEntries, currentLevel: { ...currentLevel, runId: params.runId, levelIndex: params.levelIndex, profileId: params.profileId, levelName: params.levelName, status: 'cleared', clearedAtMs: currentLevel.startedAtMs + params.elapsedMs, elapsedMs: params.elapsedMs, leaderboardEntries, board: { ...currentLevel.board, allTilesResolved: true, }, }, }; } function buildMockMatch3DRun(profileId: string): Match3DRunSnapshot { return { runId: `match3d-run-${profileId}`, profileId, ownerUserId: 'user-2', status: 'running', snapshotVersion: 1, startedAtMs: 1_000, durationLimitMs: 600_000, serverNowMs: 1_000, remainingMs: 600_000, clearCount: 4, totalItemCount: 12, clearedItemCount: 0, items: [], traySlots: Array.from({ length: 7 }, (_, slotIndex) => ({ slotIndex })), failureReason: null, }; } function buildMockRpgGalleryDetail( entry: CustomWorldGalleryCard, ): CustomWorldLibraryEntry { return { ...entry, profile: { id: entry.profileId, settingText: entry.summaryText, name: entry.worldName, subtitle: entry.subtitle, summary: entry.summaryText, tone: '压抑、潮湿、悬疑', playerGoal: '查清旧案。', templateWorldType: WorldType.WUXIA, attributeSchema: { id: `${entry.profileId}-attribute-schema`, worldId: entry.profileId, schemaVersion: 1, generatedFrom: { worldType: WorldType.CUSTOM, worldName: entry.worldName, settingSummary: entry.summaryText, tone: '压抑、潮湿、悬疑', conflictCore: '雾潮正在逼近港口', }, slots: [], }, majorFactions: ['守灯会'], coreConflicts: ['雾潮正在逼近港口'], playableNpcs: [], storyNpcs: [], items: [], landmarks: [], }, }; } 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, }; } function buildExistingRpgDraftWork( overrides: Partial = {}, ): CustomWorldWorkSummary { return { 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, ...overrides, }; } function mockExistingRpgDraftShelf( overrides: Partial = {}, ) { vi.mocked(listRpgCreationWorks).mockResolvedValue([ buildExistingRpgDraftWork(overrides), ]); } type TestAuthValue = { user: AuthUser | null; canAccessProtectedData: boolean; openLoginModal: (postLoginAction?: (() => void) | null) => void; requireAuth: (action: () => void) => void; openSettingsModal: (section?: PlatformSettingsSection) => void; openAccountModal: () => void; setCurrentUser: (user: AuthUser) => 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: () => {}, setCurrentUser: () => {}, 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(getRpgEntryWorldGalleryDetail).mockImplementation( async (ownerUserId, profileId) => buildMockRpgGalleryDetail({ ownerUserId, profileId, publicWorkCode: null, authorPublicUserCode: ownerUserId, visibility: 'published', publishedAt: '2026-04-16T12:00:00.000Z', updatedAt: '2026-04-16T12:00:00.000Z', authorDisplayName: '测试作者', worldName: '潮雾列岛', subtitle: '旧灯塔与失控航路', summaryText: '最近公开发布的世界。', coverImageSrc: null, themeMode: 'tide', playableNpcCount: 0, landmarkCount: 0, likeCount: 0, }), ); vi.mocked(getRpgEntryWorldGalleryDetailFromClient).mockImplementation( async (ownerUserId, profileId) => buildMockRpgGalleryDetail({ ownerUserId, profileId, publicWorkCode: null, authorPublicUserCode: ownerUserId, visibility: 'published', publishedAt: '2026-04-16T12:00:00.000Z', updatedAt: '2026-04-16T12:00:00.000Z', authorDisplayName: '测试作者', worldName: '潮雾列岛', subtitle: '旧灯塔与失控航路', summaryText: '最近公开发布的世界。', coverImageSrc: null, themeMode: 'tide', playableNpcCount: 0, landmarkCount: 0, likeCount: 0, }), ); vi.mocked( rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail, ).mockImplementation(async (profileId: string) => buildMockRpgGalleryDetail({ ownerUserId: mockAuthUser.id, profileId, publicWorkCode: `work-${profileId}`, authorPublicUserCode: mockAuthUser.publicUserCode, visibility: 'published', publishedAt: '2026-04-16T12:00:00.000Z', updatedAt: '2026-04-16T12:00:00.000Z', authorDisplayName: mockAuthUser.displayName, worldName: '潮雾列岛', subtitle: '旧灯塔与失控航路', summaryText: '最近公开发布的世界。', coverImageSrc: null, themeMode: 'tide', playableNpcCount: 0, landmarkCount: 0, likeCount: 0, }), ); 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(recordBigFishPlay).mockResolvedValue(undefined); vi.mocked(recordRpgEntryWorldGalleryPlay).mockImplementation( async (ownerUserId, profileId) => ({ ownerUserId, profileId, publicWorkCode: null, authorPublicUserCode: ownerUserId, profile: { id: profileId, name: '潮雾列岛', subtitle: '旧灯塔与失控航路', summary: '最近公开发布的世界。', tone: '压抑、潮湿、悬疑', playerGoal: '查清旧案。', majorFactions: ['守灯会'], coreConflicts: ['雾潮正在逼近港口'], playableNpcs: [], storyNpcs: [], landmarks: [], } as never, visibility: 'published', publishedAt: '2026-04-16T12:00:00.000Z', updatedAt: '2026-04-16T12:00:00.000Z', authorDisplayName: '测试作者', worldName: '潮雾列岛', subtitle: '旧灯塔与失控航路', summaryText: '最近公开发布的世界。', coverImageSrc: null, themeMode: 'tide', playableNpcCount: 0, landmarkCount: 0, likeCount: 0, }), ); vi.mocked(remixRpgEntryWorldGallery).mockRejectedValue( new Error('未启用 remix'), ); 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, likeCount: 0, }, 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(recordBigFishPlay).mockResolvedValue(undefined); vi.mocked(match3dCreationClient.createSession).mockResolvedValue({ session: null, }); vi.mocked(match3dCreationClient.getSession).mockResolvedValue({ session: null, }); vi.mocked(match3dCreationClient.streamMessage).mockResolvedValue(null); vi.mocked(match3dCreationClient.executeAction).mockResolvedValue({ session: null, }); vi.mocked(listMatch3DWorks).mockResolvedValue({ items: [], }); vi.mocked(listMatch3DGallery).mockResolvedValue({ items: [], }); vi.mocked(getMatch3DWorkDetail).mockRejectedValue( new Error('未找到抓大鹅作品'), ); vi.mocked(deleteMatch3DWork).mockResolvedValue({ items: [], }); vi.mocked(startMatch3DRun).mockRejectedValue( new Error('未启动抓大鹅运行态'), ); vi.mocked(clickMatch3DItem).mockRejectedValue( new Error('未执行抓大鹅点击'), ); vi.mocked(restartMatch3DRun).mockRejectedValue( new Error('未重新开始抓大鹅运行态'), ); vi.mocked(finishMatch3DTimeUp).mockResolvedValue({ run: buildMockMatch3DRun('match3d-profile-time-up'), }); vi.mocked(stopMatch3DRun).mockResolvedValue({ run: buildMockMatch3DRun('match3d-profile-stopped'), }); 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(remixPuzzleGalleryWork).mockRejectedValue( new Error('未启用拼图 remix'), ); vi.mocked(advancePuzzleNextLevel).mockImplementation(async (runId) => ({ run: buildMockPuzzleRun(`${runId}-next-profile`, '后端推荐下一关'), })); vi.mocked(getPuzzleRun).mockImplementation(async (runId) => ({ run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'), })); vi.mocked(updatePuzzleRunPause).mockImplementation(async (runId) => ({ run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'), })); vi.mocked(usePuzzleRuntimeProp).mockImplementation(async (runId) => ({ run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'), })); vi.mocked(submitPuzzleLeaderboard).mockImplementation( async (runId, payload) => ({ run: { ...buildMockPuzzleRun(payload.profileId, '服务端排行榜快照'), runId, entryProfileId: payload.profileId, leaderboardEntries: [ { rank: 1, nickname: payload.nickname, elapsedMs: payload.elapsedMs, isCurrentPlayer: true, }, ], currentLevel: { ...buildMockPuzzleRun(payload.profileId, '服务端排行榜快照') .currentLevel!, runId, profileId: payload.profileId, gridSize: payload.gridSize, leaderboardEntries: [ { rank: 1, nickname: payload.nickname, elapsedMs: payload.elapsedMs, isCurrentPlayer: true, }, ], }, }, }), ); vi.mocked(dragLocalPuzzlePiece).mockImplementation((run) => run); vi.mocked(swapLocalPuzzlePieces).mockImplementation((run) => run); 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 opens RPG while keeping AIRP and visual novel locked', 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); const rpgButton = screen.getByRole('button', { name: /角色扮演/u }); expect((rpgButton as HTMLButtonElement).disabled).toBe(false); await user.click(rpgButton); expect(createRpgCreationSession).toHaveBeenCalledTimes(1); expect( await screen.findByText('Agent工作区:custom-world-agent-session-1'), ).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: /角色扮演/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(); 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(getRpgCreationResultView).mockResolvedValue({ ...buildResultViewForSession(mockSession), targetStage: 'agent-workspace', resultViewSource: null, }); render(); await openExistingRpgDraft(user, /继续创作/u); 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); expect(createRpgCreationSession).not.toHaveBeenCalled(); }); 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 opens public detail without starting RPG', 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, likeCount: 0, }, ]); render( {}, requireAuth, })} />, ); const workCards = await screen.findAllByRole('button', { name: /潮雾列岛/u, }); await user.click(workCards[0]!); expect(await screen.findByText('详情')).toBeTruthy(); expect(requireAuth).not.toHaveBeenCalled(); await user.click(screen.getByRole('button', { name: '启动' })); expect(requireAuth).toHaveBeenCalledTimes(1); expect(recordRpgEntryWorldGalleryPlay).not.toHaveBeenCalled(); }); test('logged out public detail gates puzzle start and remix before real actions', async () => { const user = userEvent.setup(); const requireAuth = vi.fn(); 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, remixCount: 0, likeCount: 0, publishReady: true, } satisfies PuzzleWorkSummary; vi.mocked(listPuzzleGallery).mockResolvedValue({ items: [publishedPuzzleWork], }); vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({ item: publishedPuzzleWork, }); render( {}, requireAuth, })} />, ); await waitFor(() => { expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0); }); const workCards = screen.getAllByRole('button', { name: /星桥机关/u }); await user.click(workCards[0]!); expect(await screen.findByText('详情')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '启动' })); expect(requireAuth).toHaveBeenCalledTimes(1); expect(startPuzzleRun).not.toHaveBeenCalled(); await user.click(screen.getByRole('button', { name: '作品改造' })); expect(requireAuth).toHaveBeenCalledTimes(2); expect(remixPuzzleGalleryWork).not.toHaveBeenCalled(); }); test('logged out public detail gates big fish start before local runtime', async () => { const user = userEvent.setup(); const requireAuth = vi.fn(); const bigFishWork: BigFishWorkSummary = { workId: 'big-fish-work-public-1', sourceSessionId: 'big-fish-session-public-1', ownerUserId: 'user-2', authorDisplayName: '大鱼作者', 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( {}, requireAuth, })} />, ); const searchInput = await screen.findByPlaceholderText( '搜索作品号、名称、作者、描述', ); await user.type(searchInput, 'BF-NPUBLIC1'); await user.click(screen.getByRole('button', { name: '搜索' })); expect(await screen.findByText('详情')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '启动' })); expect(requireAuth).toHaveBeenCalledTimes(1); expect(startLocalBigFishRuntimeRun).not.toHaveBeenCalled(); expect(recordBigFishPlay).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', authorDisplayName: '测试玩家', 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, likeCount: 0, publishReady: false, }, ], }); const { rerender } = render(); await openCreationHub(user); const createPanel = getPlatformTabPanel('create'); await waitFor(() => { expect(listRpgCreationWorks).toHaveBeenCalled(); }); expect(await within(createPanel).findByText('拼图退出缓存作品')).toBeTruthy(); expect(within(createPanel).queryByText('RPG 退出缓存作品')).toBeNull(); expect(within(createPanel).queryByText('大鱼退出缓存作品')).toBeNull(); 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 mobile game category channel', 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, likeCount: 0, publishReady: true, } satisfies PuzzleWorkSummary; vi.mocked(listPuzzleGallery).mockResolvedValue({ items: [publishedPuzzleWork], }); vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({ item: publishedPuzzleWork, }); vi.mocked(startPuzzleRun).mockResolvedValue({ run: buildMockPuzzleRun( publishedPuzzleWork.profileId, publishedPuzzleWork.levelName, ), }); render(); await waitFor(() => { expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0); }); await user.click(screen.getByRole('button', { name: '游戏分类' })); const homePanel = getPlatformTabPanel('home'); expect(within(homePanel).getAllByText('星桥机关').length).toBeGreaterThan(0); expect( within(homePanel).getAllByRole('button', { name: /机关/u }).length, ).toBeGreaterThan(0); expect(screen.queryByRole('button', { name: 'PC游戏' })).toBeNull(); expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull(); }); test('published big fish works stay hidden from platform home and game category channel', async () => { const user = userEvent.setup(); const publishedBigFishWork: BigFishWorkSummary = { workId: 'big-fish-work-public-1', sourceSessionId: 'big-fish-session-public-1', ownerUserId: 'user-2', authorDisplayName: '大鱼作者', 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 homePanel = getPlatformTabPanel('home'); expect(within(homePanel).queryByText('机械深海 大鱼吃小鱼')).toBeNull(); expect( within(homePanel).queryAllByRole('button', { name: /大鱼/u }).length, ).toBe(0); }); test('published puzzle detail returns to the ranking 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, likeCount: 0, publishReady: true, } satisfies PuzzleWorkSummary; vi.mocked(listPuzzleGallery).mockResolvedValue({ items: [publishedPuzzleWork], }); vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({ item: publishedPuzzleWork, }); vi.mocked(startPuzzleRun).mockResolvedValue({ run: buildMockPuzzleRun( publishedPuzzleWork.profileId, publishedPuzzleWork.levelName, ), }); render(); await user.click(await screen.findByRole('button', { name: '排行' })); await waitFor(() => { expect(document.getElementById('platform-tab-panel-category')).toBeTruthy(); }); await waitFor(() => { const rankingPanel = getPlatformTabPanel('category'); expect( within(rankingPanel).getAllByText('星桥机关').length, ).toBeGreaterThan(0); }); const rankingPanel = getPlatformTabPanel('category'); await user.click( within(rankingPanel).getByRole('button', { name: /星桥机关/u, }), ); await user.click(await screen.findByRole('button', { name: '启动' })); expect(await screen.findByTestId('puzzle-board')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回上一页' })); await waitFor(() => { expect(screen.getByRole('button', { name: '启动' })).toBeTruthy(); }); await user.click(screen.getByRole('button', { name: '返回' })); await waitFor(() => { const returnedRankingPanel = getPlatformTabPanel('category'); expect(returnedRankingPanel.getAttribute('aria-hidden')).toBe('false'); expect( within(returnedRankingPanel).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 openCreationHub(user); const rpgButton = await screen.findByRole('button', { name: /角色扮演/u }); expect((rpgButton as HTMLButtonElement).disabled).toBe(false); await user.click(rpgButton); 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); const rpgButton = screen.getByRole('button', { name: /角色扮演/u }); expect((rpgButton as HTMLButtonElement).disabled).toBe(false); await user.click(rpgButton); expect(listPuzzleWorks).toHaveBeenCalled(); expect(createRpgCreationSession).toHaveBeenCalledTimes(1); expect( await within(getPlatformTabPanel('create')).findByText( '当前登录状态已失效,请重新登录后继续。', ), ).toBeTruthy(); 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( screen.getAllByText( '开启拼图创作工作台超时,请确认运行时后端已启动后重试。', ).length, ).toBeGreaterThan(0); }); expect(button as HTMLButtonElement).toHaveProperty('disabled', 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, likeCount: 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, likeCount: 0, 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('formal puzzle next level uses backend run and leaderboard keeps frontend level snapshot', async () => { const user = userEvent.setup(); const firstLevelLeaderboardEntries = [ { rank: 1, nickname: '测试玩家', elapsedMs: 12_000, isCurrentPlayer: true, }, ]; const firstLevel = buildClearedPuzzleRun({ runId: 'run-puzzle-profile-public-1', entryProfileId: 'puzzle-profile-public-1', profileId: 'puzzle-profile-public-1', levelName: '雨夜猫塔', levelIndex: 1, elapsedMs: 12_000, recommendedNextProfileId: 'puzzle-profile-public-2', leaderboardEntries: firstLevelLeaderboardEntries, }); const secondLevelBase = buildMockPuzzleRun( 'puzzle-profile-public-2', '星桥机关', ); const secondLevel: PuzzleRunSnapshot = { ...secondLevelBase, runId: firstLevel.runId, entryProfileId: firstLevel.entryProfileId, currentLevelIndex: 2, playedProfileIds: [ 'puzzle-profile-public-1', 'puzzle-profile-public-2', ], currentLevel: { ...secondLevelBase.currentLevel!, runId: firstLevel.runId, levelIndex: 2, startedAtMs: Date.now(), }, }; const clearedSecondLevel = buildClearedPuzzleRun({ runId: firstLevel.runId, entryProfileId: firstLevel.entryProfileId, profileId: 'puzzle-profile-public-2', levelName: '星桥机关', levelIndex: 2, elapsedMs: 18_000, }); const serviceLeaderboardRun = buildClearedPuzzleRun({ runId: firstLevel.runId, entryProfileId: firstLevel.entryProfileId, profileId: 'puzzle-profile-public-1', levelName: '雨夜猫塔', levelIndex: 1, elapsedMs: 18_000, recommendedNextProfileId: 'puzzle-profile-public-2', }); const leaderboardEntries = [ { rank: 1, nickname: '测试玩家', elapsedMs: 18_000, isCurrentPlayer: true, }, ]; vi.mocked(startPuzzleRun).mockResolvedValue({ run: firstLevel }); vi.mocked(advancePuzzleNextLevel).mockResolvedValue({ run: secondLevel }); vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({ run: { ...serviceLeaderboardRun, leaderboardEntries, currentLevel: { ...serviceLeaderboardRun.currentLevel!, leaderboardEntries, }, }, }); vi.mocked(dragLocalPuzzlePiece).mockReturnValue(clearedSecondLevel); vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedSecondLevel); 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, likeCount: 0, publishReady: true, }; vi.mocked(listPuzzleGallery).mockResolvedValue({ items: [puzzleWork], }); vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({ item: puzzleWork, }); render(); const searchInput = await screen.findByPlaceholderText( '搜索作品号、名称、作者、描述', ); await user.type(searchInput, 'PZ-EPUBLIC1'); await user.click(screen.getByRole('button', { name: '搜索' })); await user.click(await screen.findByRole('button', { name: '启动' })); await waitFor(() => { expect(startPuzzleRun).toHaveBeenCalledWith({ levelId: null, profileId: 'puzzle-profile-public-1', }); }); await user.click( await screen.findByRole('button', { name: '下一关' }, { timeout: 3000 }), ); await waitFor(() => { expect(advancePuzzleNextLevel).toHaveBeenCalledWith(firstLevel.runId); }); expect(advanceLocalPuzzleNextLevel).not.toHaveBeenCalled(); expect((await screen.findAllByText('星桥机关')).length).toBeGreaterThan(0); await user.click(document.querySelector('[data-piece-id="piece-0"]')!); await user.click(document.querySelector('[data-piece-id="piece-1"]')!); await waitFor(() => { expect(submitPuzzleLeaderboard).toHaveBeenCalledWith(firstLevel.runId, { profileId: 'puzzle-profile-public-2', gridSize: 3, elapsedMs: 18_000, nickname: '测试玩家', }); }); expect( await screen.findByRole('dialog', { name: '通关完成' }, { timeout: 3000 }), ).toBeTruthy(); expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0); expect(screen.getByText('测试玩家')).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, likeCount: 0, publishReady: true, }; vi.mocked(listPuzzleGallery).mockResolvedValue({ items: [puzzleWork], }); vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({ item: puzzleWork, }); render(); const searchInput = await screen.findByPlaceholderText( '搜索作品号、名称、作者、描述', ); 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('详情')).toBeTruthy(); expect(screen.getByText('雨夜猫塔')).toBeTruthy(); expect(screen.getByRole('button', { name: '启动' })).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', authorDisplayName: '大鱼作者', 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( '搜索作品号、名称、作者、描述', ); await user.type(searchInput, 'BF-NPUBLIC1'); await user.click(screen.getByRole('button', { name: '搜索' })); expect(await screen.findByText('详情')).toBeTruthy(); 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('public code search opens a published Match3D work by M3 code and starts runtime', async () => { const user = userEvent.setup(); const match3dWork: Match3DWorkSummary = { workId: 'match3d-work-public-1', profileId: 'match3d-profile-public-1', ownerUserId: 'user-2', sourceSessionId: 'match3d-session-public-1', gameName: '水果抓大鹅', themeText: '水果消除', summary: '把圆形空间里的水果全部消除。', tags: ['水果', '消除'], coverImageSrc: null, referenceImageSrc: null, clearCount: 4, difficulty: 5, publicationStatus: 'published', playCount: 3, updatedAt: '2026-04-25T10:30:00.000Z', publishedAt: '2026-04-25T10:30:00.000Z', publishReady: true, }; vi.mocked(listMatch3DGallery).mockResolvedValue({ items: [match3dWork], }); vi.mocked(startMatch3DRun).mockResolvedValue({ run: buildMockMatch3DRun(match3dWork.profileId), }); render(); const searchInput = await screen.findByPlaceholderText( '搜索作品号、名称、作者、描述', ); await user.type(searchInput, 'M3-EPUBLIC1'); await user.click(screen.getByRole('button', { name: '搜索' })); expect(await screen.findByText('详情')).toBeTruthy(); expect(screen.getByText('水果抓大鹅')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '启动' })); await waitFor(() => { expect(startMatch3DRun).toHaveBeenCalledWith('match3d-profile-public-1'); }); expect( await screen.findByText('抓大鹅运行态:match3d-run-match3d-profile-public-1'), ).toBeTruthy(); expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled(); }); test('starting draft generation leaves the agent workspace and shows the generation progress view', 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(getRpgCreationResultView).mockResolvedValue({ ...buildResultViewForSession(mockSession), targetStage: 'agent-workspace', resultViewSource: null, }); render(); await openExistingRpgDraft(user, /继续创作/u); 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), ); 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, }, ]); render(); await openExistingRpgDraft(user, /继续完善/u); 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), ); 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, }, ]); render(); await openExistingRpgDraft(user, /继续完善/u); 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(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-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 openExistingRpgDraft(user, /继续完善/u); const actionButton = await screen.findByRole( 'button', { name: '发布', }, { timeout: 5000 }, ); await user.click(actionButton); await user.click(await screen.findByRole('button', { name: '发布到广场' })); await waitFor(() => { expect( vi .mocked(executeRpgCreationAction) .mock.calls.some( ([sessionId, payload]) => sessionId === 'custom-world-agent-session-1' && payload?.action === 'publish_world', ), ).toBe(true); }); 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: true, blockers: [ { id: 'missing-cover-image', code: 'MISSING_COVER_IMAGE', message: '发布前需要补齐作品封面。', }, ], }, } satisfies CustomWorldAgentSessionSnapshot; vi.mocked(getRpgCreationSession).mockResolvedValue(testDraftSession); vi.mocked(getRpgCreationResultView).mockResolvedValue( buildResultViewForSession(testDraftSession), ); mockExistingRpgDraftShelf({ subtitle: '待发布草稿', summary: '当前草稿已经准备测试。', stage: 'ready_to_publish', stageLabel: '待发布草稿', landmarkCount: 1, roleAssetSummaryLabel: null, }); function TestDraftWrapper() { const [selectionStage, setSelectionStage] = useState('platform'); return ( {}} handleStartNewGame={() => {}} handleCustomWorldSelect={handleCustomWorldSelect} /> ); } render(); await openExistingRpgDraft(user, /继续完善/u); await screen.findByText('世界档案', {}, { timeout: 5000 }); await user.click( await screen.findByRole( 'button', { name: '作品测试' }, { timeout: 5000 }, ), ); 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); }, 10_000); 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), ); mockExistingRpgDraftShelf({ summary: '同步后的结果页快照已经回写到 session。', }); render(); await openExistingRpgDraft(user, /继续完善/u); 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('角色扮演')).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), ); mockExistingRpgDraftShelf({ summary: '作品库应该保存这份同步后的最新快照。', }); 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 openExistingRpgDraft(user, /继续完善/u); 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), ); mockExistingRpgDraftShelf({ summary: '即使 draft 中没有 legacyResultProfile,也应该正常打开结果页。', }); render(); await openExistingRpgDraft(user, /继续完善/u); 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(); expect(screen.queryByText('ARCHIVE')).toBeNull(); expect(screen.queryByText('最近存档')).toBeNull(); }); test('puzzle save archive highlights work title and level subtitle', async () => { vi.mocked(listProfileSaveArchives).mockResolvedValue([ { worldKey: 'puzzle:puzzle-profile-1', ownerUserId: 'user-2', profileId: 'puzzle-profile-1', worldType: 'PUZZLE', worldName: '雨夜猫塔', subtitle: '第 2 关 · 星桥机关', summaryText: '拼图进行中', coverImageSrc: '/generated-puzzle-assets/puzzle-1/level-2.png', lastPlayedAt: '2026-04-19T12:00:00.000Z', }, ]); render(); expect((await screen.findAllByText('雨夜猫塔')).length).toBeGreaterThan(0); expect(screen.getAllByText('第 2 关 · 星桥机关').length).toBeGreaterThan(0); expect(screen.queryByText('ARCHIVE')).toBeNull(); expect(screen.queryByText('最近存档')).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('角色扮演')).toBeTruthy(); resolveGalleryRequest([]); await waitFor(() => { expect( within(getPlatformTabPanel('create')).getByText('角色扮演'), ).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(); 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, likeCount: 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 })); expect(await screen.findByText('详情')).toBeTruthy(); expect(screen.getByText('潮雾列岛')).toBeTruthy(); expect(screen.getByRole('button', { name: '启动' })).toBeTruthy(); expect(screen.queryByRole('button', { name: '删除作品' })).toBeNull(); expect(deleteRpgEntryWorldProfile).not.toHaveBeenCalled(); }); 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, likeCount: 0, }, ]); 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, likeCount: 0, }, ]); render(); await openCreationHub(user); await user.click(await screen.findByRole('button', { name: /查看详情/u })); await user.click(await screen.findByRole('button', { name: '启动' })); await waitFor(() => { expect(handleCustomWorldSelect).toHaveBeenCalledWith( expect.objectContaining({ id: 'world-experience-1', name: '潮雾列岛', }), ); }); expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1); }); test('creation hub published work card no longer exposes direct delete action', async () => { const user = userEvent.setup(); 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, likeCount: 0, }; vi.mocked(listRpgCreationWorks) .mockResolvedValueOnce([publishedWork]) .mockResolvedValue([]); vi.mocked(listRpgEntryWorldLibrary) .mockResolvedValueOnce([publishedLibraryEntry]) .mockResolvedValue([]); vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]); render(); await openCreationHub(user); expect(await screen.findByRole('button', { name: /查看详情/u })).toBeTruthy(); expect(screen.queryByRole('button', { name: '删除' })).toBeNull(); expect(deleteRpgEntryWorldProfile).not.toHaveBeenCalled(); });