Files
Genarrative/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
五香丸子 46a254f142
Some checks failed
CI / verify (push) Has been cancelled
feat: add child motion entry and fix auth env
2026-05-10 18:27:51 +08:00

5818 lines
181 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @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,
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<typeof userEvent.setup>,
name: string | RegExp,
) {
const buttons = screen.getAllByRole('button', { name });
await user.click(buttons[0]!);
}
async function clickFirstAsyncButtonByName(
user: ReturnType<typeof userEvent.setup>,
name: string | RegExp,
) {
const buttons = await screen.findAllByRole('button', { name });
await user.click(buttons[0]!);
}
async function openCreateTemplateHub(user: ReturnType<typeof userEvent.setup>) {
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<typeof userEvent.setup>) {
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<typeof userEvent.setup>) {
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<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '我的');
await user.click(await screen.findByRole('button', { name: //u }));
expect(await screen.findByText('可继续')).toBeTruthy();
}
async function openExistingRpgDraft(
user: ReturnType<typeof userEvent.setup>,
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;
}) => (
<div className="puzzle-agent-workspace-mock">
<div>{session?.sessionId ?? 'missing-session'}</div>
{session?.messages.map((message) => (
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
))}
{error ? <div>{error}</div> : null}
<button
type="button"
disabled={isBusy}
onClick={() => {
onCreateFromForm?.({
seedText: '暖灯猫街',
workTitle: '暖灯猫街',
workDescription: '一套雨夜猫街主题拼图。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
});
}}
>
稿
</button>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../puzzle-result/PuzzleResultView', () => ({
PuzzleResultView: ({
session,
onBack,
}: {
session: { draft?: { levelName: string } | null };
onBack: () => void;
}) => (
<div className="puzzle-result-view-mock">
<div></div>
<label>
<input readOnly value={session.draft?.levelName ?? ''} />
</label>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../puzzle-gallery/PuzzleGalleryDetailView', () => ({
PuzzleGalleryDetailView: ({
item,
onBack,
onStartGame,
}: {
item: { levelName: string };
onBack: () => void;
onStartGame: () => void;
}) => (
<div className="puzzle-gallery-detail-view-mock">
<div>{item.levelName}</div>
<button type="button" onClick={onStartGame}>
1
</button>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../big-fish-creation/BigFishAgentWorkspace', () => ({
BigFishAgentWorkspace: ({
session,
}: {
session: { sessionId: string; messages: Array<{ text: string }> } | null;
}) => (
<div className="big-fish-agent-workspace-mock">
<div>{session?.sessionId ?? 'missing-session'}</div>
{session?.messages.map((message) => (
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
))}
</div>
),
}));
vi.mock('../big-fish-result/BigFishResultView', () => ({
BigFishResultView: ({
session,
onBack,
onExecuteAction,
}: {
session: { draft?: { title: string } | null };
onBack: () => void;
onExecuteAction: (payload: { action: string }) => void;
}) => (
<div className="big-fish-result-view-mock">
<div></div>
<div>{session.draft?.title ?? '缺少草稿标题'}</div>
<button
type="button"
onClick={() => {
onExecuteAction({ action: 'big_fish_publish_game' });
}}
>
</button>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
Match3DAgentWorkspace: ({
session,
}: {
session: { sessionId: string; messages: Array<{ text: string }> } | null;
}) => (
<div className="match3d-agent-workspace-mock">
<div>{session?.sessionId ?? 'missing-session'}</div>
{session?.messages.map((message) => (
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
))}
</div>
),
}));
vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
Match3DRuntimeShell: ({
run,
onBack,
}: {
run: Match3DRunSnapshot | null;
onBack: () => void;
}) => (
<div className="match3d-runtime-shell-mock">
<div>{run?.runId ?? 'missing-run'}</div>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
CustomWorldAgentWorkspace: ({
session,
onExecuteAction,
}: {
session: CustomWorldAgentSessionSnapshot | null;
onExecuteAction: (payload: { action: string }) => void;
}) => (
<div className="agent-workspace-mock">
Agent工作区{session?.sessionId ?? 'missing-session'}
<button
type="button"
onClick={() => {
onExecuteAction({
action: 'draft_foundation',
});
}}
>
稿
</button>
</div>
),
}));
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> = {},
): 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<Parameters<typeof buildMockSquareHoleAgentSessionImpl>[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> = {},
): 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<CustomWorldProfile> {
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> = {},
): 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<CustomWorldWorkSummary> = {},
) {
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<void>;
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> = {},
): 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<SelectionStage>(() =>
window.location.pathname === '/creation/rpg/agent'
? 'agent-workspace'
: 'platform',
);
const content = (
<RpgEntryFlowShell
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
hasSavedGame={false}
savedSnapshot={null}
handleContinueGame={onContinueGame ?? (() => {})}
handleStartNewGame={() => {}}
handleCustomWorldSelect={onSelectWorld ?? (() => {})}
/>
);
if (!withAuth && !authValue) {
return content;
}
return (
<AuthUiContext.Provider value={authValue ?? createAuthValue()}>
{content}
</AuthUiContext.Provider>
);
}
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(<TestWrapper withAuth />);
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('.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('embedded puzzle form routes through requireAuth while logged out', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
render(
<TestWrapper
authValue={createAuthValue({
user: null,
openLoginModal: () => {},
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(
<TestWrapper
authValue={createAuthValue({
user: null,
openLoginModal: () => {},
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(
<TestWrapper
authValue={createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
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(<TestWrapper withAuth />);
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();
});
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(
<TestWrapper
authValue={createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
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(<TestWrapper withAuth />);
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(<TestWrapper authValue={loggedInAuth} />);
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(<TestWrapper authValue={loggedOutAuth} />);
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(<TestWrapper />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper />);
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(<TestWrapper withAuth />);
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(
<TestWrapper
authValue={createAuthValue({
user: null,
openLoginModal,
requireAuth: vi.fn(),
})}
/>,
);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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('hidden match3d creation card stays closed even when public galleries fail', async () => {
const user = userEvent.setup();
vi.mocked(listRpgEntryWorldGallery).mockRejectedValueOnce(
new Error('读取作品广场失败'),
);
vi.mocked(listMatch3DGallery).mockRejectedValueOnce(
new Error('读取抓大鹅广场失败'),
);
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
expect(screen.queryByText('读取作品广场失败')).toBeNull();
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
expect(
screen.queryByRole('tab', { name: /.*/u }),
).toBeNull();
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(
<TestWrapper
authValue={createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
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(
<TestWrapper
authValue={createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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<SelectionStage>('platform');
return (
<AuthUiContext.Provider value={createAuthValue()}>
<RpgEntryFlowShell
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
hasSavedGame={false}
savedSnapshot={null}
handleContinueGame={() => {}}
handleStartNewGame={() => {}}
handleCustomWorldSelect={handleCustomWorldSelect}
/>
</AuthUiContext.Provider>
);
}
render(<PublishFlowWrapper />);
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<SelectionStage>('platform');
return (
<AuthUiContext.Provider value={createAuthValue()}>
<RpgEntryFlowShell
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
hasSavedGame={false}
savedSnapshot={null}
handleContinueGame={() => {}}
handleStartNewGame={() => {}}
handleCustomWorldSelect={handleCustomWorldSelect}
/>
</AuthUiContext.Provider>
);
}
render(<TestDraftWrapper />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
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(<TestWrapper withAuth />);
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();
});