Files
Genarrative/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
2026-05-01 22:16:01 +08:00

4546 lines
144 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 { beforeEach, expect, test, vi } from 'vitest';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type {
CustomWorldAgentSessionSnapshot,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { ApiClientError } from '../../services/apiClient';
import type { AuthUser } from '../../services/authService';
import {
createBigFishCreationSession,
getBigFishCreationSession,
} from '../../services/big-fish-creation';
import { listBigFishGallery } from '../../services/big-fish-gallery';
import {
recordBigFishPlay,
startLocalBigFishRuntimeRun,
} from '../../services/big-fish-runtime';
import { listBigFishWorks } from '../../services/big-fish-works';
import { match3dCreationClient } from '../../services/match3d-creation';
import {
clickMatch3DItem,
finishMatch3DTimeUp,
restartMatch3DRun,
startMatch3DRun,
stopMatch3DRun,
} from '../../services/match3d-runtime';
import {
deleteMatch3DWork,
getMatch3DWorkDetail,
listMatch3DGallery,
listMatch3DWorks,
} from '../../services/match3d-works';
import {
createPuzzleAgentSession,
getPuzzleAgentSession,
} from '../../services/puzzle-agent';
import {
getPuzzleGalleryDetail,
listPuzzleGallery,
remixPuzzleGalleryWork,
} from '../../services/puzzle-gallery';
import {
advanceLocalPuzzleNextLevel,
advancePuzzleNextLevel,
getPuzzleRun,
startPuzzleRun,
submitPuzzleLeaderboard,
updatePuzzleRunPause,
usePuzzleRuntimeProp,
} from '../../services/puzzle-runtime';
import {
dragLocalPuzzlePiece,
swapLocalPuzzlePieces,
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
import { listPuzzleWorks } from '../../services/puzzle-works';
import {
createRpgCreationSession,
executeRpgCreationAction,
getRpgCreationOperation,
getRpgCreationResultView,
getRpgCreationSession,
listRpgCreationWorks,
streamRpgCreationMessage,
upsertRpgWorldProfile,
} from '../../services/rpg-creation';
import {
clearRpgProfileBrowseHistory as clearProfileBrowseHistory,
getRpgEntryWorldGalleryDetail,
getRpgProfileDashboard as getProfileDashboard,
listRpgEntryWorldGallery,
listRpgEntryWorldLibrary,
listRpgProfileBrowseHistory as listProfileBrowseHistory,
listRpgProfileSaveArchives as listProfileSaveArchives,
resumeRpgProfileSaveArchive as resumeProfileSaveArchive,
upsertRpgProfileBrowseHistory as upsertProfileBrowseHistory,
} from '../../services/rpg-entry';
import {
deleteRpgEntryWorldProfile,
getRpgEntryWorldGalleryDetail as getRpgEntryWorldGalleryDetailFromClient,
getRpgEntryWorldGalleryDetailByCode,
recordRpgEntryWorldGalleryPlay,
remixRpgEntryWorldGallery,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
import { type CustomWorldProfile, WorldType } from '../../types';
import {
AuthUiContext,
type PlatformSettingsSection,
} from '../auth/AuthUiContext';
import {
RpgEntryFlowShell,
type RpgEntryFlowShellProps,
type SelectionStage,
} from './RpgEntryFlowShell';
async function clickFirstButtonByName(
user: ReturnType<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 openCreationHub(user: ReturnType<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '创作');
expect(await screen.findByText('角色扮演')).toBeTruthy();
}
async function openExistingRpgDraft(
user: ReturnType<typeof userEvent.setup>,
actionName: string | RegExp = /(?:|)/u,
) {
await openCreationHub(user);
await user.click(await screen.findByRole('button', { name: actionName }));
}
function getPlatformTabPanel(tab: string) {
const panel = document.getElementById(`platform-tab-panel-${tab}`);
if (!panel) {
throw new Error(`Missing platform tab panel: ${tab}`);
}
return panel;
}
const rpgCreationServiceMocks = vi.hoisted(() => ({
createRpgCreationSession: vi.fn(),
deleteRpgCreationAgentSession: vi.fn(),
executeRpgCreationAction: vi.fn(),
getRpgCreationOperation: vi.fn(),
getRpgCreationResultView: vi.fn(),
getRpgCreationSession: vi.fn(),
listRpgCreationWorks: vi.fn(),
streamRpgCreationMessage: vi.fn(),
upsertRpgWorldProfile: vi.fn(),
}));
const rpgEntryLibraryServiceMocks = vi.hoisted(() => ({
deleteRpgEntryWorldProfile: vi.fn(),
getRpgEntryWorldGalleryDetail: vi.fn(),
getRpgEntryWorldGalleryDetailByCode: vi.fn(),
getRpgEntryWorldLibraryDetail: vi.fn(),
listRpgEntryWorldGallery: vi.fn(),
listRpgEntryWorldLibrary: vi.fn(),
publishRpgEntryWorldProfile: vi.fn(),
recordRpgEntryWorldGalleryPlay: vi.fn(),
remixRpgEntryWorldGallery: vi.fn(),
unpublishRpgEntryWorldProfile: vi.fn(),
upsertRpgEntryWorldProfile: vi.fn(),
}));
vi.mock('../../services/rpg-creation', () => ({
...rpgCreationServiceMocks,
}));
vi.mock('../../services/rpg-creation/index', () => ({
...rpgCreationServiceMocks,
}));
vi.mock('../../services/rpg-entry', () => ({
clearRpgProfileBrowseHistory: vi.fn(),
deleteRpgEntryWorldProfile: rpgEntryLibraryServiceMocks.deleteRpgEntryWorldProfile,
getRpgEntryWorldGalleryDetail:
rpgEntryLibraryServiceMocks.getRpgEntryWorldGalleryDetail,
getRpgProfileDashboard: vi.fn(),
listRpgEntryWorldGallery:
rpgEntryLibraryServiceMocks.listRpgEntryWorldGallery,
listRpgEntryWorldLibrary:
rpgEntryLibraryServiceMocks.listRpgEntryWorldLibrary,
listRpgProfileBrowseHistory: vi.fn(),
listRpgProfileSaveArchives: vi.fn(),
publishRpgEntryWorldProfile:
rpgEntryLibraryServiceMocks.publishRpgEntryWorldProfile,
recordRpgEntryWorldGalleryPlay:
rpgEntryLibraryServiceMocks.recordRpgEntryWorldGalleryPlay,
resumeRpgProfileSaveArchive: vi.fn(),
remixRpgEntryWorldGallery:
rpgEntryLibraryServiceMocks.remixRpgEntryWorldGallery,
syncRpgProfileBrowseHistory: vi.fn(),
unpublishRpgEntryWorldProfile:
rpgEntryLibraryServiceMocks.unpublishRpgEntryWorldProfile,
upsertRpgProfileBrowseHistory: vi.fn(),
}));
vi.mock('../../services/puzzle-works', () => ({
listPuzzleWorks: vi.fn(),
}));
vi.mock('../../services/puzzle-gallery', () => ({
getPuzzleGalleryDetail: vi.fn(),
listPuzzleGallery: vi.fn(),
remixPuzzleGalleryWork: vi.fn(),
}));
vi.mock('../../services/puzzle-runtime', () => ({
advanceLocalPuzzleNextLevel: vi.fn(),
advancePuzzleNextLevel: vi.fn(),
getPuzzleRun: vi.fn(),
startPuzzleRun: vi.fn(),
swapPuzzlePieces: vi.fn(),
submitPuzzleLeaderboard: vi.fn(),
updatePuzzleRunPause: vi.fn(),
usePuzzleRuntimeProp: vi.fn(),
}));
vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({
...rpgEntryLibraryServiceMocks,
}));
vi.mock('../../services/big-fish-creation', () => ({
createBigFishCreationSession: vi.fn(),
executeBigFishCreationAction: vi.fn(),
getBigFishCreationSession: vi.fn(),
streamBigFishCreationMessage: vi.fn(),
}));
vi.mock('../../services/big-fish-works', () => ({
listBigFishWorks: vi.fn(),
}));
vi.mock('../../services/big-fish-gallery', () => ({
listBigFishGallery: vi.fn(),
}));
vi.mock('../../services/big-fish-runtime', () => ({
advanceLocalBigFishRuntimeRun: vi.fn((run) => run),
recordBigFishPlay: vi.fn(() => Promise.resolve()),
startLocalBigFishRuntimeRun: vi.fn(),
}));
vi.mock('../../services/match3d-creation', () => ({
match3dCreationClient: {
createSession: vi.fn(),
executeAction: vi.fn(),
getSession: vi.fn(),
streamMessage: vi.fn(),
},
}));
vi.mock('../../services/match3d-works', () => ({
deleteMatch3DWork: vi.fn(),
getMatch3DWorkDetail: vi.fn(),
listMatch3DGallery: vi.fn(),
listMatch3DWorks: vi.fn(),
}));
vi.mock('../../services/match3d-runtime', () => ({
clickMatch3DItem: vi.fn(),
finishMatch3DTimeUp: vi.fn(),
restartMatch3DRun: vi.fn(),
startMatch3DRun: vi.fn(),
stopMatch3DRun: vi.fn(),
}));
vi.mock('../../services/puzzle-runtime/puzzleLocalRuntime', async () => {
const actual = await vi.importActual<
typeof import('../../services/puzzle-runtime/puzzleLocalRuntime')
>('../../services/puzzle-runtime/puzzleLocalRuntime');
return {
...actual,
dragLocalPuzzlePiece: vi.fn(actual.dragLocalPuzzlePiece),
swapLocalPuzzlePieces: vi.fn(actual.swapLocalPuzzlePieces),
};
});
vi.mock('../../services/puzzle-agent', () => ({
createPuzzleAgentSession: vi.fn(),
executePuzzleAgentAction: vi.fn(),
getPuzzleAgentSession: vi.fn(),
streamPuzzleAgentMessage: vi.fn(),
}));
vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({
PuzzleAgentWorkspace: ({
session,
isBusy,
error,
onBack,
onCreateFromForm,
}: {
session: { sessionId: string; messages: Array<{ text: string }> } | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onCreateFromForm?: (payload: {
seedText: string;
workTitle: string;
workDescription: string;
pictureDescription: string;
referenceImageSrc: string | null;
}) => void;
}) => (
<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-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 buildMockPuzzleRun(
profileId: string,
levelName: string,
): PuzzleRunSnapshot {
const gridSize = 3 as const;
return {
runId: `run-${profileId}`,
entryProfileId: profileId,
clearedLevelCount: 0,
currentLevelIndex: 1,
currentGridSize: gridSize,
playedProfileIds: [profileId],
previousLevelTags: ['机关'],
recommendedNextProfileId: null,
leaderboardEntries: [],
currentLevel: {
runId: `run-${profileId}`,
levelIndex: 1,
levelId: 'puzzle-level-1',
gridSize,
profileId,
levelName,
authorDisplayName: '拼图作者',
themeTags: ['机关'],
coverImageSrc: null,
status: 'playing',
startedAtMs: 1_000,
clearedAtMs: null,
elapsedMs: null,
timeLimitMs: 300_000,
remainingMs: 300_000,
pausedAccumulatedMs: 0,
pauseStartedAtMs: null,
freezeAccumulatedMs: 0,
freezeStartedAtMs: null,
freezeUntilMs: null,
leaderboardEntries: [],
board: {
rows: 3,
cols: 3,
selectedPieceId: null,
allTilesResolved: false,
mergedGroups: [],
pieces: Array.from({ length: 9 }, (_, index) => ({
pieceId: `piece-${index}`,
correctRow: Math.floor(index / 3),
correctCol: index % 3,
currentRow: Math.floor(index / 3),
currentCol: index % 3,
mergedGroupId: null,
})),
},
},
};
}
function buildClearedPuzzleRun(params: {
runId: string;
entryProfileId: string;
profileId: string;
levelName: string;
levelIndex: number;
elapsedMs: number;
recommendedNextProfileId?: string | null;
leaderboardEntries?: PuzzleRunSnapshot['leaderboardEntries'];
}): PuzzleRunSnapshot {
const baseRun = buildMockPuzzleRun(params.profileId, params.levelName);
const currentLevel = baseRun.currentLevel!;
const leaderboardEntries = params.leaderboardEntries ?? [];
return {
...baseRun,
runId: params.runId,
entryProfileId: params.entryProfileId,
currentLevelIndex: params.levelIndex,
clearedLevelCount: params.levelIndex,
currentGridSize: currentLevel.gridSize,
playedProfileIds:
params.entryProfileId === params.profileId
? [params.entryProfileId]
: [params.entryProfileId, params.profileId],
recommendedNextProfileId: params.recommendedNextProfileId ?? null,
leaderboardEntries,
currentLevel: {
...currentLevel,
runId: params.runId,
levelIndex: params.levelIndex,
profileId: params.profileId,
levelName: params.levelName,
status: 'cleared',
clearedAtMs: currentLevel.startedAtMs + params.elapsedMs,
elapsedMs: params.elapsedMs,
leaderboardEntries,
board: {
...currentLevel.board,
allTilesResolved: true,
},
},
};
}
function buildMockMatch3DRun(profileId: string): Match3DRunSnapshot {
return {
runId: `match3d-run-${profileId}`,
profileId,
ownerUserId: 'user-2',
status: 'running',
snapshotVersion: 1,
startedAtMs: 1_000,
durationLimitMs: 600_000,
serverNowMs: 1_000,
remainingMs: 600_000,
clearCount: 4,
totalItemCount: 12,
clearedItemCount: 0,
items: [],
traySlots: Array.from({ length: 7 }, (_, slotIndex) => ({ slotIndex })),
failureReason: null,
};
}
function buildMockRpgGalleryDetail(
entry: CustomWorldGalleryCard,
): CustomWorldLibraryEntry<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();
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 0,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-04-16T12:00:00.000Z',
});
vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([]);
vi.mocked(listRpgEntryWorldGallery).mockResolvedValue([]);
vi.mocked(getRpgEntryWorldGalleryDetail).mockImplementation(
async (ownerUserId, profileId) =>
buildMockRpgGalleryDetail({
ownerUserId,
profileId,
publicWorkCode: null,
authorPublicUserCode: ownerUserId,
visibility: 'published',
publishedAt: '2026-04-16T12:00:00.000Z',
updatedAt: '2026-04-16T12:00:00.000Z',
authorDisplayName: '测试作者',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '最近公开发布的世界。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
}),
);
vi.mocked(getRpgEntryWorldGalleryDetailFromClient).mockImplementation(
async (ownerUserId, profileId) =>
buildMockRpgGalleryDetail({
ownerUserId,
profileId,
publicWorkCode: null,
authorPublicUserCode: ownerUserId,
visibility: 'published',
publishedAt: '2026-04-16T12:00:00.000Z',
updatedAt: '2026-04-16T12:00:00.000Z',
authorDisplayName: '测试作者',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '最近公开发布的世界。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
}),
);
vi.mocked(
rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail,
).mockImplementation(async (profileId: string) =>
buildMockRpgGalleryDetail({
ownerUserId: mockAuthUser.id,
profileId,
publicWorkCode: `work-${profileId}`,
authorPublicUserCode: mockAuthUser.publicUserCode,
visibility: 'published',
publishedAt: '2026-04-16T12:00:00.000Z',
updatedAt: '2026-04-16T12:00:00.000Z',
authorDisplayName: mockAuthUser.displayName,
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '最近公开发布的世界。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
}),
);
vi.mocked(listProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(listProfileSaveArchives).mockResolvedValue([]);
vi.mocked(resumeProfileSaveArchive).mockResolvedValue({
entry: {
worldKey: 'custom:world-archive-1',
ownerUserId: null,
profileId: 'world-archive-1',
worldType: 'CUSTOM',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '回到旧灯塔继续推进调查。',
coverImageSrc: null,
lastPlayedAt: '2026-04-19T12:00:00.000Z',
},
snapshot: {
version: 2,
savedAt: '2026-04-19T12:00:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {},
} as HydratedSavedGameSnapshot,
});
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
vi.mocked(recordBigFishPlay).mockResolvedValue(undefined);
vi.mocked(recordRpgEntryWorldGalleryPlay).mockImplementation(
async (ownerUserId, profileId) => ({
ownerUserId,
profileId,
publicWorkCode: null,
authorPublicUserCode: ownerUserId,
profile: {
id: profileId,
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '最近公开发布的世界。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清旧案。',
majorFactions: ['守灯会'],
coreConflicts: ['雾潮正在逼近港口'],
playableNpcs: [],
storyNpcs: [],
landmarks: [],
} as never,
visibility: 'published',
publishedAt: '2026-04-16T12:00:00.000Z',
updatedAt: '2026-04-16T12:00:00.000Z',
authorDisplayName: '测试作者',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '最近公开发布的世界。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
}),
);
vi.mocked(remixRpgEntryWorldGallery).mockRejectedValue(
new Error('未启用 remix'),
);
vi.mocked(getRpgEntryWorldGalleryDetailByCode).mockRejectedValue(
new Error('未找到公开作品'),
);
vi.mocked(upsertRpgWorldProfile).mockResolvedValue({
entry: {
ownerUserId: 'user-1',
profileId: 'agent-draft-custom-world-agent-session-1',
publicWorkCode: null,
authorPublicUserCode: null,
profile: {
id: 'agent-draft-custom-world-agent-session-1',
name: '潮雾列岛',
} as never,
visibility: 'draft',
publishedAt: null,
updatedAt: '2026-04-14T12:00:00.000Z',
authorDisplayName: '玩家',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '第一版世界底稿已经整理完成。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 1,
landmarkCount: 1,
likeCount: 0,
},
entries: [],
});
vi.mocked(createRpgCreationSession).mockResolvedValue({
session: mockSession,
});
vi.mocked(getRpgCreationResultView).mockImplementation(async () =>
buildResultViewForSession(mockSession),
);
vi.mocked(createBigFishCreationSession).mockResolvedValue({
session: {
sessionId: 'big-fish-session-1',
currentTurn: 0,
progressPercent: 0,
stage: 'clarifying',
anchorPack: {
gameplayPromise: {
key: 'gameplay_promise',
label: '核心玩法',
value: '',
status: 'missing',
},
ecologyVisualTheme: {
key: 'ecology_visual_theme',
label: '生态视觉',
value: '',
status: 'missing',
},
growthLadder: {
key: 'growth_ladder',
label: '成长阶梯',
value: '',
status: 'missing',
},
riskTempo: {
key: 'risk_tempo',
label: '风险节奏',
value: '',
status: 'missing',
},
},
draft: null,
assetSlots: [],
assetCoverage: {
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
requiredLevelCount: 0,
publishReady: false,
blockers: [],
},
messages: [],
lastAssistantReply: '先说说你想要什么样的大鱼生态。',
publishReady: false,
updatedAt: '2026-04-22T12:00:00.000Z',
},
});
vi.mocked(getBigFishCreationSession).mockResolvedValue({
session: {
sessionId: 'big-fish-session-1',
currentTurn: 2,
progressPercent: 90,
stage: 'draft_ready',
anchorPack: {
gameplayPromise: {
key: 'gameplay_promise',
label: '核心玩法',
value: '机械微生物吞并进化',
status: 'confirmed',
},
ecologyVisualTheme: {
key: 'ecology_visual_theme',
label: '生态视觉',
value: '深海机械浮游生态',
status: 'confirmed',
},
growthLadder: {
key: 'growth_ladder',
label: '成长阶梯',
value: '从微光孢子到深海巨鲲',
status: 'confirmed',
},
riskTempo: {
key: 'risk_tempo',
label: '风险节奏',
value: '快节奏吞并,后段压迫感增强',
status: 'confirmed',
},
},
draft: {
title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化 · 偏爽快节奏',
coreFun: '吞并更小机械生命并持续合体成长',
ecologyTheme: '深海机械浮游生态',
levels: [
{
level: 1,
name: '微光孢子',
oneLineFantasy: '像发光尘埃一样在深海漂浮。',
textDescription:
'微光孢子是机械深海生态中的起始个体,体型最小,会先漂浮试探并寻找可吞并目标。',
silhouetteDirection: '圆润微型机械球',
sizeRatio: 1,
visualDescription:
'带有浅色发光核心的微型机械鱼苗或孢子体,轮廓圆润,表现出弱小但灵动的初始形象。',
visualPromptSeed: 'deep sea glowing mechanical spore',
idleMotionDescription:
'待机时轻轻漂浮,身体和尾部做小幅摆动,像在适应深海水流。',
moveMotionDescription:
'移动时核心前探,尾部快速摆动推进,带出轻盈的游动轨迹。',
motionPromptSeed: 'soft floating mechanical spore',
mergeSourceLevel: null,
preyWindow: [1],
threatWindow: [2],
isFinalLevel: false,
},
],
background: {
theme: '机械深海',
colorMood: '冷青色与暗金反光',
foregroundHints: '漂浮齿轮碎片',
midgroundComposition: '珊瑚状机械群落',
backgroundDepth: '深海远景光柱',
safePlayAreaHint: '中心区域留空',
spawnEdgeHint: '边缘暗流刷怪',
backgroundPromptSeed: 'mechanical deep sea arena',
},
runtimeParams: {
levelCount: 8,
mergeCountPerUpgrade: 3,
spawnTargetCount: 28,
leaderMoveSpeed: 1.2,
followerCatchUpSpeed: 1,
offscreenCullSeconds: 8,
preySpawnDeltaLevels: [-2, -1],
threatSpawnDeltaLevels: [1, 2],
winLevel: 8,
},
},
assetSlots: [],
assetCoverage: {
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
requiredLevelCount: 8,
publishReady: false,
blockers: ['仍有主图、动作和背景未生成'],
},
messages: [
{
id: 'big-fish-message-1',
role: 'assistant',
kind: 'chat',
text: '先说说你想要什么样的大鱼生态。',
createdAt: '2026-04-22T12:00:00.000Z',
},
{
id: 'big-fish-message-2',
role: 'user',
kind: 'chat',
text: '我想做机械深海里微生物互相吞并进化。',
createdAt: '2026-04-22T12:01:00.000Z',
},
],
lastAssistantReply: '大鱼结果页草稿已经生成,可以补正式资产。',
publishReady: false,
updatedAt: '2026-04-22T12:10:00.000Z',
},
});
vi.mocked(createPuzzleAgentSession).mockResolvedValue({
session: {
sessionId: 'puzzle-session-1',
currentTurn: 0,
progressPercent: 0,
stage: 'collecting_anchors',
anchorPack: {
themePromise: {
key: 'theme_promise',
label: '主题承诺',
value: '',
status: 'missing',
},
visualSubject: {
key: 'visual_subject',
label: '视觉主体',
value: '',
status: 'missing',
},
visualMood: {
key: 'visual_mood',
label: '视觉气质',
value: '',
status: 'missing',
},
compositionHooks: {
key: 'composition_hooks',
label: '构图钩子',
value: '',
status: 'missing',
},
tagsAndForbidden: {
key: 'tags_and_forbidden',
label: '标签与禁区',
value: '',
status: 'missing',
},
},
draft: null,
messages: [],
lastAssistantReply: '先说一个你最想做成拼图的画面。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-04-22T12:00:00.000Z',
},
});
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
session: {
sessionId: 'puzzle-session-1',
currentTurn: 3,
progressPercent: 88,
stage: 'draft_ready',
anchorPack: {
themePromise: {
key: 'theme_promise',
label: '主题承诺',
value: '雨夜遗迹探索',
status: 'confirmed',
},
visualSubject: {
key: 'visual_subject',
label: '视觉主体',
value: '发光猫咪站在遗迹台阶上',
status: 'confirmed',
},
visualMood: {
key: 'visual_mood',
label: '视觉气质',
value: '潮湿、梦幻、轻悬疑',
status: 'confirmed',
},
compositionHooks: {
key: 'composition_hooks',
label: '构图钩子',
value: '台阶透视、倒影、门洞',
status: 'confirmed',
},
tagsAndForbidden: {
key: 'tags_and_forbidden',
label: '标签与禁区',
value: '雨夜、猫咪、遗迹;禁止文字水印',
status: 'confirmed',
},
},
draft: {
levelName: '雨夜猫塔',
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
themeTags: ['雨夜', '猫咪', '遗迹'],
forbiddenDirectives: ['文字水印'],
creatorIntent: null,
anchorPack: {
themePromise: {
key: 'theme_promise',
label: '主题承诺',
value: '雨夜遗迹探索',
status: 'confirmed',
},
visualSubject: {
key: 'visual_subject',
label: '视觉主体',
value: '发光猫咪站在遗迹台阶上',
status: 'confirmed',
},
visualMood: {
key: 'visual_mood',
label: '视觉气质',
value: '潮湿、梦幻、轻悬疑',
status: 'confirmed',
},
compositionHooks: {
key: 'composition_hooks',
label: '构图钩子',
value: '台阶透视、倒影、门洞',
status: 'confirmed',
},
tagsAndForbidden: {
key: 'tags_and_forbidden',
label: '标签与禁区',
value: '雨夜、猫咪、遗迹;禁止文字水印',
status: 'confirmed',
},
},
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'idle',
},
messages: [
{
id: 'puzzle-message-1',
role: 'assistant',
kind: 'chat',
text: '先说一个你最想做成拼图的画面。',
createdAt: '2026-04-22T12:00:00.000Z',
},
{
id: 'puzzle-message-2',
role: 'user',
kind: 'chat',
text: '雨夜里有一只会发光的猫站在遗迹台阶上。',
createdAt: '2026-04-22T12:01:00.000Z',
},
],
lastAssistantReply: '拼图结果页草稿已经生成,可以开始出图并确认标签。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: {
draft: {
levelName: '雨夜猫塔',
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
themeTags: ['雨夜', '猫咪', '遗迹'],
forbiddenDirectives: ['文字水印'],
creatorIntent: null,
anchorPack: {
themePromise: {
key: 'theme_promise',
label: '主题承诺',
value: '雨夜遗迹探索',
status: 'confirmed',
},
visualSubject: {
key: 'visual_subject',
label: '视觉主体',
value: '发光猫咪站在遗迹台阶上',
status: 'confirmed',
},
visualMood: {
key: 'visual_mood',
label: '视觉气质',
value: '潮湿、梦幻、轻悬疑',
status: 'confirmed',
},
compositionHooks: {
key: 'composition_hooks',
label: '构图钩子',
value: '台阶透视、倒影、门洞',
status: 'confirmed',
},
tagsAndForbidden: {
key: 'tags_and_forbidden',
label: '标签与禁区',
value: '雨夜、猫咪、遗迹;禁止文字水印',
status: 'confirmed',
},
},
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'idle',
},
blockers: [
{
id: 'missing-cover-image',
code: 'MISSING_COVER_IMAGE',
message: '正式拼图图片尚未确定',
},
],
qualityFindings: [],
publishReady: false,
},
updatedAt: '2026-04-22T12:10:00.000Z',
},
});
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
vi.mocked(listRpgCreationWorks).mockResolvedValue([]);
vi.mocked(listBigFishWorks).mockResolvedValue({
items: [],
});
vi.mocked(listBigFishGallery).mockResolvedValue({
items: [],
});
vi.mocked(recordBigFishPlay).mockResolvedValue(undefined);
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: null,
});
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: null,
});
vi.mocked(match3dCreationClient.streamMessage).mockResolvedValue(null);
vi.mocked(match3dCreationClient.executeAction).mockResolvedValue({
session: null,
});
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [],
});
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [],
});
vi.mocked(getMatch3DWorkDetail).mockRejectedValue(
new Error('未找到抓大鹅作品'),
);
vi.mocked(deleteMatch3DWork).mockResolvedValue({
items: [],
});
vi.mocked(startMatch3DRun).mockRejectedValue(
new Error('未启动抓大鹅运行态'),
);
vi.mocked(clickMatch3DItem).mockRejectedValue(
new Error('未执行抓大鹅点击'),
);
vi.mocked(restartMatch3DRun).mockRejectedValue(
new Error('未重新开始抓大鹅运行态'),
);
vi.mocked(finishMatch3DTimeUp).mockResolvedValue({
run: buildMockMatch3DRun('match3d-profile-time-up'),
});
vi.mocked(stopMatch3DRun).mockResolvedValue({
run: buildMockMatch3DRun('match3d-profile-stopped'),
});
vi.mocked(startLocalBigFishRuntimeRun).mockReturnValue({
runId: 'big-fish-run-1',
sessionId: 'big-fish-session-public-1',
status: 'running',
tick: 0,
playerLevel: 1,
winLevel: 8,
leaderEntityId: 'owned-1',
ownedEntities: [
{
entityId: 'owned-1',
level: 1,
position: { x: 0, y: 0 },
radius: 12,
offscreenSeconds: 0,
},
],
wildEntities: [],
cameraCenter: { x: 0, y: 0 },
lastInput: { x: 0, y: 0 },
eventLog: ['机械鱼群开始巡游。'],
updatedAt: '2026-04-25T12:12:00.000Z',
});
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [],
});
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [],
});
vi.mocked(remixPuzzleGalleryWork).mockRejectedValue(
new Error('未启用拼图 remix'),
);
vi.mocked(advancePuzzleNextLevel).mockImplementation(async (runId) => ({
run: buildMockPuzzleRun(`${runId}-next-profile`, '后端推荐下一关'),
}));
vi.mocked(getPuzzleRun).mockImplementation(async (runId) => ({
run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'),
}));
vi.mocked(updatePuzzleRunPause).mockImplementation(async (runId) => ({
run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'),
}));
vi.mocked(usePuzzleRuntimeProp).mockImplementation(async (runId) => ({
run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'),
}));
vi.mocked(submitPuzzleLeaderboard).mockImplementation(
async (runId, payload) => ({
run: {
...buildMockPuzzleRun(payload.profileId, '服务端排行榜快照'),
runId,
entryProfileId: payload.profileId,
leaderboardEntries: [
{
rank: 1,
nickname: payload.nickname,
elapsedMs: payload.elapsedMs,
isCurrentPlayer: true,
},
],
currentLevel: {
...buildMockPuzzleRun(payload.profileId, '服务端排行榜快照')
.currentLevel!,
runId,
profileId: payload.profileId,
gridSize: payload.gridSize,
leaderboardEntries: [
{
rank: 1,
nickname: payload.nickname,
elapsedMs: payload.elapsedMs,
isCurrentPlayer: true,
},
],
},
},
}),
);
vi.mocked(dragLocalPuzzlePiece).mockImplementation((run) => run);
vi.mocked(swapLocalPuzzlePieces).mockImplementation((run) => run);
vi.mocked(executeRpgCreationAction).mockResolvedValue({
operation: {
operationId: 'operation-draft-foundation-1',
type: 'draft_foundation',
status: 'queued',
phaseLabel: '已接收请求',
phaseDetail: '正在准备生成世界底稿。',
progress: 10,
error: null,
},
});
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-draft-foundation-1',
type: 'draft_foundation',
status: 'running',
phaseLabel: '生成世界底稿',
phaseDetail: '正在根据已确认锚点编译第一版世界结构。',
progress: 38,
error: null,
});
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession);
});
test('create hub opens RPG while keeping AIRP and visual novel locked', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreationHub(user);
const airpButton = screen.getByRole('button', { name: /AIRP/u });
const visualNovelButton = screen.getByRole('button', {
name: //u,
});
expect((airpButton as HTMLButtonElement).disabled).toBe(true);
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
const rpgButton = screen.getByRole('button', { name: //u });
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
await user.click(rpgButton);
expect(createRpgCreationSession).toHaveBeenCalledTimes(1);
expect(
await screen.findByText('Agent工作区custom-world-agent-session-1'),
).toBeTruthy();
});
test('platform create hub does not prefetch hidden big fish platform data', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(
await screen.findByRole('button', { name: //u }),
).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(listBigFishWorks).not.toHaveBeenCalled();
expect(listBigFishGallery).not.toHaveBeenCalled();
});
test('opening RPG agent workspace does not refetch session snapshot in a render loop', async () => {
const user = userEvent.setup();
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: 'draft:custom-world-agent-session-1',
sourceType: 'agent_session',
status: 'draft',
title: '潮雾列岛',
subtitle: '补齐关键锚点',
summary: '玩家是失职返乡的守灯人。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: null,
stage: 'clarifying',
stageLabel: '补齐关键锚点',
playableNpcCount: 0,
landmarkCount: 0,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: 'custom-world-agent-session-1',
profileId: null,
canResume: true,
canEnterWorld: false,
},
]);
vi.mocked(getRpgCreationResultView).mockResolvedValue({
...buildResultViewForSession(mockSession),
targetStage: 'agent-workspace',
resultViewSource: null,
});
render(<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 openCreationHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
await waitFor(
() => {
expect(screen.queryByText('正在加载世界编辑器...')).toBeNull();
},
{ timeout: 5000 },
);
expect(
await screen.findByText('世界档案', {}, { timeout: 5000 }),
).toBeTruthy();
expect(
screen.queryByText('Agent工作区custom-world-agent-session-1'),
).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
}, 10000);
test('create tab resumes agent workspace when draft has no compiled result yet', async () => {
const user = userEvent.setup();
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: 'draft:custom-world-agent-session-1',
sourceType: 'agent_session',
status: 'draft',
title: '潮雾列岛',
subtitle: '补齐关键锚点',
summary: '玩家是失职返乡的守灯人。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: null,
stage: 'clarifying',
stageLabel: '补齐关键锚点',
playableNpcCount: 0,
landmarkCount: 0,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: 'custom-world-agent-session-1',
profileId: null,
canResume: true,
canEnterWorld: false,
},
]);
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
expect(
await screen.findByText('Agent工作区custom-world-agent-session-1'),
).toBeTruthy();
expect(screen.queryByText('世界档案')).toBeNull();
});
test('create tab resumes agent workspace when session has no draft profile even if summary counts look compiled', async () => {
const user = userEvent.setup();
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: 'draft:custom-world-agent-session-1',
sourceType: 'agent_session',
status: 'draft',
title: '潮雾列岛',
subtitle: '待完善草稿',
summary: '作品卡摘要仍带着旧对象数量,但服务端还没有草稿 profile。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: null,
stage: 'clarifying',
stageLabel: '补齐关键锚点',
playableNpcCount: 2,
landmarkCount: 1,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: 'custom-world-agent-session-1',
profileId: null,
canResume: true,
canEnterWorld: false,
},
]);
vi.mocked(getRpgCreationSession).mockResolvedValue({
...mockSession,
stage: 'clarifying',
draftProfile: null,
});
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession({
...mockSession,
stage: 'clarifying',
draftProfile: null,
}),
);
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
expect(
await screen.findByText('Agent工作区custom-world-agent-session-1'),
).toBeTruthy();
expect(screen.queryByText('世界档案')).toBeNull();
});
test('opening a compiled draft with a missing agent session falls back to create hub', async () => {
const user = userEvent.setup();
vi.mocked(listRpgCreationWorks)
.mockResolvedValueOnce([
{
workId: 'draft:custom-world-agent-session-missing',
sourceType: 'agent_session',
status: 'draft',
title: '潮雾列岛',
subtitle: '世界底稿已生成',
summary: '这是一份已经整理过首版结果页的草稿。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-20T11:00:00.000Z',
publishedAt: null,
stage: 'object_refining',
stageLabel: '整理关键对象',
playableNpcCount: 1,
landmarkCount: 1,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: 'custom-world-agent-session-missing',
profileId: null,
canResume: true,
canEnterWorld: false,
},
])
.mockResolvedValueOnce([]);
const missingSessionError = new ApiClientError({
message: 'custom world agent session not found',
status: 404,
code: 'NOT_FOUND',
});
vi.mocked(getRpgCreationSession).mockRejectedValueOnce(missingSessionError);
vi.mocked(getRpgCreationResultView).mockRejectedValueOnce(
missingSessionError,
);
render(<TestWrapper withAuth />);
await openCreationHub(user);
await user.click(await screen.findByRole('button', { name: //u }));
await waitFor(() => {
expect(
within(getPlatformTabPanel('create')).getByText(
'这份共创草稿已失效,已为你返回创作中心,请重新开始创作。',
),
).toBeTruthy();
});
expect(window.location.search).toBe('');
expect(listRpgCreationWorks).toHaveBeenCalledTimes(2);
expect(screen.getByText('还没有作品')).toBeTruthy();
expect(
screen.queryByText('Agent工作区custom-world-agent-session-missing'),
).toBeNull();
});
test('clicking a public work while logged out opens public detail without starting RPG', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
vi.mocked(listRpgEntryWorldGallery).mockResolvedValue([
{
ownerUserId: 'author-1',
profileId: 'world-public-1',
publicWorkCode: 'work-public-1',
authorPublicUserCode: 'author-1',
visibility: 'published',
publishedAt: '2026-04-16T12:00:00.000Z',
updatedAt: '2026-04-16T12:00:00.000Z',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '最近公开发布的世界。',
coverImageSrc: null,
themeMode: 'tide',
authorDisplayName: '潮汐作者',
playableNpcCount: 3,
landmarkCount: 4,
likeCount: 0,
},
]);
render(
<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('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,
})}
/>,
);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'BF-NPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
expect(await screen.findByText('详情')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '启动' }));
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(startLocalBigFishRuntimeRun).not.toHaveBeenCalled();
expect(recordBigFishPlay).not.toHaveBeenCalled();
});
test('creation hub clears all private work shelves immediately after logout state', async () => {
const user = userEvent.setup();
const loggedInAuth = createAuthValue();
const loggedOutAuth = createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth: () => {},
});
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: 'draft:rpg-logout-cache-1',
sourceType: 'agent_session',
status: 'draft',
title: 'RPG 退出缓存作品',
subtitle: '登出后不应继续可见',
summary: '这条 RPG 私有作品只能在登录态展示。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-25T10:00:00.000Z',
publishedAt: null,
stage: 'clarifying',
stageLabel: '补齐关键锚点',
playableNpcCount: 0,
landmarkCount: 0,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: 'rpg-logout-cache-session',
profileId: null,
canResume: true,
canEnterWorld: false,
},
]);
vi.mocked(listBigFishWorks).mockResolvedValue({
items: [
{
workId: 'big-fish-logout-cache-1',
sourceSessionId: 'big-fish-logout-cache-session',
ownerUserId: 'user-1',
authorDisplayName: '测试玩家',
title: '大鱼退出缓存作品',
subtitle: '登出后不应继续可见',
summary: '这条大鱼私有作品只能在登录态展示。',
coverImageSrc: null,
status: 'draft',
updatedAt: '2026-04-25T10:05:00.000Z',
publishReady: false,
levelCount: 8,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
},
],
});
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [
{
workId: 'puzzle-logout-cache-1',
profileId: 'puzzle-logout-cache-profile',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-logout-cache-session',
authorDisplayName: '测试玩家',
levelName: '拼图退出缓存作品',
summary: '这条拼图私有作品只能在登录态展示。',
themeTags: ['退出态'],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-04-25T10:10:00.000Z',
publishedAt: null,
playCount: 0,
likeCount: 0,
publishReady: false,
},
],
});
const { rerender } = render(<TestWrapper authValue={loggedInAuth} />);
await openCreationHub(user);
const createPanel = getPlatformTabPanel('create');
await waitFor(() => {
expect(listRpgCreationWorks).toHaveBeenCalled();
});
expect(await within(createPanel).findByText('拼图退出缓存作品')).toBeTruthy();
expect(within(createPanel).queryByText('RPG 退出缓存作品')).toBeNull();
expect(within(createPanel).queryByText('大鱼退出缓存作品')).toBeNull();
rerender(<TestWrapper authValue={loggedOutAuth} />);
await waitFor(() => {
expect(within(createPanel).queryByText('RPG 退出缓存作品')).toBeNull();
expect(within(createPanel).queryByText('拼图退出缓存作品')).toBeNull();
});
expect(within(createPanel).getByText('还没有作品')).toBeTruthy();
});
test('published puzzle works appear on home and mobile game category channel', async () => {
const user = userEvent.setup();
const publishedPuzzleWork = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: 'puzzle-session-public-1',
authorDisplayName: '拼图作者',
levelName: '星桥机关',
summary: '旋转碎片并接通星桥机关。',
themeTags: ['机关', '星桥'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
playCount: 3,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [publishedPuzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: publishedPuzzleWork,
});
vi.mocked(startPuzzleRun).mockResolvedValue({
run: buildMockPuzzleRun(
publishedPuzzleWork.profileId,
publishedPuzzleWork.levelName,
),
});
render(<TestWrapper />);
await waitFor(() => {
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
});
await user.click(screen.getByRole('button', { name: '游戏分类' }));
const homePanel = getPlatformTabPanel('home');
expect(within(homePanel).getAllByText('星桥机关').length).toBeGreaterThan(0);
expect(
within(homePanel).getAllByRole('button', { name: //u }).length,
).toBeGreaterThan(0);
expect(screen.queryByRole('button', { name: 'PC游戏' })).toBeNull();
expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull();
});
test('published big fish works stay hidden from platform home and game category channel', async () => {
const user = userEvent.setup();
const publishedBigFishWork: BigFishWorkSummary = {
workId: 'big-fish-work-public-1',
sourceSessionId: 'big-fish-session-public-1',
ownerUserId: 'user-2',
authorDisplayName: '大鱼作者',
title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化',
summary: '从微光孢子一路吞并成长到深海巨鲲。',
coverImageSrc: null,
status: 'published',
updatedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
levelCount: 8,
levelMainImageReadyCount: 8,
levelMotionReadyCount: 16,
backgroundReady: true,
};
vi.mocked(listBigFishGallery).mockResolvedValue({
items: [publishedBigFishWork],
});
render(<TestWrapper />);
await waitFor(() => {
expect(listBigFishGallery).not.toHaveBeenCalled();
});
expect(screen.queryByText('机械深海 大鱼吃小鱼')).toBeNull();
await user.click(screen.getByRole('button', { name: '游戏分类' }));
const homePanel = getPlatformTabPanel('home');
expect(within(homePanel).queryByText('机械深海 大鱼吃小鱼')).toBeNull();
expect(
within(homePanel).queryAllByRole('button', { name: //u }).length,
).toBe(0);
});
test('published puzzle detail returns to the ranking platform tab', async () => {
const user = userEvent.setup();
const publishedPuzzleWork = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: null,
authorDisplayName: '拼图作者',
levelName: '星桥机关',
summary: '旋转碎片并接通星桥机关。',
themeTags: ['机关', '星桥'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
playCount: 3,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [publishedPuzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: publishedPuzzleWork,
});
vi.mocked(startPuzzleRun).mockResolvedValue({
run: buildMockPuzzleRun(
publishedPuzzleWork.profileId,
publishedPuzzleWork.levelName,
),
});
render(<TestWrapper withAuth />);
await user.click(await screen.findByRole('button', { name: '排行' }));
await waitFor(() => {
expect(document.getElementById('platform-tab-panel-category')).toBeTruthy();
});
await waitFor(() => {
const rankingPanel = getPlatformTabPanel('category');
expect(
within(rankingPanel).getAllByText('星桥机关').length,
).toBeGreaterThan(0);
});
const rankingPanel = getPlatformTabPanel('category');
await user.click(
within(rankingPanel).getByRole('button', {
name: //u,
}),
);
await user.click(await screen.findByRole('button', { name: '启动' }));
expect(await screen.findByTestId('puzzle-board')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回上一页' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: '启动' })).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: '返回' }));
await waitFor(() => {
const returnedRankingPanel = getPlatformTabPanel('category');
expect(returnedRankingPanel.getAttribute('aria-hidden')).toBe('false');
expect(
within(returnedRankingPanel).getAllByText('星桥机关').length,
).toBeGreaterThan(0);
});
});
test('selecting RPG creation while logged out routes through requireAuth', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
render(
<TestWrapper
authValue={createAuthValue({
user: null,
openLoginModal: () => {},
requireAuth,
})}
/>,
);
await openCreationHub(user);
const rpgButton = await screen.findByRole('button', { name: //u });
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
await user.click(rpgButton);
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(createRpgCreationSession).not.toHaveBeenCalled();
});
test('restoring an agent workspace while logged out opens login modal before loading the protected session', async () => {
const openLoginModal = vi.fn();
window.history.replaceState(
null,
'',
'/?customWorldSessionId=custom-world-agent-session-1',
);
render(
<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('new creation entry maps raw bearer token errors to user-facing auth copy', async () => {
const user = userEvent.setup();
vi.mocked(createRpgCreationSession).mockRejectedValueOnce(
new ApiClientError({
message: '缺少 Authorization Bearer Token',
status: 401,
code: 'UNAUTHORIZED',
}),
);
render(<TestWrapper withAuth />);
await openCreationHub(user);
const rpgButton = screen.getByRole('button', { name: //u });
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
await user.click(rpgButton);
expect(listPuzzleWorks).toHaveBeenCalled();
expect(createRpgCreationSession).toHaveBeenCalledTimes(1);
expect(
await within(getPlatformTabPanel('create')).findByText(
'当前登录状态已失效,请重新登录后继续。',
),
).toBeTruthy();
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
});
test('hidden big fish creation entry does not render in platform create hub', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(createBigFishCreationSession).not.toHaveBeenCalled();
});
test('puzzle creation timeout exits busy state and shows a readable error', async () => {
const user = userEvent.setup();
vi.mocked(createPuzzleAgentSession).mockRejectedValueOnce(
Object.assign(new Error('请求超时15000ms'), {
name: 'TimeoutError',
}),
);
render(<TestWrapper withAuth />);
await openCreationHub(user);
const button = screen.getByRole('button', { name: /.*/u });
await user.click(button);
await waitFor(() => {
expect(
screen.getAllByText(
'开启拼图创作工作台超时,请确认运行时后端已启动后重试。',
).length,
).toBeGreaterThan(0);
});
expect(button as HTMLButtonElement).toHaveProperty('disabled', false);
expect(screen.queryByText(//u)).toBeNull();
});
test('puzzle draft card restores the bound agent session and opens the result view', async () => {
const user = userEvent.setup();
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [
{
workId: 'puzzle-work-session-1',
profileId: 'puzzle-profile-session-1',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-1',
authorDisplayName: '测试玩家',
levelName: '雨夜猫塔',
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
themeTags: ['雨夜', '猫咪', '遗迹'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: '2026-04-22T12:10:00.000Z',
publishedAt: null,
playCount: 0,
likeCount: 0,
publishReady: false,
},
],
});
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
await waitFor(() => {
expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1');
});
expect(await screen.findByText('拼图结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
expect(
await screen.findByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
).toBeTruthy();
expect(screen.queryByText('拼图玩法共创')).toBeNull();
});
test('published puzzle work card restores its source session for editing', async () => {
const user = userEvent.setup();
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [
{
workId: 'puzzle-work-session-1',
profileId: 'puzzle-profile-session-1',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-1',
authorDisplayName: '测试玩家',
levelName: '雨夜猫塔',
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
themeTags: ['雨夜', '猫咪', '遗迹'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T12:10:00.000Z',
publishedAt: '2026-04-25T12:10:00.000Z',
playCount: 8,
likeCount: 0,
publishReady: true,
},
],
});
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
await waitFor(() => {
expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1');
});
expect(getPuzzleGalleryDetail).not.toHaveBeenCalledWith(
'puzzle-profile-session-1',
);
expect(await screen.findByText('拼图结果页')).toBeTruthy();
expect(screen.getByDisplayValue('雨夜猫塔')).toBeTruthy();
});
test('formal puzzle next level uses backend run and leaderboard keeps frontend level snapshot', async () => {
const user = userEvent.setup();
const firstLevelLeaderboardEntries = [
{
rank: 1,
nickname: '测试玩家',
elapsedMs: 12_000,
isCurrentPlayer: true,
},
];
const firstLevel = buildClearedPuzzleRun({
runId: 'run-puzzle-profile-public-1',
entryProfileId: 'puzzle-profile-public-1',
profileId: 'puzzle-profile-public-1',
levelName: '雨夜猫塔',
levelIndex: 1,
elapsedMs: 12_000,
recommendedNextProfileId: 'puzzle-profile-public-2',
leaderboardEntries: firstLevelLeaderboardEntries,
});
const secondLevelBase = buildMockPuzzleRun(
'puzzle-profile-public-2',
'星桥机关',
);
const secondLevel: PuzzleRunSnapshot = {
...secondLevelBase,
runId: firstLevel.runId,
entryProfileId: firstLevel.entryProfileId,
currentLevelIndex: 2,
playedProfileIds: [
'puzzle-profile-public-1',
'puzzle-profile-public-2',
],
currentLevel: {
...secondLevelBase.currentLevel!,
runId: firstLevel.runId,
levelIndex: 2,
startedAtMs: Date.now(),
},
};
const clearedSecondLevel = buildClearedPuzzleRun({
runId: firstLevel.runId,
entryProfileId: firstLevel.entryProfileId,
profileId: 'puzzle-profile-public-2',
levelName: '星桥机关',
levelIndex: 2,
elapsedMs: 18_000,
});
const serviceLeaderboardRun = buildClearedPuzzleRun({
runId: firstLevel.runId,
entryProfileId: firstLevel.entryProfileId,
profileId: 'puzzle-profile-public-1',
levelName: '雨夜猫塔',
levelIndex: 1,
elapsedMs: 18_000,
recommendedNextProfileId: 'puzzle-profile-public-2',
});
const leaderboardEntries = [
{
rank: 1,
nickname: '测试玩家',
elapsedMs: 18_000,
isCurrentPlayer: true,
},
];
vi.mocked(startPuzzleRun).mockResolvedValue({ run: firstLevel });
vi.mocked(advancePuzzleNextLevel).mockResolvedValue({ run: secondLevel });
vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({
run: {
...serviceLeaderboardRun,
leaderboardEntries,
currentLevel: {
...serviceLeaderboardRun.currentLevel!,
leaderboardEntries,
},
},
});
vi.mocked(dragLocalPuzzlePiece).mockReturnValue(clearedSecondLevel);
vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedSecondLevel);
const puzzleWork: PuzzleWorkSummary = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: null,
authorDisplayName: '拼图作者',
levelName: '雨夜猫塔',
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
themeTags: ['雨夜', '猫咪', '遗迹'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T12:10:00.000Z',
publishedAt: '2026-04-25T12:10:00.000Z',
playCount: 8,
likeCount: 0,
publishReady: true,
};
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [puzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: puzzleWork,
});
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'PZ-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
await user.click(await screen.findByRole('button', { name: '启动' }));
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith({
levelId: null,
profileId: 'puzzle-profile-public-1',
});
});
await user.click(
await screen.findByRole('button', { name: '下一关' }, { timeout: 3000 }),
);
await waitFor(() => {
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(firstLevel.runId);
});
expect(advanceLocalPuzzleNextLevel).not.toHaveBeenCalled();
expect((await screen.findAllByText('星桥机关')).length).toBeGreaterThan(0);
await user.click(document.querySelector('[data-piece-id="piece-0"]')!);
await user.click(document.querySelector('[data-piece-id="piece-1"]')!);
await waitFor(() => {
expect(submitPuzzleLeaderboard).toHaveBeenCalledWith(firstLevel.runId, {
profileId: 'puzzle-profile-public-2',
gridSize: 3,
elapsedMs: 18_000,
nickname: '测试玩家',
});
});
expect(
await screen.findByRole('dialog', { name: '通关完成' }, { timeout: 3000 }),
).toBeTruthy();
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
expect(screen.getByText('测试玩家')).toBeTruthy();
});
test('public code search opens a published puzzle by PZ code', async () => {
const user = userEvent.setup();
const puzzleWork: PuzzleWorkSummary = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: null,
authorDisplayName: '拼图作者',
levelName: '雨夜猫塔',
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
themeTags: ['雨夜', '猫咪', '遗迹'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T12:10:00.000Z',
publishedAt: '2026-04-25T12:10:00.000Z',
playCount: 8,
likeCount: 0,
publishReady: true,
};
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [puzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: puzzleWork,
});
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'PZ-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
await waitFor(() => {
expect(getPuzzleGalleryDetail).toHaveBeenCalledWith(
'puzzle-profile-public-1',
);
});
expect(await screen.findByText('详情')).toBeTruthy();
expect(screen.getByText('雨夜猫塔')).toBeTruthy();
expect(screen.getByRole('button', { name: '启动' })).toBeTruthy();
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
});
test('public code search opens a published big fish work by BF code', async () => {
const user = userEvent.setup();
const bigFishWork: BigFishWorkSummary = {
workId: 'big-fish-work-public-1',
sourceSessionId: 'big-fish-session-public-1',
ownerUserId: 'user-2',
authorDisplayName: '大鱼作者',
title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化',
summary: '从微光孢子一路吞并成长到深海巨鲲。',
coverImageSrc: null,
status: 'published',
updatedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
levelCount: 8,
levelMainImageReadyCount: 8,
levelMotionReadyCount: 16,
backgroundReady: true,
};
vi.mocked(listBigFishGallery).mockResolvedValue({
items: [bigFishWork],
});
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'BF-NPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
expect(await screen.findByText('详情')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '启动' }));
await waitFor(() => {
expect(startLocalBigFishRuntimeRun).toHaveBeenCalledWith({
work: expect.objectContaining({
sourceSessionId: 'big-fish-session-public-1',
}),
});
});
expect(await screen.findByText('Lv.1/8 · 进行中')).toBeTruthy();
expect(getBigFishCreationSession).not.toHaveBeenCalledWith(
'big-fish-session-public-1',
);
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
});
test('public code search opens a published Match3D work by M3 code and starts runtime', async () => {
const user = userEvent.setup();
const match3dWork: Match3DWorkSummary = {
workId: 'match3d-work-public-1',
profileId: 'match3d-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: 'match3d-session-public-1',
gameName: '水果抓大鹅',
themeText: '水果消除',
summary: '把圆形空间里的水果全部消除。',
tags: ['水果', '消除'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 4,
difficulty: 5,
publicationStatus: 'published',
playCount: 3,
updatedAt: '2026-04-25T10:30:00.000Z',
publishedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
};
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [match3dWork],
});
vi.mocked(startMatch3DRun).mockResolvedValue({
run: buildMockMatch3DRun(match3dWork.profileId),
});
render(<TestWrapper withAuth />);
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 openCreationHub(user);
expect(await screen.findByText('失败中的潮雾列岛')).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(screen.queryByText(/Agent/u)).toBeNull();
});
test('existing draft sessions open result page refinement instead of agent dialog', async () => {
const user = userEvent.setup();
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-draft-foundation-1',
type: 'draft_foundation',
status: 'completed',
phaseLabel: '世界底稿已生成',
phaseDetail: '第一版世界底稿和 4 张草稿卡已经整理完成。',
progress: 100,
error: null,
});
vi.mocked(getRpgCreationSession).mockResolvedValue(compiledAgentDraftSession);
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(compiledAgentDraftSession),
);
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: 'draft:custom-world-agent-session-1',
sourceType: 'agent_session',
status: 'draft',
title: '潮雾列岛',
subtitle: '待完善草稿',
summary: '玩家是失职返乡的守灯人。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: null,
stage: 'object_refining',
stageLabel: '待完善草稿',
playableNpcCount: 3,
landmarkCount: 4,
roleVisualReadyCount: 1,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: '沈砺 · 主图已生成',
sessionId: 'custom-world-agent-session-1',
profileId: null,
canResume: true,
canEnterWorld: false,
},
]);
render(<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 openCreationHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.getByRole('button', { name: '作品测试' })).toBeTruthy();
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
});
expect(screen.queryByText(/ 4 /u)).toBeNull();
const actionButton = screen.getByRole('button', {
name: '发布',
});
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
});
test('agent draft result back button returns to creation hub without syncing result profile', async () => {
const user = userEvent.setup();
const resultSession = {
...mockSession,
stage: 'object_refining' as const,
creatorIntent: {
sourceMode: 'card',
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
themeKeywords: ['海雾', '旧航路'],
toneDirectives: ['压抑', '悬疑'],
openingSituation: '首夜就有陌生船只闯入禁航区。',
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: ['会移动的海雾'],
forbiddenDirectives: [],
rawSettingText: '',
},
draftProfile: {
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '旧航路引路人',
role: '关键同行者',
publicIdentity: '最熟悉旧航路的人。',
publicMask: '看上去像可靠旧友。',
currentPressure: '他必须在两股势力间站队。',
hiddenHook: '暗中替沉船商盟引路。',
relationToPlayer: '旧友兼潜在背叛者',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
title: '守灯会值夜人',
role: '场景关键角色',
publicIdentity: '负责夜间巡灯与封锁。',
publicMask: '对外一直冷静克制。',
currentPressure: '她知道更多禁航区真相。',
hiddenHook: '曾亲眼见过失控海雾吞船。',
relationToPlayer: '最早愿意交换线索的人',
threadIds: ['thread-1'],
summary: '她总像比所有人更早知道海雾会往哪一侧压下来。',
},
],
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
purpose: '观察雾潮与往来船只',
mood: '潮湿、压抑、风声不止',
importance: '开局核心场景',
characterIds: ['story-1'],
threadIds: ['thread-1'],
summary: '旧灯塔是整片群岛最先看见异动的地方。',
},
],
factions: [],
threads: [],
chapters: [],
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
openingSituation: '首夜就有陌生船只闯入禁航区。',
iconicElements: ['会移动的海雾'],
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
legacyResultProfile: {
id: 'agent-draft-custom-world-agent-session-1',
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·同步后',
subtitle: '旧灯塔与失控航路',
summary: '同步后的结果页快照已经回写到 session。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
generationMode: 'full',
generationStatus: 'complete',
},
},
draftCards: [
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
status: 'warning',
linkedIds: ['playable-1', 'story-1', 'landmark-1'],
warningCount: 0,
},
],
resultPreview: {
source: 'session_preview' as const,
preview: {
id: 'agent-draft-custom-world-agent-session-1',
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·同步后',
subtitle: '旧灯塔与失控航路',
summary: '同步后的结果页快照已经回写到 session。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
generationMode: 'full',
generationStatus: 'complete',
sessionId: 'custom-world-agent-session-1',
},
generatedAt: '2026-04-20T12:00:00.000Z',
qualityFindings: [],
blockers: [],
publishReady: false,
canEnterWorld: false,
},
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getRpgCreationSession).mockResolvedValue(resultSession);
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(resultSession),
);
mockExistingRpgDraftShelf({
summary: '同步后的结果页快照已经回写到 session。',
});
render(<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.getByText('角色扮演')).toBeTruthy();
});
expect(
vi
.mocked(executeRpgCreationAction)
.mock.calls.some(
([sessionId, payload]) =>
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
),
).toBe(false);
expect(screen.queryByText('世界档案')).toBeNull();
});
test('agent draft result auto-save syncs result profile before persisting backend result view', async () => {
const user = userEvent.setup();
const syncedSession = {
...mockSession,
stage: 'object_refining' as const,
creatorIntent: {
sourceMode: 'card',
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
themeKeywords: ['海雾', '旧航路'],
toneDirectives: ['压抑', '悬疑'],
openingSituation: '首夜就有陌生船只闯入禁航区。',
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: ['会移动的海雾'],
forbiddenDirectives: [],
rawSettingText: '',
},
draftProfile: {
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [],
storyNpcs: [],
landmarks: [],
factions: [],
threads: [],
chapters: [],
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
openingSituation: '首夜就有陌生船只闯入禁航区。',
iconicElements: ['会移动的海雾'],
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
legacyResultProfile: {
id: 'agent-draft-custom-world-agent-session-1',
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·session最新版',
subtitle: '旧灯塔与失控航路',
summary: '作品库应该保存这份同步后的最新快照。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
generationMode: 'full',
generationStatus: 'complete',
},
},
draftCards: [
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
status: 'warning',
linkedIds: [],
warningCount: 0,
},
],
resultPreview: {
source: 'session_preview' as const,
preview: {
id: 'agent-draft-custom-world-agent-session-1',
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·session最新版',
subtitle: '旧灯塔与失控航路',
summary: '作品库应该保存这份同步后的最新快照。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
generationMode: 'full',
generationStatus: 'complete',
sessionId: 'custom-world-agent-session-1',
},
generatedAt: '2026-04-20T12:00:00.000Z',
qualityFindings: [],
blockers: [],
publishReady: false,
canEnterWorld: false,
},
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getRpgCreationSession).mockResolvedValue(syncedSession);
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(syncedSession),
);
mockExistingRpgDraftShelf({
summary: '作品库应该保存这份同步后的最新快照。',
});
vi.mocked(executeRpgCreationAction).mockImplementation(
async (_, payload) => ({
operation: {
operationId:
payload.action === 'sync_result_profile'
? 'operation-sync-result-profile-1'
: 'operation-draft-foundation-1',
type: payload.action,
status: 'queued',
phaseLabel: '已接收请求',
phaseDetail:
payload.action === 'sync_result_profile'
? '正在同步结果页档案。'
: '正在准备生成世界底稿。',
progress: 10,
error: null,
},
}),
);
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-sync-result-profile-1',
type: 'sync_result_profile',
status: 'completed',
phaseLabel: '结果页档案已同步',
phaseDetail: '服务端已根据最新结果页档案刷新会话预览。',
progress: 100,
error: null,
});
render(<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 with save archives default into the saves tab', async () => {
vi.mocked(listProfileSaveArchives).mockResolvedValue([
{
worldKey: 'custom:world-1',
ownerUserId: null,
profileId: 'world-1',
worldType: 'CUSTOM',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '回到旧灯塔继续推进调查。',
coverImageSrc: null,
lastPlayedAt: '2026-04-19T12:00:00.000Z',
},
]);
render(<TestWrapper withAuth />);
expect((await screen.findAllByText('全部存档')).length).toBeGreaterThan(0);
expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0);
expect(screen.getAllByText('最近更新时间排序').length).toBeGreaterThan(0);
expect(screen.queryByText('SAVE ARCHIVE')).toBeNull();
expect(screen.queryByText('ARCHIVE')).toBeNull();
expect(screen.queryByText('最近存档')).toBeNull();
});
test('puzzle save archive highlights work title and level subtitle', async () => {
vi.mocked(listProfileSaveArchives).mockResolvedValue([
{
worldKey: 'puzzle:puzzle-profile-1',
ownerUserId: 'user-2',
profileId: 'puzzle-profile-1',
worldType: 'PUZZLE',
worldName: '雨夜猫塔',
subtitle: '第 2 关 · 星桥机关',
summaryText: '拼图进行中',
coverImageSrc: '/generated-puzzle-assets/puzzle-1/level-2.png',
lastPlayedAt: '2026-04-19T12:00:00.000Z',
},
]);
render(<TestWrapper withAuth />);
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.findByText('角色扮演')).toBeTruthy();
resolveGalleryRequest([]);
await waitFor(() => {
expect(
within(getPlatformTabPanel('create')).getByText('角色扮演'),
).toBeTruthy();
});
expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe(
'false',
);
expect(getPlatformTabPanel('home').getAttribute('aria-hidden')).toBe('true');
});
test('save tab can resume a selected archive directly into the game', async () => {
const user = userEvent.setup();
const handleContinueGame = vi.fn();
vi.mocked(listProfileSaveArchives).mockResolvedValue([
{
worldKey: 'custom:world-1',
ownerUserId: null,
profileId: 'world-1',
worldType: 'CUSTOM',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '回到旧灯塔继续推进调查。',
coverImageSrc: null,
lastPlayedAt: '2026-04-19T12:00:00.000Z',
},
]);
vi.mocked(resumeProfileSaveArchive).mockResolvedValue({
entry: {
worldKey: 'custom:world-1',
ownerUserId: null,
profileId: 'world-1',
worldType: 'CUSTOM',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '回到旧灯塔继续推进调查。',
coverImageSrc: null,
lastPlayedAt: '2026-04-19T12:00:00.000Z',
},
snapshot: {
version: 2,
savedAt: '2026-04-19T12:00:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {
worldType: 'CUSTOM',
},
} as HydratedSavedGameSnapshot,
});
render(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
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 openCreationHub(user);
await user.click(await screen.findByRole('button', { name: //u }));
expect(await screen.findByText('详情')).toBeTruthy();
expect(screen.getByText('潮雾列岛')).toBeTruthy();
expect(screen.getByRole('button', { name: '启动' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '删除作品' })).toBeNull();
expect(deleteRpgEntryWorldProfile).not.toHaveBeenCalled();
});
test('creation hub published work enters existing detail view', async () => {
const user = userEvent.setup();
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: 'published:world-public-1',
sourceType: 'published_profile',
status: 'published',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '已经发布的群岛世界作品。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: '2026-04-20T10:00:00.000Z',
stage: null,
stageLabel: '已发布',
playableNpcCount: 3,
landmarkCount: 4,
roleVisualReadyCount: 1,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: null,
profileId: 'world-public-1',
canResume: false,
canEnterWorld: true,
},
]);
vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([
{
ownerUserId: 'user-1',
profileId: 'world-public-1',
publicWorkCode: 'work-public-1',
authorPublicUserCode: 'user-1',
profile: {
id: 'world-public-1',
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '已经发布的群岛世界作品。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清群岛旧案。',
majorFactions: ['守灯会'],
coreConflicts: ['假航灯正在扰乱航线'],
playableNpcs: [],
storyNpcs: [],
landmarks: [],
} as never,
visibility: 'published',
publishedAt: '2026-04-20T10:00:00.000Z',
updatedAt: '2026-04-20T10:00:00.000Z',
authorDisplayName: '测试玩家',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '已经发布的群岛世界作品。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 3,
landmarkCount: 4,
likeCount: 0,
},
]);
render(<TestWrapper withAuth />);
await openCreationHub(user);
await user.click(await screen.findByRole('button', { name: //u }));
expect(await screen.findByText('详情')).toBeTruthy();
expect(screen.getByRole('button', { name: '启动' })).toBeTruthy();
expect(screen.getByText('潮雾列岛')).toBeTruthy();
});
test('creation hub published work experience button enters world directly', async () => {
const user = userEvent.setup();
const handleCustomWorldSelect = vi.fn();
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: 'published:world-experience-1',
sourceType: 'published_profile',
status: 'published',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '已经发布的群岛世界作品。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: '2026-04-20T10:00:00.000Z',
stage: null,
stageLabel: '已发布',
playableNpcCount: 3,
landmarkCount: 4,
roleVisualReadyCount: 1,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: null,
profileId: 'world-experience-1',
canResume: false,
canEnterWorld: true,
},
]);
vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([
{
ownerUserId: 'user-1',
profileId: 'world-experience-1',
publicWorkCode: 'work-experience-1',
authorPublicUserCode: 'user-1',
profile: {
id: 'world-experience-1',
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '已经发布的群岛世界作品。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清群岛旧案。',
majorFactions: ['守灯会'],
coreConflicts: ['假航灯正在扰乱航线'],
playableNpcs: [],
storyNpcs: [],
landmarks: [],
} as never,
visibility: 'published',
publishedAt: '2026-04-20T10:00:00.000Z',
updatedAt: '2026-04-20T10:00:00.000Z',
authorDisplayName: '测试玩家',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '已经发布的群岛世界作品。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 3,
landmarkCount: 4,
likeCount: 0,
},
]);
render(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
await openCreationHub(user);
await user.click(await screen.findByRole('button', { name: //u }));
await user.click(await screen.findByRole('button', { name: '启动' }));
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({
id: 'world-experience-1',
name: '潮雾列岛',
}),
);
});
expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1);
});
test('creation hub published work card no longer exposes direct delete action', async () => {
const user = userEvent.setup();
const publishedWork = {
workId: 'published:world-card-delete-1',
sourceType: 'published_profile' as const,
status: 'published' as const,
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '用于测试卡片删除流程的作品。',
coverImageSrc: null,
coverRenderMode: 'image' as const,
coverCharacterImageSrcs: [],
updatedAt: '2026-04-16T12:00:00.000Z',
publishedAt: '2026-04-16T12:00:00.000Z',
stage: null,
stageLabel: '已发布',
playableNpcCount: 0,
landmarkCount: 0,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: null,
profileId: 'world-card-delete-1',
canResume: false,
canEnterWorld: true,
};
const publishedLibraryEntry = {
ownerUserId: 'user-1',
profileId: 'world-card-delete-1',
publicWorkCode: 'work-card-delete-1',
authorPublicUserCode: 'user-1',
profile: {
id: 'world-card-delete-1',
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '用于测试卡片删除流程的作品。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清旧案。',
majorFactions: ['守灯会'],
coreConflicts: ['雾潮正在逼近港口'],
playableNpcs: [],
storyNpcs: [],
landmarks: [],
} as never,
visibility: 'published' as const,
publishedAt: '2026-04-16T12:00:00.000Z',
updatedAt: '2026-04-16T12:00:00.000Z',
authorDisplayName: '测试玩家',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '用于测试卡片删除流程的作品。',
coverImageSrc: null,
themeMode: 'tide' as const,
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
};
vi.mocked(listRpgCreationWorks)
.mockResolvedValueOnce([publishedWork])
.mockResolvedValue([]);
vi.mocked(listRpgEntryWorldLibrary)
.mockResolvedValueOnce([publishedLibraryEntry])
.mockResolvedValue([]);
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
expect(deleteRpgEntryWorldProfile).not.toHaveBeenCalled();
});