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