/* @vitest-environment jsdom */ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent'; import type { CustomWorldAgentSessionSnapshot, CustomWorldWorkSummary, } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { Match3DAgentSessionSnapshot, } from '../../../packages/shared/src/contracts/match3dAgent'; import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleAnchorPack, PuzzleResultDraft, } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; import type { PuzzleAgentSessionSnapshot, } from '../../../packages/shared/src/contracts/puzzleAgentSession'; 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, startBigFishRun, submitBigFishInput, } from '../../services/big-fish-runtime'; import { listBigFishWorks } from '../../services/big-fish-works'; import { cancelCreativeAgentSession, confirmCreativePuzzleTemplate, createCreativeAgentSession, streamCreativeAgentMessage, streamCreativeDraftEdit, } from '../../services/creative-agent'; 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, executePuzzleAgentAction, getPuzzleAgentSession, } from '../../services/puzzle-agent'; import { getPuzzleGalleryDetail, listPuzzleGallery, remixPuzzleGalleryWork, } from '../../services/puzzle-gallery'; import { generatePuzzleOnboardingWork, savePuzzleOnboardingWork, } from '../../services/puzzle-onboarding'; import { advancePuzzleNextLevel, dragPuzzlePieceOrGroup, getPuzzleRun, startPuzzleRun, submitPuzzleLeaderboard, swapPuzzlePieces, 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 { squareHoleCreationClient } from '../../services/square-hole-creation'; import { dropSquareHoleShape, finishSquareHoleTimeUp, restartSquareHoleRun, startSquareHoleRun, stopSquareHoleRun, } from '../../services/square-hole-runtime'; import { deleteSquareHoleWork, getSquareHoleWorkDetail, listSquareHoleGallery, listSquareHoleWorks, } from '../../services/square-hole-works'; 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 openCreateTemplateHub(user: ReturnType) { await clickFirstButtonByName(user, '创作'); expect( await screen.findByRole('tablist', { name: '选择模板' }), ).toBeTruthy(); expect(screen.getByRole('tab', { name: '拼图' })).toBeTruthy(); expect(screen.getByText('拼图工作区:missing-session')).toBeTruthy(); } async function openDraftHub(user: ReturnType) { await clickFirstButtonByName(user, '草稿'); const panel = getPlatformTabPanel('saves'); await waitFor(() => { expect(panel.getAttribute('aria-hidden')).toBe('false'); }); expect( await within(panel).findByRole('button', { name: /全部/u }), ).toBeTruthy(); } async function openDiscoverHub(user: ReturnType) { await clickFirstButtonByName(user, '发现'); const panel = getPlatformTabPanel('category'); await waitFor(() => { expect(panel.getAttribute('aria-hidden')).toBe('false'); }); expect( await within(panel).findByPlaceholderText( '搜索作品号、名称、作者、描述', ), ).toBeTruthy(); return panel; } async function openProfilePlayedWorks(user: ReturnType) { await clickFirstButtonByName(user, '我的'); await user.click(await screen.findByRole('button', { name: /玩过/u })); expect(await screen.findByText('可继续')).toBeTruthy(); } async function openExistingRpgDraft( user: ReturnType, actionName: string | RegExp = /继续(?:完善|创作)/u, ) { await openDraftHub(user); await user.click(await screen.findByRole('button', { name: actionName })); } const ISOLATED_RUNTIME_AUTH_OPTIONS = { authImpact: 'local', skipRefresh: true, notifyAuthStateChange: false, clearAuthOnUnauthorized: false, }; 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', () => ({ advancePuzzleNextLevel: vi.fn(), dragPuzzlePieceOrGroup: 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', () => ({ recordBigFishPlay: vi.fn().mockResolvedValue({ items: [] }), startBigFishRun: vi.fn(), submitBigFishInput: 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/square-hole-creation', () => ({ squareHoleCreationClient: { createSession: vi.fn(), executeAction: vi.fn(), getSession: vi.fn(), sendMessage: vi.fn(), streamMessage: vi.fn(), }, })); vi.mock('../../services/square-hole-runtime', () => ({ dropSquareHoleShape: vi.fn(), finishSquareHoleTimeUp: vi.fn(), getSquareHoleRun: vi.fn(), restartSquareHoleRun: vi.fn(), startSquareHoleRun: vi.fn(), stopSquareHoleRun: vi.fn(), })); vi.mock('../../services/square-hole-works', () => ({ deleteSquareHoleWork: vi.fn(), getSquareHoleWorkDetail: vi.fn(), listSquareHoleGallery: vi.fn(), listSquareHoleWorks: vi.fn(), })); vi.mock('../../services/creative-agent', () => ({ cancelCreativeAgentSession: vi.fn(), confirmCreativePuzzleTemplate: vi.fn(), createCreativeAgentSession: vi.fn(), streamCreativeAgentMessage: vi.fn(), streamCreativeDraftEdit: 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-onboarding', () => ({ generatePuzzleOnboardingWork: vi.fn(), savePuzzleOnboardingWork: vi.fn(), })); vi.mock('../../services/puzzle-agent', () => ({ createPuzzleAgentSession: vi.fn(), executePuzzleAgentAction: vi.fn(), getPuzzleAgentSession: vi.fn(), streamPuzzleAgentMessage: vi.fn(), })); vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({ PuzzleAgentWorkspace: ({ session, 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: ({ isBusy, onExecuteAction, session, onBack, }: { isBusy?: boolean; onExecuteAction: (payload: { action: string; levelId?: string; promptText?: string; }) => void; 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-creation/Match3DAgentWorkspace', () => ({ Match3DAgentWorkspace: ({ session, onCreateFromForm, }: { session: { sessionId: string; messages: Array<{ text: string }> } | null; onCreateFromForm?: (payload: { seedText: string; themeText: string; referenceImageSrc: string | null; clearCount: number; difficulty: number; }) => void; }) => (
抓大鹅工作区:{session?.sessionId ?? 'missing-session'}
{session?.messages.map((message) => (
{message.text}
))}
), })); 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 buildMockCreativeAgentSession( overrides: Partial = {}, ): CreativeAgentSessionSnapshot { const sessionId = overrides.sessionId ?? 'creative-agent-session-1'; return { sessionId, stage: 'waiting_user', inputSummary: { text: null, entryContext: 'creation_home', images: [], materialSummary: null, unsupportedCapabilities: [], }, messages: [ { id: 'creative-agent-message-1', role: 'assistant', kind: 'chat', text: '说一个灵感,我来帮你做成互动内容。', createdAt: '2026-05-05T10:00:00.000Z', }, ], puzzleTemplateCatalog: [], puzzleTemplateSelection: null, puzzleImageGenerationPlan: null, targetBinding: null, updatedAt: '2026-05-05T10:00:00.000Z', ...overrides, }; } function buildMockSquareHoleAgentSession( overrides: Partial[0]> = {}, ) { return buildMockSquareHoleAgentSessionImpl(overrides); } function buildMockSquareHoleAgentSessionImpl( overrides: Partial<{ sessionId: string; stage: string; messages: Array<{ id: string; role: string; kind: string; text: string; createdAt: string }>; updatedAt: string; }> = {}, ) { const sessionId = overrides.sessionId ?? 'square-hole-session-1'; return { sessionId, currentTurn: 0, progressPercent: 20, stage: 'collecting_config', anchorPack: { theme: { key: 'theme', label: '题材主题', value: '霓虹形状', status: 'confirmed', }, twistRule: { key: 'twistRule', label: '反直觉规则', value: '颜色会误导洞口', status: 'confirmed', }, shapeCount: { key: 'shapeCount', label: '形状数量', value: '12', status: 'confirmed', }, difficulty: { key: 'difficulty', label: '难度', value: '5', status: 'confirmed', }, }, config: { themeText: '霓虹形状', twistRule: '颜色会误导洞口', shapeCount: 12, difficulty: 5, shapeOptions: [ { optionId: 'shape-square', shapeKind: 'square', label: '方块', targetHoleId: 'hole-square', imagePrompt: '霓虹方块', imageSrc: null, }, ], holeOptions: [ { holeId: 'hole-square', holeKind: 'square', label: '方洞', imagePrompt: '发光方洞', imageSrc: null, }, ], backgroundPrompt: '霓虹街机背景', coverImageSrc: null, backgroundImageSrc: null, }, draft: null, messages: [ { id: 'square-hole-message-1', role: 'assistant', kind: 'chat', text: '先确定方洞挑战的题材和反直觉规则。', createdAt: '2026-05-01T10:00:00.000Z', }, ], lastAssistantReply: '先确定方洞挑战的题材和反直觉规则。', publishedProfileId: null, updatedAt: '2026-05-01T10:00:00.000Z', ...overrides, }; } function buildMockSquareHoleRun(profileId: string) { return { runId: `square-hole-run-${profileId}`, profileId, ownerUserId: 'user-2', status: 'running', snapshotVersion: 1, startedAtMs: 1_000, durationLimitMs: 600_000, remainingMs: 600_000, totalShapeCount: 12, completedShapeCount: 0, combo: 0, bestCombo: 0, score: 0, ruleLabel: '颜色会误导洞口', currentShape: { shapeId: 'shape-1', shapeKind: 'square', label: '方块', targetHoleId: 'hole-square', color: '#ff5f7e', imageSrc: null, }, holes: [ { holeId: 'hole-square', holeKind: 'square', label: '方洞', x: 0.2, y: 0.5, }, ], lastFeedback: null, }; } 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 buildPuzzleAnchorPack(): PuzzleAnchorPack { return { themePromise: { key: 'themePromise', label: '题材承诺', value: '雨夜拼图', status: 'confirmed', }, visualSubject: { key: 'visualSubject', label: '画面主体', value: '雨夜猫塔', status: 'confirmed', }, visualMood: { key: 'visualMood', label: '视觉气质', value: '暖灯', status: 'confirmed', }, compositionHooks: { key: 'compositionHooks', label: '拼图记忆点', value: '灯塔与猫', status: 'confirmed', }, tagsAndForbidden: { key: 'tagsAndForbidden', label: '标签与禁忌', value: '雨夜、猫咪、塔', status: 'confirmed', }, }; } 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 buildMockMatch3DAgentSession( overrides: Partial = {}, ): Match3DAgentSessionSnapshot { const sessionId = overrides.sessionId ?? 'match3d-agent-session-1'; return { sessionId, currentTurn: 0, progressPercent: 20, stage: 'collecting', anchorPack: { theme: { key: 'theme', label: '题材主题', value: '水果消除', status: 'confirmed', }, clearCount: { key: 'clearCount', label: '需要消除次数', value: '4', status: 'confirmed', }, difficulty: { key: 'difficulty', label: '难度', value: '5', status: 'confirmed', }, }, config: { themeText: '水果消除', referenceImageSrc: null, clearCount: 4, difficulty: 5, }, draft: null, messages: [ { id: 'match3d-message-1', role: 'assistant', kind: 'chat', text: '我们先确定抓大鹅题材、消除次数和难度。', createdAt: '2026-05-01T10:00:00.000Z', }, ], lastAssistantReply: '我们先确定抓大鹅题材、消除次数和难度。', publishedProfileId: null, updatedAt: '2026-05-01T10:00:00.000Z', ...overrides, }; } 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(); window.localStorage.setItem( 'genarrative.puzzle-onboarding.first-visit.v1', '1', ); 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({ items: [] }); 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(startBigFishRun).mockResolvedValue({ run: { 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(submitBigFishInput).mockImplementation(async (runId, payload) => ({ run: { runId, sessionId: 'big-fish-session-public-1', status: 'running', tick: 1, playerLevel: 1, winLevel: 8, leaderEntityId: 'owned-1', ownedEntities: [ { entityId: 'owned-1', level: 1, position: payload, radius: 12, offscreenSeconds: 0, }, ], wildEntities: [], cameraCenter: payload, lastInput: payload, eventLog: ['机械鱼群继续巡游。'], updatedAt: '2026-04-25T12:12:01.000Z', }, })); vi.mocked(recordBigFishPlay).mockResolvedValue({ items: [] }); vi.mocked(match3dCreationClient.createSession).mockResolvedValue({ session: buildMockMatch3DAgentSession(), }); vi.mocked(match3dCreationClient.getSession).mockResolvedValue({ session: buildMockMatch3DAgentSession(), }); vi.mocked(match3dCreationClient.streamMessage).mockResolvedValue( buildMockMatch3DAgentSession(), ); vi.mocked(match3dCreationClient.executeAction).mockResolvedValue({ session: buildMockMatch3DAgentSession(), }); 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(squareHoleCreationClient.createSession).mockResolvedValue({ session: buildMockSquareHoleAgentSession(), }); vi.mocked(squareHoleCreationClient.getSession).mockResolvedValue({ session: buildMockSquareHoleAgentSession(), }); vi.mocked(squareHoleCreationClient.streamMessage).mockResolvedValue( buildMockSquareHoleAgentSession(), ); vi.mocked(squareHoleCreationClient.executeAction).mockResolvedValue({ session: buildMockSquareHoleAgentSession(), }); vi.mocked(listSquareHoleWorks).mockResolvedValue({ items: [], }); vi.mocked(listSquareHoleGallery).mockResolvedValue({ items: [], }); vi.mocked(getSquareHoleWorkDetail).mockRejectedValue( new Error('未找到方洞挑战作品'), ); vi.mocked(deleteSquareHoleWork).mockResolvedValue({ items: [], }); vi.mocked(startSquareHoleRun).mockResolvedValue({ run: buildMockSquareHoleRun('square-hole-profile-1'), }); vi.mocked(dropSquareHoleShape).mockResolvedValue({ feedback: { accepted: true, rejectReason: null, message: '投入成功', }, run: buildMockSquareHoleRun('square-hole-profile-1'), }); vi.mocked(restartSquareHoleRun).mockResolvedValue({ run: buildMockSquareHoleRun('square-hole-profile-1'), }); vi.mocked(finishSquareHoleTimeUp).mockResolvedValue({ run: buildMockSquareHoleRun('square-hole-profile-1'), }); vi.mocked(stopSquareHoleRun).mockResolvedValue({ run: buildMockSquareHoleRun('square-hole-profile-1'), }); vi.mocked(listPuzzleWorks).mockResolvedValue({ items: [], }); vi.mocked(listPuzzleGallery).mockResolvedValue({ items: [], }); vi.mocked(generatePuzzleOnboardingWork).mockResolvedValue({ item: { workId: 'onboarding-work-1', profileId: 'onboarding-profile-1', ownerUserId: 'onboarding-guest', sourceSessionId: null, authorDisplayName: '百梦主', workTitle: '梦境拼图', workDescription: '我想飞上天', levelName: '云上飞行', summary: '我想飞上天', themeTags: ['新手引导', '拼图'], coverImageSrc: 'data:image/svg+xml;utf8,onboarding', coverAssetId: 'onboarding-asset-1', publicationStatus: 'draft', updatedAt: '2026-05-05T12:00:00.000Z', publishedAt: null, playCount: 0, remixCount: 0, likeCount: 0, publishReady: true, levels: [], }, level: { levelId: 'onboarding-level-1', levelName: '云上飞行', pictureDescription: '我想飞上天', pictureReference: null, candidates: [ { candidateId: 'onboarding-candidate-1', imageSrc: 'data:image/svg+xml;utf8,onboarding', assetId: 'onboarding-asset-1', prompt: '我想飞上天', actualPrompt: '我想飞上天', sourceType: 'generated', selected: true, }, ], selectedCandidateId: 'onboarding-candidate-1', coverImageSrc: 'data:image/svg+xml;utf8,onboarding', coverAssetId: 'onboarding-asset-1', generationStatus: 'ready', }, }); vi.mocked(savePuzzleOnboardingWork).mockResolvedValue({ item: { workId: 'onboarding-work-saved', profileId: 'onboarding-profile-saved', ownerUserId: mockAuthUser.id, sourceSessionId: 'puzzle-session-onboarding', authorDisplayName: mockAuthUser.displayName, workTitle: '梦境拼图', workDescription: '我想飞上天', levelName: '云上飞行', summary: '我想飞上天', themeTags: ['新手引导', '拼图'], coverImageSrc: 'data:image/svg+xml;utf8,onboarding', coverAssetId: 'onboarding-asset-1', publicationStatus: 'draft', updatedAt: '2026-05-05T12:00:00.000Z', publishedAt: null, playCount: 0, remixCount: 0, likeCount: 0, publishReady: true, levels: [], 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', }, }, }, }); vi.mocked(remixPuzzleGalleryWork).mockRejectedValue( new Error('未启用拼图 remix'), ); vi.mocked(startPuzzleRun).mockImplementation(async (payload) => { const run = buildMockPuzzleRun(payload.profileId, '后端拼图关卡'); return { run: { ...run, currentLevel: run.currentLevel ? { ...run.currentLevel, levelId: payload.levelId ?? run.currentLevel.levelId, startedAtMs: Date.now(), } : run.currentLevel, }, }; }); 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); vi.mocked(createCreativeAgentSession).mockResolvedValue({ session: buildMockCreativeAgentSession(), }); vi.mocked(streamCreativeAgentMessage).mockImplementation( async (sessionId, payload) => buildMockCreativeAgentSession({ sessionId, stage: 'collaborating', messages: [ { id: 'creative-agent-message-1', role: 'assistant', kind: 'chat', text: '说一个灵感,我来帮你做成互动内容。', createdAt: '2026-05-05T10:00:00.000Z', }, { id: payload.clientMessageId, role: 'user', kind: 'chat', text: payload.content .map((part) => part.type === 'input_text' ? part.text.trim() : '参考图', ) .filter(Boolean) .join(' / '), createdAt: '2026-05-05T10:01:00.000Z', }, { id: 'creative-agent-message-2', role: 'assistant', kind: 'chat', text: '收到,我先帮你整理成可创作方案。', createdAt: '2026-05-05T10:01:01.000Z', }, ], }), ); vi.mocked(cancelCreativeAgentSession).mockResolvedValue({ session: buildMockCreativeAgentSession({ stage: 'failed' }), }); vi.mocked(confirmCreativePuzzleTemplate).mockResolvedValue({ session: buildMockCreativeAgentSession(), }); vi.mocked(streamCreativeDraftEdit).mockResolvedValue( buildMockCreativeAgentSession(), ); }); afterEach(() => { vi.unstubAllEnvs(); }); test('create tab shows template tabs and embeds puzzle form by default', async () => { const user = userEvent.setup(); render(); await openCreateTemplateHub(user); expect(screen.getByRole('tablist', { name: '选择模板' })).toBeTruthy(); expect( screen.getByRole('tab', { name: '拼图' }).getAttribute('aria-selected'), ).toBe('true'); expect( screen.getByRole('tab', { name: '拼图' }).querySelector('img')?.src, ).toContain('/creation-type-references/puzzle.webp'); expect( screen.getByRole('tab', { name: '方洞挑战' }).querySelector('img')?.src, ).toContain('/creation-type-references/square-hole.webp'); expect( screen.getByRole('tab', { name: '视觉小说' }).querySelector('img')?.src, ).toContain('/creation-type-references/visual-novel.webp'); expect( screen.getByRole('tab', { name: 'AIRP' }).querySelector('img')?.src, ).toContain('/creation-type-references/airp.webp'); expect( screen.getByRole('tab', { name: '抓大鹅' }).querySelector('img')?.src, ).toContain('/creation-type-references/match3d.webp'); expect( screen.getByRole('tab', { name: '拼图' }).querySelector('.text-white'), ).toBeTruthy(); expect( screen.getByRole('tab', { name: '拼图' }).querySelector('.text-inherit'), ).toBeNull(); expect(screen.queryByRole('button', { name: /智能创作/u })).toBeNull(); expect(screen.queryByPlaceholderText('问一问百梦')).toBeNull(); expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull(); expect(screen.getByRole('tab', { name: /抓大鹅/u })).toBeTruthy(); expect(createRpgCreationSession).not.toHaveBeenCalled(); expect(match3dCreationClient.createSession).not.toHaveBeenCalled(); expect(createPuzzleAgentSession).not.toHaveBeenCalled(); }); test('create tab switches match3d into the embedded entry form', async () => { const user = userEvent.setup(); render(); await openCreateTemplateHub(user); await user.click(screen.getByRole('tab', { name: '抓大鹅' })); expect( screen.getByRole('tab', { name: '抓大鹅' }).getAttribute('aria-selected'), ).toBe('true'); expect( await screen.findByText('抓大鹅工作区:missing-session'), ).toBeTruthy(); expect(createPuzzleAgentSession).not.toHaveBeenCalled(); expect(match3dCreationClient.createSession).not.toHaveBeenCalled(); }); test('embedded puzzle form routes through requireAuth while logged out', async () => { const user = userEvent.setup(); const requireAuth = vi.fn(); render( {}, requireAuth, })} />, ); await openCreateTemplateHub(user); const generateButton = await screen.findByRole('button', { name: /生成草稿/u, }); await user.click(generateButton); expect(requireAuth).toHaveBeenCalledTimes(1); expect(createCreativeAgentSession).not.toHaveBeenCalled(); expect(streamCreativeAgentMessage).not.toHaveBeenCalled(); expect(createRpgCreationSession).not.toHaveBeenCalled(); expect(createPuzzleAgentSession).not.toHaveBeenCalled(); expect(match3dCreationClient.createSession).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 openDraftHub(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 openDraftHub(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 openDraftHub(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 draft 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 openDraftHub(user); await user.click(await screen.findByRole('button', { name: /继续完善/u })); const fallbackDraftPanel = getPlatformTabPanel('saves'); await waitFor(() => { expect(fallbackDraftPanel.getAttribute('aria-hidden')).toBe('false'); expect( within(fallbackDraftPanel).getByText( '这份共创草稿已失效,已为你返回草稿列表,请重新开始创作。', ), ).toBeTruthy(); }); expect(window.location.search).toBe(''); expect(listRpgCreationWorks).toHaveBeenCalledTimes(2); expect(within(fallbackDraftPanel).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('owned public puzzle detail edits original draft instead of remixing', async () => { const user = userEvent.setup(); const ownedPuzzleWork = { workId: 'puzzle-work-owned-1', profileId: 'puzzle-profile-owned-1', ownerUserId: mockAuthUser.id, sourceSessionId: 'puzzle-session-1', authorDisplayName: mockAuthUser.displayName, 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: [ownedPuzzleWork], }); vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({ item: ownedPuzzleWork, }); render(); await openDiscoverHub(user); 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(); expect(screen.getByRole('button', { name: '作品编辑' })).toBeTruthy(); expect(screen.queryByRole('button', { name: '作品改造' })).toBeNull(); await user.click(screen.getByRole('button', { name: '作品编辑' })); expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1'); expect(remixPuzzleGalleryWork).not.toHaveBeenCalled(); expect(await screen.findByText('拼图结果页')).toBeTruthy(); const generatingPuzzleDraft: PuzzleResultDraft = { workTitle: '暖灯猫街作品', workDescription: '一套雨夜猫街主题拼图。', 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: [ { candidateId: 'candidate-1', imageSrc: '/puzzle/candidate-1.png', assetId: 'asset-1', prompt: '雨夜猫咪', actualPrompt: null, sourceType: 'generated', selected: true, }, ], selectedCandidateId: 'candidate-1', coverImageSrc: '/puzzle/candidate-1.png', coverAssetId: 'asset-1', generationStatus: 'generating', levels: [ { levelId: 'puzzle-level-1', levelName: '雨夜猫街', pictureDescription: '屋檐下的猫与暖灯街角。', pictureReference: null, candidates: [ { candidateId: 'candidate-1', imageSrc: '/puzzle/candidate-1.png', assetId: 'asset-1', prompt: '雨夜猫咪', actualPrompt: null, sourceType: 'generated', selected: true, }, ], selectedCandidateId: 'candidate-1', coverImageSrc: '/puzzle/candidate-1.png', coverAssetId: 'asset-1', generationStatus: 'generating', }, ], metadata: null, }; vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({ operation: { operationId: 'puzzle-image-generation-1', type: 'generate_puzzle_images', status: 'running', phaseLabel: '生成中', phaseDetail: '正在生成拼图画面', progress: 0.3, }, session: { sessionId: 'puzzle-session-1', currentTurn: 3, progressPercent: 88, stage: 'ready_to_publish', 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: generatingPuzzleDraft, messages: [], lastAssistantReply: '大鱼结果页草稿已经生成,可以补正式资产。', publishedProfileId: null, suggestedActions: [], resultPreview: { draft: generatingPuzzleDraft, publishReady: false, blockers: [], qualityFindings: [], }, updatedAt: '2026-04-26T10:10:00.000Z', }, }); await user.click(screen.getByRole('button', { name: '重新生成画面' })); expect(executePuzzleAgentAction).toHaveBeenCalledWith( 'puzzle-session-1', expect.objectContaining({ action: 'generate_puzzle_images', }), ); expect(screen.getByRole('button', { name: '新增关卡' })).toHaveProperty( 'disabled', false, ); }); 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, })} />, ); await openDiscoverHub(user); 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(startBigFishRun).not.toHaveBeenCalled(); expect(recordBigFishPlay).not.toHaveBeenCalled(); }); test('public code search blocks edutainment work when entry switch is disabled', async () => { vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false'); const user = userEvent.setup(); const edutainmentPuzzleWork: PuzzleWorkSummary = { workId: 'puzzle-work-edutainment-1', profileId: 'puzzle-profile-edutainment-1', ownerUserId: 'user-2', sourceSessionId: 'puzzle-session-edutainment-1', authorDisplayName: '动作 Demo 作者', levelName: '儿童动作热身 Demo', summary: '寓教于乐专属动作 Demo。', themeTags: ['运动', '安全', '拼图', '寓教于乐'], coverImageSrc: null, coverAssetId: null, publicationStatus: 'published', updatedAt: '2026-05-09T10:00:00.000Z', publishedAt: '2026-05-09T10:00:00.000Z', playCount: 3, remixCount: 0, likeCount: 0, publishReady: true, }; vi.mocked(listPuzzleGallery).mockResolvedValue({ items: [edutainmentPuzzleWork], }); vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({ item: edutainmentPuzzleWork, }); render(); await openDiscoverHub(user); const searchInput = await screen.findByPlaceholderText( '搜索作品号、名称、作者、描述', ); await user.type(searchInput, 'PZ-TMENT1'); await user.click(screen.getByRole('button', { name: '搜索' })); expect(await screen.findByText('未找到结果')).toBeTruthy(); expect(screen.queryByText('儿童动作热身 Demo')).toBeNull(); expect(getPuzzleGalleryDetail).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 openDraftHub(user); const draftPanel = getPlatformTabPanel('saves'); await waitFor(() => { expect(listRpgCreationWorks).toHaveBeenCalled(); }); expect(await within(draftPanel).findByText('拼图退出缓存作品')).toBeTruthy(); expect(within(draftPanel).queryByText('RPG 退出缓存作品')).toBeNull(); expect(within(draftPanel).queryByText('大鱼退出缓存作品')).toBeNull(); rerender(); await waitFor(() => { expect(screen.queryByText('RPG 退出缓存作品')).toBeNull(); expect(screen.queryByText('拼图退出缓存作品')).toBeNull(); }); }); 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, }); render(); await waitFor(() => { expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0); }); await clickFirstButtonByName(user, '发现'); await user.click(screen.getByRole('button', { name: '分类' })); const discoverPanel = getPlatformTabPanel('category'); expect( within(discoverPanel).getAllByText('星桥机关').length, ).toBeGreaterThan(0); expect( within(discoverPanel).getAllByRole('button', { name: /机关/u }).length, ).toBeGreaterThan(0); expect(screen.queryByRole('button', { name: 'PC游戏' })).toBeNull(); expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull(); }); test('home recommendation starts embedded puzzle without global auth reset on local failure', async () => { 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, }); render(); await waitFor(() => { expect(startPuzzleRun).toHaveBeenCalledWith( { profileId: 'puzzle-profile-public-1', levelId: null, }, ISOLATED_RUNTIME_AUTH_OPTIONS, ); }); }); test('home recommendation surfaces start failure instead of staying in loading state', async () => { 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).mockRejectedValueOnce( new Error('启动拼图玩法失败'), ); render(); expect( await screen.findByText('作品暂时无法进入,请稍后再试。'), ).toBeTruthy(); expect(screen.queryByText('加载中...')).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 clickFirstButtonByName(user, '发现'); await user.click(screen.getByRole('button', { name: '分类' })); const discoverPanel = getPlatformTabPanel('category'); expect(within(discoverPanel).queryByText('机械深海 大鱼吃小鱼')).toBeNull(); expect( within(discoverPanel).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, }); render(); await clickFirstButtonByName(user, '发现'); 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 user.click( within( await screen.findByRole('dialog', { name: /体验不佳?\s*试试改造功能!/u, }), ).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('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('embedded puzzle form maps raw bearer token errors to user-facing auth copy', async () => { const user = userEvent.setup(); vi.mocked(createPuzzleAgentSession).mockRejectedValueOnce( new ApiClientError({ message: '缺少 Authorization Bearer Token', status: 401, code: 'UNAUTHORIZED', }), ); render(); await openCreateTemplateHub(user); const generateButton = screen.getByRole('button', { name: /生成草稿/u }); expect((generateButton as HTMLButtonElement).disabled).toBe(false); await user.click(generateButton); expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1); expect(createCreativeAgentSession).not.toHaveBeenCalled(); expect( await screen.findByText( '当前登录状态已失效,请重新登录后继续。', ), ).toBeTruthy(); expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull(); }); test('create tab does not render legacy gameplay creation entries', async () => { const user = userEvent.setup(); render(); await openCreateTemplateHub(user); expect(screen.queryByText('选择创作类型')).toBeNull(); expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull(); expect(createBigFishCreationSession).not.toHaveBeenCalled(); }); test('embedded puzzle form 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 openCreateTemplateHub(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(createCreativeAgentSession).not.toHaveBeenCalled(); expect(streamCreativeAgentMessage).not.toHaveBeenCalled(); }); test('match3d creation tab stays usable even when public galleries fail', async () => { const user = userEvent.setup(); vi.mocked(listRpgEntryWorldGallery).mockRejectedValueOnce( new Error('读取作品广场失败'), ); vi.mocked(listMatch3DGallery).mockRejectedValueOnce( new Error('读取抓大鹅广场失败'), ); render(); await openCreateTemplateHub(user); expect(screen.queryByText('读取作品广场失败')).toBeNull(); expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull(); expect(screen.getByRole('tab', { name: '抓大鹅' })).toBeTruthy(); expect(match3dCreationClient.createSession).not.toHaveBeenCalled(); }); test('puzzle draft result back button returns to creation hub', 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 openDraftHub(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.findByRole('tablist', { name: '选择模板' }), ).toBeTruthy(); expect(screen.getByText('雨夜里有一只会发光的猫站在遗迹台阶上。')).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 openDraftHub(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('first launch puzzle onboarding can be skipped from top right', async () => { const user = userEvent.setup(); window.localStorage.removeItem( 'genarrative.puzzle-onboarding.first-visit.v1', ); render( {}, requireAuth: () => {}, })} />, ); expect(await screen.findByText('待定待定待定')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '跳过' })); await waitFor(() => { expect(screen.queryByText('待定待定待定')).toBeNull(); }); expect( window.localStorage.getItem( 'genarrative.puzzle-onboarding.first-visit.v1', ), ).toBe('1'); expect(generatePuzzleOnboardingWork).not.toHaveBeenCalled(); }); test('first launch puzzle onboarding falls back to local run when generate route is missing', async () => { const user = userEvent.setup(); window.localStorage.removeItem( 'genarrative.puzzle-onboarding.first-visit.v1', ); vi.mocked(generatePuzzleOnboardingWork).mockRejectedValueOnce( new ApiClientError({ message: '资源不存在', status: 404, code: 'NOT_FOUND', }), ); render( {}, requireAuth: () => {}, })} />, ); await user.type( await screen.findByPlaceholderText('把你的梦讲给我听吧'), '我想飞上天', ); await user.click(screen.getByRole('button', { name: '生成' })); expect( await screen.findByTestId('puzzle-board', undefined, { timeout: 3000 }), ).toBeTruthy(); expect(generatePuzzleOnboardingWork).toHaveBeenCalledWith({ promptText: '我想飞上天', }); expect(screen.queryByText('资源不存在')).toBeNull(); expect(startPuzzleRun).not.toHaveBeenCalled(); expect( window.localStorage.getItem( 'genarrative.puzzle-onboarding.first-visit.v1', ), ).toBe('1'); }); test('formal puzzle runtime uses frontend move merge logic and backend leaderboard next level', async () => { const user = userEvent.setup(); const clearedFirstLevel = buildClearedPuzzleRun({ runId: 'run-puzzle-profile-public-1', entryProfileId: 'puzzle-profile-public-1', profileId: 'puzzle-profile-public-1', levelName: '雨夜猫塔', levelIndex: 1, elapsedMs: 18_000, }); const clearedFirstLevelWithNext = { ...clearedFirstLevel, recommendedNextProfileId: 'puzzle-profile-public-1', nextLevelMode: 'sameWork' as const, nextLevelProfileId: 'puzzle-profile-public-1', nextLevelId: 'puzzle-level-2', recommendedNextWorks: [], }; const leaderboardEntries = [ { rank: 1, nickname: '测试玩家', elapsedMs: 18_000, isCurrentPlayer: true, }, ]; const backendLeaderboardRun = { ...clearedFirstLevelWithNext, leaderboardEntries, currentLevel: { ...clearedFirstLevelWithNext.currentLevel!, leaderboardEntries, }, }; const backendSecondLevel = { ...buildMockPuzzleRun('puzzle-profile-public-1', '星桥机关'), runId: clearedFirstLevel.runId, entryProfileId: clearedFirstLevel.entryProfileId, currentLevelIndex: 2, currentLevel: { ...buildMockPuzzleRun('puzzle-profile-public-1', '星桥机关') .currentLevel!, runId: clearedFirstLevel.runId, levelIndex: 2, levelId: 'puzzle-level-2', startedAtMs: Date.now(), }, }; const backendStartedRun = buildMockPuzzleRun( 'puzzle-profile-public-1', '雨夜猫塔', ); vi.mocked(startPuzzleRun).mockResolvedValue({ run: { ...backendStartedRun, currentLevel: { ...backendStartedRun.currentLevel!, startedAtMs: Date.now(), }, }, }); vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({ run: backendLeaderboardRun, }); vi.mocked(advancePuzzleNextLevel).mockResolvedValue({ run: backendSecondLevel, }); vi.mocked(dragLocalPuzzlePiece).mockReturnValue(clearedFirstLevel); vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedFirstLevel); 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, levels: [ { levelId: 'puzzle-level-1', levelName: '雨夜猫塔', pictureDescription: '雨夜猫塔首关。', candidates: [], selectedCandidateId: null, coverImageSrc: null, coverAssetId: null, generationStatus: 'ready', }, { levelId: 'puzzle-level-2', levelName: '星桥机关', pictureDescription: '星桥机关第二关。', candidates: [], selectedCandidateId: null, coverImageSrc: null, coverAssetId: null, generationStatus: 'ready', }, ], }; vi.mocked(listPuzzleGallery).mockResolvedValue({ items: [puzzleWork], }); vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({ item: puzzleWork, }); render(); await openDiscoverHub(user); 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(screen.getByTestId('puzzle-board')).toBeTruthy(); }); expect(startPuzzleRun).toHaveBeenCalledWith( { profileId: 'puzzle-profile-public-1', levelId: null, }, ISOLATED_RUNTIME_AUTH_OPTIONS, ); vi.mocked(listProfileSaveArchives).mockClear(); vi.mocked(listProfileSaveArchives).mockRejectedValueOnce( new Error('后台存档刷新 401'), ); await user.click(document.querySelector('[data-piece-id="piece-0"]')!); await user.click(document.querySelector('[data-piece-id="piece-1"]')!); await waitFor(() => { expect(swapLocalPuzzlePieces).toHaveBeenCalled(); }); expect(swapPuzzlePieces).not.toHaveBeenCalled(); expect(dragPuzzlePieceOrGroup).not.toHaveBeenCalled(); await waitFor(() => { expect(submitPuzzleLeaderboard).toHaveBeenCalledWith( clearedFirstLevel.runId, { profileId: 'puzzle-profile-public-1', gridSize: 3, elapsedMs: 18_000, nickname: '测试玩家', }, ISOLATED_RUNTIME_AUTH_OPTIONS, ); }); const dialog = await screen.findByRole( 'dialog', { name: '通关完成' }, { timeout: 3000 }, ); expect(dialog).toBeTruthy(); expect(screen.getByText('测试玩家')).toBeTruthy(); expect(listProfileSaveArchives).toHaveBeenCalledWith( ISOLATED_RUNTIME_AUTH_OPTIONS, ); await user.click(within(dialog).getByRole('button', { name: '下一关' })); await waitFor(() => { expect(advancePuzzleNextLevel).toHaveBeenCalledWith( clearedFirstLevel.runId, {}, ISOLATED_RUNTIME_AUTH_OPTIONS, ); }); expect( (await screen.findAllByText('星桥机关', undefined, { timeout: 3000, })).length, ).toBeGreaterThan(0); }); test('formal puzzle similar work keeps current run level progression', async () => { const user = userEvent.setup(); const clearedThirdLevel = buildClearedPuzzleRun({ runId: 'run-puzzle-profile-public-1', entryProfileId: 'puzzle-profile-public-1', profileId: 'puzzle-profile-public-1', levelName: '雨夜猫塔', levelIndex: 3, elapsedMs: 18_000, recommendedNextProfileId: 'puzzle-profile-similar-2', }); const clearedThirdLevelWithCandidates: PuzzleRunSnapshot = { ...clearedThirdLevel, nextLevelMode: 'similarWorks', nextLevelProfileId: 'puzzle-profile-similar-1', nextLevelId: null, recommendedNextWorks: [ { profileId: 'puzzle-profile-similar-1', levelName: '雾海遗迹', authorDisplayName: '星桥旅人', themeTags: ['奇幻', '遗迹'], coverImageSrc: null, similarityScore: 0.91, }, { profileId: 'puzzle-profile-similar-2', levelName: '风塔试炼', authorDisplayName: '晨风', themeTags: ['奇幻', '机关'], coverImageSrc: null, similarityScore: 0.84, }, ], }; const similarFourthLevel = { ...buildMockPuzzleRun('puzzle-profile-similar-2', '风塔试炼'), runId: clearedThirdLevel.runId, entryProfileId: clearedThirdLevel.entryProfileId, currentLevelIndex: 4, currentGridSize: 5 as const, playedProfileIds: [ 'puzzle-profile-public-1', 'puzzle-profile-similar-2', ], currentLevel: { ...buildMockPuzzleRun('puzzle-profile-similar-2', '风塔试炼') .currentLevel!, runId: clearedThirdLevel.runId, levelIndex: 4, levelId: 'similar-level-1', gridSize: 5 as const, timeLimitMs: 210_000, remainingMs: 210_000, startedAtMs: Date.now(), board: { rows: 5, cols: 5, selectedPieceId: null, allTilesResolved: false, mergedGroups: [], pieces: Array.from({ length: 25 }, (_, index) => ({ pieceId: `piece-${index}`, correctRow: Math.floor(index / 5), correctCol: index % 5, currentRow: Math.floor(index / 5), currentCol: index % 5, mergedGroupId: null, })), }, }, }; const backendStartedRun = buildMockPuzzleRun( 'puzzle-profile-public-1', '雨夜猫塔', ); vi.mocked(startPuzzleRun).mockResolvedValue({ run: { ...backendStartedRun, currentLevel: { ...backendStartedRun.currentLevel!, startedAtMs: Date.now(), }, }, }); vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({ run: clearedThirdLevelWithCandidates, }); vi.mocked(advancePuzzleNextLevel).mockResolvedValue({ run: similarFourthLevel, }); vi.mocked(dragLocalPuzzlePiece).mockReturnValue(clearedThirdLevel); vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedThirdLevel); const entryWork: 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, }; const similarWork: PuzzleWorkSummary = { ...entryWork, workId: 'puzzle-work-similar-2', profileId: 'puzzle-profile-similar-2', levelName: '风塔试炼', summary: '另一套奇幻机关拼图。', }; vi.mocked(listPuzzleGallery).mockResolvedValue({ items: [entryWork], }); vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({ item: profileId === similarWork.profileId ? similarWork : entryWork, })); render(); await openDiscoverHub(user); 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(screen.getByTestId('puzzle-board')).toBeTruthy(); }); vi.mocked(startPuzzleRun).mockClear(); await user.click(document.querySelector('[data-piece-id="piece-0"]')!); await user.click(document.querySelector('[data-piece-id="piece-1"]')!); const dialog = await screen.findByRole( 'dialog', { name: '通关完成' }, { timeout: 3000 }, ); await user.click(within(dialog).getByRole('button', { name: /风塔试炼/u })); await waitFor(() => { expect(advancePuzzleNextLevel).toHaveBeenCalledWith( clearedThirdLevel.runId, { targetProfileId: 'puzzle-profile-similar-2' }, ISOLATED_RUNTIME_AUTH_OPTIONS, ); }); expect(startPuzzleRun).not.toHaveBeenCalled(); expect(await screen.findByText('第 4 关')).toBeTruthy(); await waitFor(() => { expect(document.querySelectorAll('[data-piece-id]').length).toBe(25); }); }); test('first puzzle runtime back click can open remix result page', 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, remixCount: 0, likeCount: 0, publishReady: true, }; const anchorPack = buildPuzzleAnchorPack(); const remixDraft: PuzzleResultDraft = { workTitle: '改造后的雨夜猫塔', workDescription: '准备改造的拼图草稿。', levelName: '改造后的雨夜猫塔', summary: '一只猫站在雨夜塔顶。', themeTags: ['雨夜', '猫咪', '塔'], forbiddenDirectives: [], creatorIntent: null, anchorPack, candidates: [], selectedCandidateId: null, coverImageSrc: null, coverAssetId: null, generationStatus: 'idle', levels: [], metadata: null, }; const remixSession: PuzzleAgentSessionSnapshot = { sessionId: 'puzzle-session-remix-1', currentTurn: 1, progressPercent: 100, stage: 'ready_to_publish', anchorPack, draft: remixDraft, messages: [], lastAssistantReply: null, publishedProfileId: null, suggestedActions: [], resultPreview: null, updatedAt: '2026-04-25T12:12:00.000Z', }; vi.mocked(listPuzzleGallery).mockResolvedValue({ items: [puzzleWork], }); vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({ item: puzzleWork, }); vi.mocked(remixPuzzleGalleryWork).mockResolvedValue({ session: remixSession, }); render(); await openDiscoverHub(user); 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: '启动' })); expect(await screen.findByTestId('puzzle-board')).toBeTruthy(); await user.click(await screen.findByRole('button', { name: '返回上一页' })); const dialog = await screen.findByRole('dialog', { name: /体验不佳?\s*试试改造功能!/u, }); await user.click(within(dialog).getByRole('button', { name: '作品改造' })); await waitFor(() => { expect(remixPuzzleGalleryWork).toHaveBeenCalledWith( 'puzzle-profile-public-1', ); }); expect(await screen.findByText('拼图结果页')).toBeTruthy(); expect(screen.getByDisplayValue('改造后的雨夜猫塔')).toBeTruthy(); }); test('public code search opens a published puzzle by PZ code', async () => { const user = userEvent.setup(); const puzzleWork: PuzzleWorkSummary = { workId: 'puzzle-work-public-1', profileId: 'puzzle-profile-public-1', ownerUserId: 'user-2', sourceSessionId: null, authorDisplayName: '拼图作者', levelName: '雨夜猫塔', summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。', themeTags: ['雨夜', '猫咪', '遗迹'], coverImageSrc: null, coverAssetId: null, publicationStatus: 'published', updatedAt: '2026-04-25T12:10:00.000Z', publishedAt: '2026-04-25T12:10:00.000Z', playCount: 8, likeCount: 0, publishReady: true, }; vi.mocked(listPuzzleGallery).mockResolvedValue({ items: [puzzleWork], }); vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({ item: puzzleWork, }); render(); await openDiscoverHub(user); 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('missing puzzle public detail returns to platform home', async () => { const user = userEvent.setup(); const missingPuzzleWork = { workId: 'puzzle-work-missing-1', profileId: 'puzzle-profile-missing-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: 1, remixCount: 0, likeCount: 0, publishReady: true, } satisfies PuzzleWorkSummary; vi.mocked(listPuzzleGallery).mockResolvedValue({ items: [missingPuzzleWork], }); vi.mocked(getPuzzleGalleryDetail).mockRejectedValueOnce( new ApiClientError({ message: '资源不存在', status: 404, code: 'NOT_FOUND', }), ); render(); await openDiscoverHub(user); const workCards = await screen.findAllByRole('button', { name: /失效拼图/u }); await user.click(workCards[0]!); await waitFor(() => { expect(window.location.pathname).toBe('/'); }); expect(getPlatformTabPanel('home').getAttribute('aria-hidden')).toBe('false'); expect(screen.queryByText('详情')).toBeNull(); expect(screen.queryByText('资源不存在')).toBeNull(); expect(startPuzzleRun).toHaveBeenCalledTimes(0); }); 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(); await openDiscoverHub(user); 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(startBigFishRun).toHaveBeenCalledWith( '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(); await openDiscoverHub(user); 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 openDraftHub(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 openDraftHub(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.getByRole('tablist', { name: '选择模板' })).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 can open save archives from the profile played panel', async () => { const user = userEvent.setup(); 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(); await openProfilePlayedWorks(user); 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 () => { const user = userEvent.setup(); 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(); await openProfilePlayedWorks(user); 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.findByRole('tablist', { name: '选择模板' }), ).toBeTruthy(); resolveGalleryRequest([]); await waitFor(() => { expect( within(getPlatformTabPanel('create')).getByRole('tablist', { name: '选择模板', }), ).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 openProfilePlayedWorks(user); 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 openDraftHub(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 openDraftHub(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 openDraftHub(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 keeps delete action guarded by detail flow', 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 openDraftHub(user); expect(await screen.findByRole('button', { name: /查看详情/u })).toBeTruthy(); await user.click(screen.getByRole('button', { name: '删除' })); const dialog = await screen.findByRole('dialog', { name: '删除作品' }); expect(dialog.parentElement?.className).toContain('platform-theme--light'); expect(dialog.parentElement?.className).toContain('!items-center'); expect(dialog.className).toContain('platform-modal-shell'); expect(dialog.className).toContain('platform-remap-surface'); expect(dialog.className).toContain('rounded-[1.75rem]'); expect( within(dialog).getByText('确认删除《潮雾列岛》吗?'), ).toBeTruthy(); expect( within(dialog).getByRole('button', { name: '确认删除' }), ).toBeTruthy(); expect(deleteRpgEntryWorldProfile).not.toHaveBeenCalled(); });