5818 lines
181 KiB
TypeScript
5818 lines
181 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 { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||
|
||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||
import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent';
|
||
import type {
|
||
CustomWorldAgentSessionSnapshot,
|
||
CustomWorldWorkSummary,
|
||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||
import type {
|
||
Match3DAgentSessionSnapshot,
|
||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||
import type {
|
||
PuzzleAnchorPack,
|
||
PuzzleResultDraft,
|
||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||
import type {
|
||
PuzzleAgentSessionSnapshot,
|
||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||
import type {
|
||
CustomWorldGalleryCard,
|
||
CustomWorldLibraryEntry,
|
||
} from '../../../packages/shared/src/contracts/runtime';
|
||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||
import { ApiClientError } from '../../services/apiClient';
|
||
import type { AuthUser } from '../../services/authService';
|
||
import {
|
||
createBigFishCreationSession,
|
||
getBigFishCreationSession,
|
||
} from '../../services/big-fish-creation';
|
||
import { listBigFishGallery } from '../../services/big-fish-gallery';
|
||
import {
|
||
recordBigFishPlay,
|
||
startBigFishRun,
|
||
submitBigFishInput,
|
||
} from '../../services/big-fish-runtime';
|
||
import { listBigFishWorks } from '../../services/big-fish-works';
|
||
import {
|
||
cancelCreativeAgentSession,
|
||
confirmCreativePuzzleTemplate,
|
||
createCreativeAgentSession,
|
||
streamCreativeAgentMessage,
|
||
streamCreativeDraftEdit,
|
||
} from '../../services/creative-agent';
|
||
import { match3dCreationClient } from '../../services/match3d-creation';
|
||
import {
|
||
clickMatch3DItem,
|
||
finishMatch3DTimeUp,
|
||
restartMatch3DRun,
|
||
startMatch3DRun,
|
||
stopMatch3DRun,
|
||
} from '../../services/match3d-runtime';
|
||
import {
|
||
deleteMatch3DWork,
|
||
getMatch3DWorkDetail,
|
||
listMatch3DGallery,
|
||
listMatch3DWorks,
|
||
} from '../../services/match3d-works';
|
||
import {
|
||
createPuzzleAgentSession,
|
||
getPuzzleAgentSession,
|
||
} from '../../services/puzzle-agent';
|
||
import {
|
||
getPuzzleGalleryDetail,
|
||
listPuzzleGallery,
|
||
remixPuzzleGalleryWork,
|
||
} from '../../services/puzzle-gallery';
|
||
import {
|
||
generatePuzzleOnboardingWork,
|
||
savePuzzleOnboardingWork,
|
||
} from '../../services/puzzle-onboarding';
|
||
import {
|
||
advancePuzzleNextLevel,
|
||
dragPuzzlePieceOrGroup,
|
||
getPuzzleRun,
|
||
startPuzzleRun,
|
||
submitPuzzleLeaderboard,
|
||
swapPuzzlePieces,
|
||
updatePuzzleRunPause,
|
||
usePuzzleRuntimeProp,
|
||
} from '../../services/puzzle-runtime';
|
||
import {
|
||
dragLocalPuzzlePiece,
|
||
swapLocalPuzzlePieces,
|
||
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
|
||
import { listPuzzleWorks } from '../../services/puzzle-works';
|
||
import {
|
||
createRpgCreationSession,
|
||
executeRpgCreationAction,
|
||
getRpgCreationOperation,
|
||
getRpgCreationResultView,
|
||
getRpgCreationSession,
|
||
listRpgCreationWorks,
|
||
streamRpgCreationMessage,
|
||
upsertRpgWorldProfile,
|
||
} from '../../services/rpg-creation';
|
||
import {
|
||
clearRpgProfileBrowseHistory as clearProfileBrowseHistory,
|
||
getRpgEntryWorldGalleryDetail,
|
||
getRpgProfileDashboard as getProfileDashboard,
|
||
listRpgEntryWorldGallery,
|
||
listRpgEntryWorldLibrary,
|
||
listRpgProfileBrowseHistory as listProfileBrowseHistory,
|
||
listRpgProfileSaveArchives as listProfileSaveArchives,
|
||
resumeRpgProfileSaveArchive as resumeProfileSaveArchive,
|
||
upsertRpgProfileBrowseHistory as upsertProfileBrowseHistory,
|
||
} from '../../services/rpg-entry';
|
||
import {
|
||
deleteRpgEntryWorldProfile,
|
||
getRpgEntryWorldGalleryDetail as getRpgEntryWorldGalleryDetailFromClient,
|
||
getRpgEntryWorldGalleryDetailByCode,
|
||
recordRpgEntryWorldGalleryPlay,
|
||
remixRpgEntryWorldGallery,
|
||
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||
import { squareHoleCreationClient } from '../../services/square-hole-creation';
|
||
import {
|
||
dropSquareHoleShape,
|
||
finishSquareHoleTimeUp,
|
||
restartSquareHoleRun,
|
||
startSquareHoleRun,
|
||
stopSquareHoleRun,
|
||
} from '../../services/square-hole-runtime';
|
||
import {
|
||
deleteSquareHoleWork,
|
||
getSquareHoleWorkDetail,
|
||
listSquareHoleGallery,
|
||
listSquareHoleWorks,
|
||
} from '../../services/square-hole-works';
|
||
import { type CustomWorldProfile, WorldType } from '../../types';
|
||
import {
|
||
AuthUiContext,
|
||
type PlatformSettingsSection,
|
||
} from '../auth/AuthUiContext';
|
||
import {
|
||
RpgEntryFlowShell,
|
||
type RpgEntryFlowShellProps,
|
||
type SelectionStage,
|
||
} from './RpgEntryFlowShell';
|
||
|
||
async function clickFirstButtonByName(
|
||
user: ReturnType<typeof userEvent.setup>,
|
||
name: string | RegExp,
|
||
) {
|
||
const buttons = screen.getAllByRole('button', { name });
|
||
await user.click(buttons[0]!);
|
||
}
|
||
|
||
async function clickFirstAsyncButtonByName(
|
||
user: ReturnType<typeof userEvent.setup>,
|
||
name: string | RegExp,
|
||
) {
|
||
const buttons = await screen.findAllByRole('button', { name });
|
||
await user.click(buttons[0]!);
|
||
}
|
||
|
||
async function openCreateTemplateHub(user: ReturnType<typeof userEvent.setup>) {
|
||
await clickFirstButtonByName(user, '创作');
|
||
expect(
|
||
await screen.findByRole('tablist', { name: '选择模板' }),
|
||
).toBeTruthy();
|
||
expect(screen.getByRole('tab', { name: '拼图' })).toBeTruthy();
|
||
expect(screen.getByText('拼图工作区:missing-session')).toBeTruthy();
|
||
}
|
||
|
||
async function openDraftHub(user: ReturnType<typeof userEvent.setup>) {
|
||
await clickFirstButtonByName(user, '草稿');
|
||
const panel = getPlatformTabPanel('saves');
|
||
await waitFor(() => {
|
||
expect(panel.getAttribute('aria-hidden')).toBe('false');
|
||
});
|
||
expect(
|
||
await within(panel).findByRole('button', { name: /全部/u }),
|
||
).toBeTruthy();
|
||
}
|
||
|
||
async function openDiscoverHub(user: ReturnType<typeof userEvent.setup>) {
|
||
await clickFirstButtonByName(user, '发现');
|
||
const panel = getPlatformTabPanel('category');
|
||
await waitFor(() => {
|
||
expect(panel.getAttribute('aria-hidden')).toBe('false');
|
||
});
|
||
expect(
|
||
await within(panel).findByPlaceholderText(
|
||
'搜索作品号、名称、作者、描述',
|
||
),
|
||
).toBeTruthy();
|
||
return panel;
|
||
}
|
||
|
||
async function openProfilePlayedWorks(user: ReturnType<typeof userEvent.setup>) {
|
||
await clickFirstButtonByName(user, '我的');
|
||
await user.click(await screen.findByRole('button', { name: /玩过/u }));
|
||
expect(await screen.findByText('可继续')).toBeTruthy();
|
||
}
|
||
|
||
async function openExistingRpgDraft(
|
||
user: ReturnType<typeof userEvent.setup>,
|
||
actionName: string | RegExp = /继续(?:完善|创作)/u,
|
||
) {
|
||
await openDraftHub(user);
|
||
await user.click(await screen.findByRole('button', { name: actionName }));
|
||
}
|
||
|
||
const ISOLATED_RUNTIME_AUTH_OPTIONS = {
|
||
authImpact: 'local',
|
||
skipRefresh: true,
|
||
notifyAuthStateChange: false,
|
||
clearAuthOnUnauthorized: false,
|
||
};
|
||
|
||
function getPlatformTabPanel(tab: string) {
|
||
const panel = document.getElementById(`platform-tab-panel-${tab}`);
|
||
if (!panel) {
|
||
throw new Error(`Missing platform tab panel: ${tab}`);
|
||
}
|
||
|
||
return panel;
|
||
}
|
||
|
||
const rpgCreationServiceMocks = vi.hoisted(() => ({
|
||
createRpgCreationSession: vi.fn(),
|
||
deleteRpgCreationAgentSession: vi.fn(),
|
||
executeRpgCreationAction: vi.fn(),
|
||
getRpgCreationOperation: vi.fn(),
|
||
getRpgCreationResultView: vi.fn(),
|
||
getRpgCreationSession: vi.fn(),
|
||
listRpgCreationWorks: vi.fn(),
|
||
streamRpgCreationMessage: vi.fn(),
|
||
upsertRpgWorldProfile: vi.fn(),
|
||
}));
|
||
|
||
const rpgEntryLibraryServiceMocks = vi.hoisted(() => ({
|
||
deleteRpgEntryWorldProfile: vi.fn(),
|
||
getRpgEntryWorldGalleryDetail: vi.fn(),
|
||
getRpgEntryWorldGalleryDetailByCode: vi.fn(),
|
||
getRpgEntryWorldLibraryDetail: vi.fn(),
|
||
listRpgEntryWorldGallery: vi.fn(),
|
||
listRpgEntryWorldLibrary: vi.fn(),
|
||
publishRpgEntryWorldProfile: vi.fn(),
|
||
recordRpgEntryWorldGalleryPlay: vi.fn(),
|
||
remixRpgEntryWorldGallery: vi.fn(),
|
||
unpublishRpgEntryWorldProfile: vi.fn(),
|
||
upsertRpgEntryWorldProfile: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../services/rpg-creation', () => ({
|
||
...rpgCreationServiceMocks,
|
||
}));
|
||
|
||
vi.mock('../../services/rpg-creation/index', () => ({
|
||
...rpgCreationServiceMocks,
|
||
}));
|
||
|
||
vi.mock('../../services/rpg-entry', () => ({
|
||
clearRpgProfileBrowseHistory: vi.fn(),
|
||
deleteRpgEntryWorldProfile: rpgEntryLibraryServiceMocks.deleteRpgEntryWorldProfile,
|
||
getRpgEntryWorldGalleryDetail:
|
||
rpgEntryLibraryServiceMocks.getRpgEntryWorldGalleryDetail,
|
||
getRpgProfileDashboard: vi.fn(),
|
||
listRpgEntryWorldGallery:
|
||
rpgEntryLibraryServiceMocks.listRpgEntryWorldGallery,
|
||
listRpgEntryWorldLibrary:
|
||
rpgEntryLibraryServiceMocks.listRpgEntryWorldLibrary,
|
||
listRpgProfileBrowseHistory: vi.fn(),
|
||
listRpgProfileSaveArchives: vi.fn(),
|
||
publishRpgEntryWorldProfile:
|
||
rpgEntryLibraryServiceMocks.publishRpgEntryWorldProfile,
|
||
recordRpgEntryWorldGalleryPlay:
|
||
rpgEntryLibraryServiceMocks.recordRpgEntryWorldGalleryPlay,
|
||
resumeRpgProfileSaveArchive: vi.fn(),
|
||
remixRpgEntryWorldGallery:
|
||
rpgEntryLibraryServiceMocks.remixRpgEntryWorldGallery,
|
||
syncRpgProfileBrowseHistory: vi.fn(),
|
||
unpublishRpgEntryWorldProfile:
|
||
rpgEntryLibraryServiceMocks.unpublishRpgEntryWorldProfile,
|
||
upsertRpgProfileBrowseHistory: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../services/puzzle-works', () => ({
|
||
listPuzzleWorks: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../services/puzzle-gallery', () => ({
|
||
getPuzzleGalleryDetail: vi.fn(),
|
||
listPuzzleGallery: vi.fn(),
|
||
remixPuzzleGalleryWork: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../services/puzzle-runtime', () => ({
|
||
advancePuzzleNextLevel: vi.fn(),
|
||
dragPuzzlePieceOrGroup: vi.fn(),
|
||
getPuzzleRun: vi.fn(),
|
||
startPuzzleRun: vi.fn(),
|
||
swapPuzzlePieces: vi.fn(),
|
||
submitPuzzleLeaderboard: vi.fn(),
|
||
updatePuzzleRunPause: vi.fn(),
|
||
usePuzzleRuntimeProp: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({
|
||
...rpgEntryLibraryServiceMocks,
|
||
}));
|
||
|
||
vi.mock('../../services/big-fish-creation', () => ({
|
||
createBigFishCreationSession: vi.fn(),
|
||
executeBigFishCreationAction: vi.fn(),
|
||
getBigFishCreationSession: vi.fn(),
|
||
streamBigFishCreationMessage: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../services/big-fish-works', () => ({
|
||
listBigFishWorks: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../services/big-fish-gallery', () => ({
|
||
listBigFishGallery: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../services/big-fish-runtime', () => ({
|
||
recordBigFishPlay: vi.fn().mockResolvedValue({ items: [] }),
|
||
startBigFishRun: vi.fn(),
|
||
submitBigFishInput: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../services/match3d-creation', () => ({
|
||
match3dCreationClient: {
|
||
createSession: vi.fn(),
|
||
executeAction: vi.fn(),
|
||
getSession: vi.fn(),
|
||
streamMessage: vi.fn(),
|
||
},
|
||
}));
|
||
|
||
vi.mock('../../services/match3d-works', () => ({
|
||
deleteMatch3DWork: vi.fn(),
|
||
getMatch3DWorkDetail: vi.fn(),
|
||
listMatch3DGallery: vi.fn(),
|
||
listMatch3DWorks: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../services/match3d-runtime', () => ({
|
||
clickMatch3DItem: vi.fn(),
|
||
finishMatch3DTimeUp: vi.fn(),
|
||
restartMatch3DRun: vi.fn(),
|
||
startMatch3DRun: vi.fn(),
|
||
stopMatch3DRun: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../services/square-hole-creation', () => ({
|
||
squareHoleCreationClient: {
|
||
createSession: vi.fn(),
|
||
executeAction: vi.fn(),
|
||
getSession: vi.fn(),
|
||
sendMessage: vi.fn(),
|
||
streamMessage: vi.fn(),
|
||
},
|
||
}));
|
||
|
||
vi.mock('../../services/square-hole-runtime', () => ({
|
||
dropSquareHoleShape: vi.fn(),
|
||
finishSquareHoleTimeUp: vi.fn(),
|
||
getSquareHoleRun: vi.fn(),
|
||
restartSquareHoleRun: vi.fn(),
|
||
startSquareHoleRun: vi.fn(),
|
||
stopSquareHoleRun: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../services/square-hole-works', () => ({
|
||
deleteSquareHoleWork: vi.fn(),
|
||
getSquareHoleWorkDetail: vi.fn(),
|
||
listSquareHoleGallery: vi.fn(),
|
||
listSquareHoleWorks: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../services/creative-agent', () => ({
|
||
cancelCreativeAgentSession: vi.fn(),
|
||
confirmCreativePuzzleTemplate: vi.fn(),
|
||
createCreativeAgentSession: vi.fn(),
|
||
streamCreativeAgentMessage: vi.fn(),
|
||
streamCreativeDraftEdit: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../services/puzzle-runtime/puzzleLocalRuntime', async () => {
|
||
const actual = await vi.importActual<
|
||
typeof import('../../services/puzzle-runtime/puzzleLocalRuntime')
|
||
>('../../services/puzzle-runtime/puzzleLocalRuntime');
|
||
|
||
return {
|
||
...actual,
|
||
dragLocalPuzzlePiece: vi.fn(actual.dragLocalPuzzlePiece),
|
||
swapLocalPuzzlePieces: vi.fn(actual.swapLocalPuzzlePieces),
|
||
};
|
||
});
|
||
|
||
vi.mock('../../services/puzzle-onboarding', () => ({
|
||
generatePuzzleOnboardingWork: vi.fn(),
|
||
savePuzzleOnboardingWork: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../services/puzzle-agent', () => ({
|
||
createPuzzleAgentSession: vi.fn(),
|
||
executePuzzleAgentAction: vi.fn(),
|
||
getPuzzleAgentSession: vi.fn(),
|
||
streamPuzzleAgentMessage: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({
|
||
PuzzleAgentWorkspace: ({
|
||
session,
|
||
isBusy,
|
||
error,
|
||
onBack,
|
||
onCreateFromForm,
|
||
}: {
|
||
session: { sessionId: string; messages: Array<{ text: string }> } | null;
|
||
isBusy?: boolean;
|
||
error?: string | null;
|
||
onBack: () => void;
|
||
onCreateFromForm?: (payload: {
|
||
seedText: string;
|
||
workTitle: string;
|
||
workDescription: string;
|
||
pictureDescription: string;
|
||
referenceImageSrc: string | null;
|
||
}) => void;
|
||
}) => (
|
||
<div className="puzzle-agent-workspace-mock">
|
||
<div>拼图工作区:{session?.sessionId ?? 'missing-session'}</div>
|
||
{session?.messages.map((message) => (
|
||
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
|
||
))}
|
||
{error ? <div>{error}</div> : null}
|
||
<button
|
||
type="button"
|
||
disabled={isBusy}
|
||
onClick={() => {
|
||
onCreateFromForm?.({
|
||
seedText: '暖灯猫街',
|
||
workTitle: '暖灯猫街',
|
||
workDescription: '一套雨夜猫街主题拼图。',
|
||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||
referenceImageSrc: null,
|
||
});
|
||
}}
|
||
>
|
||
生成草稿
|
||
</button>
|
||
<button type="button" onClick={onBack}>
|
||
返回
|
||
</button>
|
||
</div>
|
||
),
|
||
}));
|
||
|
||
vi.mock('../puzzle-result/PuzzleResultView', () => ({
|
||
PuzzleResultView: ({
|
||
session,
|
||
onBack,
|
||
}: {
|
||
session: { draft?: { levelName: string } | null };
|
||
onBack: () => void;
|
||
}) => (
|
||
<div className="puzzle-result-view-mock">
|
||
<div>拼图结果页</div>
|
||
<label>
|
||
关卡名
|
||
<input readOnly value={session.draft?.levelName ?? ''} />
|
||
</label>
|
||
<button type="button" onClick={onBack}>
|
||
返回
|
||
</button>
|
||
</div>
|
||
),
|
||
}));
|
||
|
||
vi.mock('../puzzle-gallery/PuzzleGalleryDetailView', () => ({
|
||
PuzzleGalleryDetailView: ({
|
||
item,
|
||
onBack,
|
||
onStartGame,
|
||
}: {
|
||
item: { levelName: string };
|
||
onBack: () => void;
|
||
onStartGame: () => void;
|
||
}) => (
|
||
<div className="puzzle-gallery-detail-view-mock">
|
||
<div>{item.levelName}</div>
|
||
<button type="button" onClick={onStartGame}>
|
||
进入第 1 关
|
||
</button>
|
||
<button type="button" onClick={onBack}>
|
||
返回
|
||
</button>
|
||
</div>
|
||
),
|
||
}));
|
||
|
||
vi.mock('../big-fish-creation/BigFishAgentWorkspace', () => ({
|
||
BigFishAgentWorkspace: ({
|
||
session,
|
||
}: {
|
||
session: { sessionId: string; messages: Array<{ text: string }> } | null;
|
||
}) => (
|
||
<div className="big-fish-agent-workspace-mock">
|
||
<div>大鱼吃小鱼工作区:{session?.sessionId ?? 'missing-session'}</div>
|
||
{session?.messages.map((message) => (
|
||
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
|
||
))}
|
||
</div>
|
||
),
|
||
}));
|
||
|
||
vi.mock('../big-fish-result/BigFishResultView', () => ({
|
||
BigFishResultView: ({
|
||
session,
|
||
onBack,
|
||
onExecuteAction,
|
||
}: {
|
||
session: { draft?: { title: string } | null };
|
||
onBack: () => void;
|
||
onExecuteAction: (payload: { action: string }) => void;
|
||
}) => (
|
||
<div className="big-fish-result-view-mock">
|
||
<div>大鱼吃小鱼结果页</div>
|
||
<div>{session.draft?.title ?? '缺少草稿标题'}</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
onExecuteAction({ action: 'big_fish_publish_game' });
|
||
}}
|
||
>
|
||
发布
|
||
</button>
|
||
<button type="button" onClick={onBack}>
|
||
返回
|
||
</button>
|
||
</div>
|
||
),
|
||
}));
|
||
|
||
vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
|
||
Match3DAgentWorkspace: ({
|
||
session,
|
||
}: {
|
||
session: { sessionId: string; messages: Array<{ text: string }> } | null;
|
||
}) => (
|
||
<div className="match3d-agent-workspace-mock">
|
||
<div>抓大鹅工作区:{session?.sessionId ?? 'missing-session'}</div>
|
||
{session?.messages.map((message) => (
|
||
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
|
||
))}
|
||
</div>
|
||
),
|
||
}));
|
||
|
||
vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
|
||
Match3DRuntimeShell: ({
|
||
run,
|
||
onBack,
|
||
}: {
|
||
run: Match3DRunSnapshot | null;
|
||
onBack: () => void;
|
||
}) => (
|
||
<div className="match3d-runtime-shell-mock">
|
||
<div>抓大鹅运行态:{run?.runId ?? 'missing-run'}</div>
|
||
<button type="button" onClick={onBack}>
|
||
返回
|
||
</button>
|
||
</div>
|
||
),
|
||
}));
|
||
|
||
vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
|
||
CustomWorldAgentWorkspace: ({
|
||
session,
|
||
onExecuteAction,
|
||
}: {
|
||
session: CustomWorldAgentSessionSnapshot | null;
|
||
onExecuteAction: (payload: { action: string }) => void;
|
||
}) => (
|
||
<div className="agent-workspace-mock">
|
||
Agent工作区:{session?.sessionId ?? 'missing-session'}
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
onExecuteAction({
|
||
action: 'draft_foundation',
|
||
});
|
||
}}
|
||
>
|
||
开始生成草稿
|
||
</button>
|
||
</div>
|
||
),
|
||
}));
|
||
|
||
const mockSession: CustomWorldAgentSessionSnapshot = {
|
||
sessionId: 'custom-world-agent-session-1',
|
||
currentTurn: 0,
|
||
anchorContent: {
|
||
worldPromise:
|
||
'被海雾吞没的旧航路群岛,灯塔与禁航令共同决定谁能穿过死潮,体验压抑、潮湿、悬疑。',
|
||
playerFantasy:
|
||
'玩家是被迫返乡的守灯人继承者,追查沉船夜与假航灯的关系,风险是失去家族最后一条可信航线。',
|
||
themeBoundary: '压抑、悬疑;潮湿群岛、冷雾港口;避免轻喜冒险。',
|
||
playerEntryPoint:
|
||
'玩家以返乡守灯人继承者身份切入,回港首夜撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。',
|
||
coreConflict:
|
||
'守灯会与航运公会争夺航路解释权,有人在借假航灯持续清洗旧案证据,玩家返乡当夜就被卷进封航冲突。',
|
||
keyRelationships: '玩家与沈砺旧友互疑,沈砺知道沉船夜的另一半真相。',
|
||
hiddenLines:
|
||
'沉船夜与假航灯骗局属于同一操盘链条,表面像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。',
|
||
iconicElements:
|
||
'假航灯、沉钟回响、旧灯塔、禁航碑;错误航灯会把船引进必死水域。',
|
||
},
|
||
progressPercent: 0,
|
||
lastAssistantReply: '先告诉我你想做一个怎样的 RPG 世界。',
|
||
stage: 'clarifying',
|
||
focusCardId: null,
|
||
creatorIntent: {},
|
||
creatorIntentReadiness: {
|
||
isReady: false,
|
||
completedKeys: ['world_hook'],
|
||
missingKeys: [
|
||
'player_premise',
|
||
'theme_and_tone',
|
||
'core_conflict',
|
||
'relationship_seed',
|
||
'iconic_element',
|
||
],
|
||
},
|
||
anchorPack: {},
|
||
lockState: {},
|
||
draftProfile: null,
|
||
messages: [
|
||
{
|
||
id: 'message-1',
|
||
role: 'assistant',
|
||
kind: 'summary',
|
||
text: '先告诉我你想做一个怎样的 RPG 世界。',
|
||
createdAt: '2026-04-14T12:00:00.000Z',
|
||
relatedOperationId: null,
|
||
},
|
||
],
|
||
draftCards: [],
|
||
pendingClarifications: [],
|
||
suggestedActions: [],
|
||
recommendedReplies: [],
|
||
qualityFindings: [],
|
||
assetCoverage: {
|
||
roleAssets: [],
|
||
sceneAssets: [],
|
||
allRoleAssetsReady: false,
|
||
allSceneAssetsReady: false,
|
||
},
|
||
updatedAt: '2026-04-14T12:00:00.000Z',
|
||
};
|
||
|
||
const mockAuthUser: AuthUser = {
|
||
id: 'user-1',
|
||
username: 'tester',
|
||
displayName: '测试玩家',
|
||
avatarUrl: null,
|
||
publicUserCode: 'user-tester',
|
||
phoneNumberMasked: null,
|
||
loginMethod: 'password',
|
||
bindingStatus: 'active',
|
||
wechatBound: false,
|
||
createdAt: new Date().toISOString(),
|
||
};
|
||
|
||
function buildMockCreativeAgentSession(
|
||
overrides: Partial<CreativeAgentSessionSnapshot> = {},
|
||
): CreativeAgentSessionSnapshot {
|
||
const sessionId = overrides.sessionId ?? 'creative-agent-session-1';
|
||
|
||
return {
|
||
sessionId,
|
||
stage: 'waiting_user',
|
||
inputSummary: {
|
||
text: null,
|
||
entryContext: 'creation_home',
|
||
images: [],
|
||
materialSummary: null,
|
||
unsupportedCapabilities: [],
|
||
},
|
||
messages: [
|
||
{
|
||
id: 'creative-agent-message-1',
|
||
role: 'assistant',
|
||
kind: 'chat',
|
||
text: '说一个灵感,我来帮你做成互动内容。',
|
||
createdAt: '2026-05-05T10:00:00.000Z',
|
||
},
|
||
],
|
||
puzzleTemplateCatalog: [],
|
||
puzzleTemplateSelection: null,
|
||
puzzleImageGenerationPlan: null,
|
||
targetBinding: null,
|
||
updatedAt: '2026-05-05T10:00:00.000Z',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function buildMockSquareHoleAgentSession(
|
||
overrides: Partial<Parameters<typeof buildMockSquareHoleAgentSessionImpl>[0]> = {},
|
||
) {
|
||
return buildMockSquareHoleAgentSessionImpl(overrides);
|
||
}
|
||
|
||
function buildMockSquareHoleAgentSessionImpl(
|
||
overrides: Partial<{
|
||
sessionId: string;
|
||
stage: string;
|
||
messages: Array<{ id: string; role: string; kind: string; text: string; createdAt: string }>;
|
||
updatedAt: string;
|
||
}> = {},
|
||
) {
|
||
const sessionId = overrides.sessionId ?? 'square-hole-session-1';
|
||
|
||
return {
|
||
sessionId,
|
||
currentTurn: 0,
|
||
progressPercent: 20,
|
||
stage: 'collecting_config',
|
||
anchorPack: {
|
||
theme: {
|
||
key: 'theme',
|
||
label: '题材主题',
|
||
value: '霓虹形状',
|
||
status: 'confirmed',
|
||
},
|
||
twistRule: {
|
||
key: 'twistRule',
|
||
label: '反直觉规则',
|
||
value: '颜色会误导洞口',
|
||
status: 'confirmed',
|
||
},
|
||
shapeCount: {
|
||
key: 'shapeCount',
|
||
label: '形状数量',
|
||
value: '12',
|
||
status: 'confirmed',
|
||
},
|
||
difficulty: {
|
||
key: 'difficulty',
|
||
label: '难度',
|
||
value: '5',
|
||
status: 'confirmed',
|
||
},
|
||
},
|
||
config: {
|
||
themeText: '霓虹形状',
|
||
twistRule: '颜色会误导洞口',
|
||
shapeCount: 12,
|
||
difficulty: 5,
|
||
shapeOptions: [
|
||
{
|
||
optionId: 'shape-square',
|
||
shapeKind: 'square',
|
||
label: '方块',
|
||
targetHoleId: 'hole-square',
|
||
imagePrompt: '霓虹方块',
|
||
imageSrc: null,
|
||
},
|
||
],
|
||
holeOptions: [
|
||
{
|
||
holeId: 'hole-square',
|
||
holeKind: 'square',
|
||
label: '方洞',
|
||
imagePrompt: '发光方洞',
|
||
imageSrc: null,
|
||
},
|
||
],
|
||
backgroundPrompt: '霓虹街机背景',
|
||
coverImageSrc: null,
|
||
backgroundImageSrc: null,
|
||
},
|
||
draft: null,
|
||
messages: [
|
||
{
|
||
id: 'square-hole-message-1',
|
||
role: 'assistant',
|
||
kind: 'chat',
|
||
text: '先确定方洞挑战的题材和反直觉规则。',
|
||
createdAt: '2026-05-01T10:00:00.000Z',
|
||
},
|
||
],
|
||
lastAssistantReply: '先确定方洞挑战的题材和反直觉规则。',
|
||
publishedProfileId: null,
|
||
updatedAt: '2026-05-01T10:00:00.000Z',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function buildMockSquareHoleRun(profileId: string) {
|
||
return {
|
||
runId: `square-hole-run-${profileId}`,
|
||
profileId,
|
||
ownerUserId: 'user-2',
|
||
status: 'running',
|
||
snapshotVersion: 1,
|
||
startedAtMs: 1_000,
|
||
durationLimitMs: 600_000,
|
||
remainingMs: 600_000,
|
||
totalShapeCount: 12,
|
||
completedShapeCount: 0,
|
||
combo: 0,
|
||
bestCombo: 0,
|
||
score: 0,
|
||
ruleLabel: '颜色会误导洞口',
|
||
currentShape: {
|
||
shapeId: 'shape-1',
|
||
shapeKind: 'square',
|
||
label: '方块',
|
||
targetHoleId: 'hole-square',
|
||
color: '#ff5f7e',
|
||
imageSrc: null,
|
||
},
|
||
holes: [
|
||
{
|
||
holeId: 'hole-square',
|
||
holeKind: 'square',
|
||
label: '方洞',
|
||
x: 0.2,
|
||
y: 0.5,
|
||
},
|
||
],
|
||
lastFeedback: null,
|
||
};
|
||
}
|
||
|
||
function buildMockPuzzleRun(
|
||
profileId: string,
|
||
levelName: string,
|
||
): PuzzleRunSnapshot {
|
||
const gridSize = 3 as const;
|
||
|
||
return {
|
||
runId: `run-${profileId}`,
|
||
entryProfileId: profileId,
|
||
clearedLevelCount: 0,
|
||
currentLevelIndex: 1,
|
||
currentGridSize: gridSize,
|
||
playedProfileIds: [profileId],
|
||
previousLevelTags: ['机关'],
|
||
recommendedNextProfileId: null,
|
||
leaderboardEntries: [],
|
||
currentLevel: {
|
||
runId: `run-${profileId}`,
|
||
levelIndex: 1,
|
||
levelId: 'puzzle-level-1',
|
||
gridSize,
|
||
profileId,
|
||
levelName,
|
||
authorDisplayName: '拼图作者',
|
||
themeTags: ['机关'],
|
||
coverImageSrc: null,
|
||
status: 'playing',
|
||
startedAtMs: 1_000,
|
||
clearedAtMs: null,
|
||
elapsedMs: null,
|
||
timeLimitMs: 300_000,
|
||
remainingMs: 300_000,
|
||
pausedAccumulatedMs: 0,
|
||
pauseStartedAtMs: null,
|
||
freezeAccumulatedMs: 0,
|
||
freezeStartedAtMs: null,
|
||
freezeUntilMs: null,
|
||
leaderboardEntries: [],
|
||
board: {
|
||
rows: 3,
|
||
cols: 3,
|
||
selectedPieceId: null,
|
||
allTilesResolved: false,
|
||
mergedGroups: [],
|
||
pieces: Array.from({ length: 9 }, (_, index) => ({
|
||
pieceId: `piece-${index}`,
|
||
correctRow: Math.floor(index / 3),
|
||
correctCol: index % 3,
|
||
currentRow: Math.floor(index / 3),
|
||
currentCol: index % 3,
|
||
mergedGroupId: null,
|
||
})),
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
function buildPuzzleAnchorPack(): PuzzleAnchorPack {
|
||
return {
|
||
themePromise: {
|
||
key: 'themePromise',
|
||
label: '题材承诺',
|
||
value: '雨夜拼图',
|
||
status: 'confirmed',
|
||
},
|
||
visualSubject: {
|
||
key: 'visualSubject',
|
||
label: '画面主体',
|
||
value: '雨夜猫塔',
|
||
status: 'confirmed',
|
||
},
|
||
visualMood: {
|
||
key: 'visualMood',
|
||
label: '视觉气质',
|
||
value: '暖灯',
|
||
status: 'confirmed',
|
||
},
|
||
compositionHooks: {
|
||
key: 'compositionHooks',
|
||
label: '拼图记忆点',
|
||
value: '灯塔与猫',
|
||
status: 'confirmed',
|
||
},
|
||
tagsAndForbidden: {
|
||
key: 'tagsAndForbidden',
|
||
label: '标签与禁忌',
|
||
value: '雨夜、猫咪、塔',
|
||
status: 'confirmed',
|
||
},
|
||
};
|
||
}
|
||
|
||
function buildClearedPuzzleRun(params: {
|
||
runId: string;
|
||
entryProfileId: string;
|
||
profileId: string;
|
||
levelName: string;
|
||
levelIndex: number;
|
||
elapsedMs: number;
|
||
recommendedNextProfileId?: string | null;
|
||
leaderboardEntries?: PuzzleRunSnapshot['leaderboardEntries'];
|
||
}): PuzzleRunSnapshot {
|
||
const baseRun = buildMockPuzzleRun(params.profileId, params.levelName);
|
||
const currentLevel = baseRun.currentLevel!;
|
||
const leaderboardEntries = params.leaderboardEntries ?? [];
|
||
|
||
return {
|
||
...baseRun,
|
||
runId: params.runId,
|
||
entryProfileId: params.entryProfileId,
|
||
currentLevelIndex: params.levelIndex,
|
||
clearedLevelCount: params.levelIndex,
|
||
currentGridSize: currentLevel.gridSize,
|
||
playedProfileIds:
|
||
params.entryProfileId === params.profileId
|
||
? [params.entryProfileId]
|
||
: [params.entryProfileId, params.profileId],
|
||
recommendedNextProfileId: params.recommendedNextProfileId ?? null,
|
||
leaderboardEntries,
|
||
currentLevel: {
|
||
...currentLevel,
|
||
runId: params.runId,
|
||
levelIndex: params.levelIndex,
|
||
profileId: params.profileId,
|
||
levelName: params.levelName,
|
||
status: 'cleared',
|
||
clearedAtMs: currentLevel.startedAtMs + params.elapsedMs,
|
||
elapsedMs: params.elapsedMs,
|
||
leaderboardEntries,
|
||
board: {
|
||
...currentLevel.board,
|
||
allTilesResolved: true,
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
function buildMockMatch3DRun(profileId: string): Match3DRunSnapshot {
|
||
return {
|
||
runId: `match3d-run-${profileId}`,
|
||
profileId,
|
||
ownerUserId: 'user-2',
|
||
status: 'running',
|
||
snapshotVersion: 1,
|
||
startedAtMs: 1_000,
|
||
durationLimitMs: 600_000,
|
||
serverNowMs: 1_000,
|
||
remainingMs: 600_000,
|
||
clearCount: 4,
|
||
totalItemCount: 12,
|
||
clearedItemCount: 0,
|
||
items: [],
|
||
traySlots: Array.from({ length: 7 }, (_, slotIndex) => ({ slotIndex })),
|
||
failureReason: null,
|
||
};
|
||
}
|
||
|
||
function buildMockMatch3DAgentSession(
|
||
overrides: Partial<Match3DAgentSessionSnapshot> = {},
|
||
): Match3DAgentSessionSnapshot {
|
||
const sessionId = overrides.sessionId ?? 'match3d-agent-session-1';
|
||
|
||
return {
|
||
sessionId,
|
||
currentTurn: 0,
|
||
progressPercent: 20,
|
||
stage: 'collecting',
|
||
anchorPack: {
|
||
theme: {
|
||
key: 'theme',
|
||
label: '题材主题',
|
||
value: '水果消除',
|
||
status: 'confirmed',
|
||
},
|
||
clearCount: {
|
||
key: 'clearCount',
|
||
label: '需要消除次数',
|
||
value: '4',
|
||
status: 'confirmed',
|
||
},
|
||
difficulty: {
|
||
key: 'difficulty',
|
||
label: '难度',
|
||
value: '5',
|
||
status: 'confirmed',
|
||
},
|
||
},
|
||
config: {
|
||
themeText: '水果消除',
|
||
referenceImageSrc: null,
|
||
clearCount: 4,
|
||
difficulty: 5,
|
||
},
|
||
draft: null,
|
||
messages: [
|
||
{
|
||
id: 'match3d-message-1',
|
||
role: 'assistant',
|
||
kind: 'chat',
|
||
text: '我们先确定抓大鹅题材、消除次数和难度。',
|
||
createdAt: '2026-05-01T10:00:00.000Z',
|
||
},
|
||
],
|
||
lastAssistantReply: '我们先确定抓大鹅题材、消除次数和难度。',
|
||
publishedProfileId: null,
|
||
updatedAt: '2026-05-01T10:00:00.000Z',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function buildMockRpgGalleryDetail(
|
||
entry: CustomWorldGalleryCard,
|
||
): CustomWorldLibraryEntry<CustomWorldProfile> {
|
||
return {
|
||
...entry,
|
||
profile: {
|
||
id: entry.profileId,
|
||
settingText: entry.summaryText,
|
||
name: entry.worldName,
|
||
subtitle: entry.subtitle,
|
||
summary: entry.summaryText,
|
||
tone: '压抑、潮湿、悬疑',
|
||
playerGoal: '查清旧案。',
|
||
templateWorldType: WorldType.WUXIA,
|
||
attributeSchema: {
|
||
id: `${entry.profileId}-attribute-schema`,
|
||
worldId: entry.profileId,
|
||
schemaVersion: 1,
|
||
generatedFrom: {
|
||
worldType: WorldType.CUSTOM,
|
||
worldName: entry.worldName,
|
||
settingSummary: entry.summaryText,
|
||
tone: '压抑、潮湿、悬疑',
|
||
conflictCore: '雾潮正在逼近港口',
|
||
},
|
||
slots: [],
|
||
},
|
||
majorFactions: ['守灯会'],
|
||
coreConflicts: ['雾潮正在逼近港口'],
|
||
playableNpcs: [],
|
||
storyNpcs: [],
|
||
items: [],
|
||
landmarks: [],
|
||
},
|
||
};
|
||
}
|
||
|
||
const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = {
|
||
...mockSession,
|
||
stage: 'object_refining',
|
||
creatorIntent: {
|
||
sourceMode: 'card',
|
||
worldHook: '被海雾吞没的旧航路群岛',
|
||
playerPremise: '玩家回到群岛调查沉船真相。',
|
||
themeKeywords: ['海雾', '旧航路'],
|
||
toneDirectives: ['压抑', '悬疑'],
|
||
openingSituation: '首夜就有陌生船只闯入禁航区。',
|
||
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
|
||
keyFactions: [],
|
||
keyCharacters: [],
|
||
keyLandmarks: [],
|
||
iconicElements: ['会移动的海雾'],
|
||
forbiddenDirectives: [],
|
||
rawSettingText: '',
|
||
},
|
||
draftProfile: {
|
||
name: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '第一版世界底稿已经整理完成。',
|
||
tone: '压抑、潮湿、悬疑',
|
||
playerGoal: '查清沉船与禁航区异动的真相。',
|
||
majorFactions: ['守灯会', '航运公会'],
|
||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||
playableNpcs: [
|
||
{
|
||
id: 'playable-1',
|
||
name: '沈砺',
|
||
title: '旧航路引路人',
|
||
role: '关键同行者',
|
||
publicIdentity: '最熟悉旧航路的人。',
|
||
publicMask: '看上去像可靠旧友。',
|
||
currentPressure: '他必须在两股势力间站队。',
|
||
hiddenHook: '暗中替沉船商盟引路。',
|
||
relationToPlayer: '旧友兼潜在背叛者',
|
||
threadIds: ['thread-1'],
|
||
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
|
||
},
|
||
],
|
||
storyNpcs: [
|
||
{
|
||
id: 'story-1',
|
||
name: '顾潮音',
|
||
title: '守灯会值夜人',
|
||
role: '场景关键角色',
|
||
publicIdentity: '负责夜间巡灯与封锁。',
|
||
publicMask: '对外一直冷静克制。',
|
||
currentPressure: '她知道更多禁航区真相。',
|
||
hiddenHook: '曾亲眼见过失控海雾吞船。',
|
||
relationToPlayer: '最早愿意交换线索的人',
|
||
threadIds: ['thread-1'],
|
||
summary: '她总像比所有人更早知道海雾会往哪一侧压下来。',
|
||
},
|
||
],
|
||
landmarks: [
|
||
{
|
||
id: 'landmark-1',
|
||
name: '回潮旧灯塔',
|
||
purpose: '观察雾潮与往来船只',
|
||
mood: '潮湿、压抑、风声不止',
|
||
importance: '开局核心场景',
|
||
characterIds: ['story-1'],
|
||
threadIds: ['thread-1'],
|
||
summary: '旧灯塔是整片群岛最先看见异动的地方。',
|
||
},
|
||
],
|
||
factions: [],
|
||
threads: [],
|
||
chapters: [],
|
||
worldHook: '被海雾吞没的旧航路群岛',
|
||
playerPremise: '玩家回到群岛调查沉船真相。',
|
||
openingSituation: '首夜就有陌生船只闯入禁航区。',
|
||
iconicElements: ['会移动的海雾'],
|
||
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
|
||
},
|
||
draftCards: [
|
||
{
|
||
id: 'world-foundation',
|
||
kind: 'world',
|
||
title: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '第一版世界底稿已经整理完成。',
|
||
status: 'warning',
|
||
linkedIds: ['playable-1', 'story-1', 'landmark-1'],
|
||
warningCount: 0,
|
||
},
|
||
],
|
||
resultPreview: {
|
||
source: 'session_preview',
|
||
preview: {
|
||
id: 'agent-draft-custom-world-agent-session-1',
|
||
settingText: '被海雾吞没的旧航路群岛',
|
||
name: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '第一版世界底稿已经整理完成。',
|
||
tone: '压抑、潮湿、悬疑',
|
||
playerGoal: '查清沉船与禁航区异动的真相。',
|
||
templateWorldType: 'WUXIA',
|
||
majorFactions: ['守灯会', '航运公会'],
|
||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||
playableNpcs: [
|
||
{
|
||
id: 'playable-1',
|
||
name: '沈砺',
|
||
title: '旧航路引路人',
|
||
role: '关键同行者',
|
||
description: '最熟悉旧航路的人。',
|
||
backstory: '曾在沉船夜里带着半支船队逃出海雾。',
|
||
personality: '表面沉稳,心里一直在算退路。',
|
||
motivation: '想赶在守灯会封航前查清真相。',
|
||
combatStyle: '借地形和潮路换位,先拉扯再压近。',
|
||
initialAffinity: 18,
|
||
relationshipHooks: ['旧友', '沉船旧案'],
|
||
tags: ['潮路', '引路'],
|
||
},
|
||
],
|
||
storyNpcs: [
|
||
{
|
||
id: 'story-1',
|
||
name: '顾潮音',
|
||
title: '守灯会值夜人',
|
||
role: '场景关键角色',
|
||
description: '夜里巡灯与封锁禁航区的人。',
|
||
backstory: '在失控海雾第一次吞船那夜,她是最后一个还留在灯塔顶的人。',
|
||
personality: '冷静克制,但提到旧灯册时会显得过分警觉。',
|
||
motivation: '想守住灯塔记录,也想找到谁先改动了禁航信号。',
|
||
combatStyle: '借塔顶视角和风向压制,再用灯火错位扰乱。',
|
||
initialAffinity: 8,
|
||
relationshipHooks: ['禁航记录', '灯塔值夜'],
|
||
tags: ['守灯会', '灯塔'],
|
||
},
|
||
],
|
||
items: [],
|
||
landmarks: [
|
||
{
|
||
id: 'landmark-1',
|
||
name: '回潮旧灯塔',
|
||
description: '旧灯塔是整片群岛最先看见异动的地方。',
|
||
sceneNpcIds: ['story-1'],
|
||
connections: [],
|
||
},
|
||
],
|
||
generationMode: 'full',
|
||
generationStatus: 'complete',
|
||
sessionId: 'custom-world-agent-session-1',
|
||
},
|
||
generatedAt: '2026-04-14T12:00:00.000Z',
|
||
qualityFindings: [],
|
||
blockers: [],
|
||
publishReady: true,
|
||
canEnterWorld: false,
|
||
},
|
||
};
|
||
|
||
function buildResultViewForSession(
|
||
session: CustomWorldAgentSessionSnapshot,
|
||
): RpgCreationResultView {
|
||
const profile = session.resultPreview?.preview ?? null;
|
||
const isResultStage =
|
||
session.stage === 'object_refining' ||
|
||
session.stage === 'visual_refining' ||
|
||
session.stage === 'long_tail_review' ||
|
||
session.stage === 'ready_to_publish' ||
|
||
session.stage === 'published';
|
||
|
||
return {
|
||
session,
|
||
profile,
|
||
profileSource: profile ? 'result_preview' : 'none',
|
||
targetStage:
|
||
profile && isResultStage
|
||
? 'custom-world-result'
|
||
: session.stage === 'error'
|
||
? 'custom-world-generating'
|
||
: 'agent-workspace',
|
||
generationViewSource:
|
||
session.stage === 'error' ? 'agent-draft-foundation' : null,
|
||
resultViewSource: profile && isResultStage ? 'agent-draft' : null,
|
||
canAutosaveLibrary: Boolean(profile && isResultStage),
|
||
canSyncResultProfile:
|
||
session.stage === 'object_refining' ||
|
||
session.stage === 'visual_refining' ||
|
||
session.stage === 'long_tail_review' ||
|
||
session.stage === 'ready_to_publish',
|
||
publishReady: Boolean(session.resultPreview?.publishReady),
|
||
canEnterWorld: Boolean(session.resultPreview?.canEnterWorld),
|
||
blockerCount: session.resultPreview?.blockers?.length ?? 0,
|
||
recoveryAction:
|
||
profile && isResultStage
|
||
? 'open_result'
|
||
: session.stage === 'error'
|
||
? 'resume_generation'
|
||
: 'continue_agent',
|
||
recoveryReason: null,
|
||
};
|
||
}
|
||
|
||
function buildExistingRpgDraftWork(
|
||
overrides: Partial<CustomWorldWorkSummary> = {},
|
||
): CustomWorldWorkSummary {
|
||
return {
|
||
workId: 'draft:custom-world-agent-session-1',
|
||
sourceType: 'agent_session',
|
||
status: 'draft',
|
||
title: '潮雾列岛',
|
||
subtitle: '待完善草稿',
|
||
summary: '玩家是失职返乡的守灯人。',
|
||
coverImageSrc: null,
|
||
coverRenderMode: 'image',
|
||
coverCharacterImageSrcs: [],
|
||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||
publishedAt: null,
|
||
stage: 'object_refining',
|
||
stageLabel: '待完善草稿',
|
||
playableNpcCount: 3,
|
||
landmarkCount: 4,
|
||
roleVisualReadyCount: 1,
|
||
roleAnimationReadyCount: 0,
|
||
roleAssetSummaryLabel: '沈砺 · 主图已生成',
|
||
sessionId: 'custom-world-agent-session-1',
|
||
profileId: null,
|
||
canResume: true,
|
||
canEnterWorld: false,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function mockExistingRpgDraftShelf(
|
||
overrides: Partial<CustomWorldWorkSummary> = {},
|
||
) {
|
||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||
buildExistingRpgDraftWork(overrides),
|
||
]);
|
||
}
|
||
|
||
type TestAuthValue = {
|
||
user: AuthUser | null;
|
||
canAccessProtectedData: boolean;
|
||
openLoginModal: (postLoginAction?: (() => void) | null) => void;
|
||
requireAuth: (action: () => void) => void;
|
||
openSettingsModal: (section?: PlatformSettingsSection) => void;
|
||
openAccountModal: () => void;
|
||
setCurrentUser: (user: AuthUser) => void;
|
||
logout: () => Promise<void>;
|
||
musicVolume: number;
|
||
setMusicVolume: (value: number) => void;
|
||
platformTheme: 'light' | 'dark';
|
||
setPlatformTheme: (theme: 'light' | 'dark') => void;
|
||
isHydratingSettings: boolean;
|
||
isPersistingSettings: boolean;
|
||
settingsError: string | null;
|
||
};
|
||
|
||
function createAuthValue(
|
||
overrides: Partial<TestAuthValue> = {},
|
||
): TestAuthValue {
|
||
return {
|
||
user: mockAuthUser,
|
||
canAccessProtectedData: true,
|
||
openLoginModal: () => {},
|
||
requireAuth: (action) => action(),
|
||
openSettingsModal: () => {},
|
||
openAccountModal: () => {},
|
||
setCurrentUser: () => {},
|
||
logout: async () => {},
|
||
musicVolume: 0.42,
|
||
setMusicVolume: () => {},
|
||
platformTheme: 'light',
|
||
setPlatformTheme: () => {},
|
||
isHydratingSettings: false,
|
||
isPersistingSettings: false,
|
||
settingsError: null,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function TestWrapper({
|
||
withAuth = false,
|
||
authValue,
|
||
onContinueGame,
|
||
onSelectWorld,
|
||
}: {
|
||
withAuth?: boolean;
|
||
authValue?: TestAuthValue;
|
||
onContinueGame?: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||
onSelectWorld?: RpgEntryFlowShellProps['handleCustomWorldSelect'];
|
||
} = {}) {
|
||
const [selectionStage, setSelectionStage] = useState<SelectionStage>(() =>
|
||
window.location.pathname === '/creation/rpg/agent'
|
||
? 'agent-workspace'
|
||
: 'platform',
|
||
);
|
||
|
||
const content = (
|
||
<RpgEntryFlowShell
|
||
selectionStage={selectionStage}
|
||
setSelectionStage={setSelectionStage}
|
||
hasSavedGame={false}
|
||
savedSnapshot={null}
|
||
handleContinueGame={onContinueGame ?? (() => {})}
|
||
handleStartNewGame={() => {}}
|
||
handleCustomWorldSelect={onSelectWorld ?? (() => {})}
|
||
/>
|
||
);
|
||
|
||
if (!withAuth && !authValue) {
|
||
return content;
|
||
}
|
||
|
||
return (
|
||
<AuthUiContext.Provider value={authValue ?? createAuthValue()}>
|
||
{content}
|
||
</AuthUiContext.Provider>
|
||
);
|
||
}
|
||
|
||
beforeEach(() => {
|
||
vi.resetAllMocks();
|
||
window.history.replaceState(null, '', '/');
|
||
window.sessionStorage.clear();
|
||
window.localStorage.clear();
|
||
window.localStorage.setItem(
|
||
'genarrative.puzzle-onboarding.first-visit.v1',
|
||
'1',
|
||
);
|
||
vi.mocked(getProfileDashboard).mockResolvedValue({
|
||
walletBalance: 0,
|
||
totalPlayTimeMs: 0,
|
||
playedWorldCount: 0,
|
||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||
});
|
||
vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([]);
|
||
vi.mocked(listRpgEntryWorldGallery).mockResolvedValue([]);
|
||
vi.mocked(getRpgEntryWorldGalleryDetail).mockImplementation(
|
||
async (ownerUserId, profileId) =>
|
||
buildMockRpgGalleryDetail({
|
||
ownerUserId,
|
||
profileId,
|
||
publicWorkCode: null,
|
||
authorPublicUserCode: ownerUserId,
|
||
visibility: 'published',
|
||
publishedAt: '2026-04-16T12:00:00.000Z',
|
||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||
authorDisplayName: '测试作者',
|
||
worldName: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summaryText: '最近公开发布的世界。',
|
||
coverImageSrc: null,
|
||
themeMode: 'tide',
|
||
playableNpcCount: 0,
|
||
landmarkCount: 0,
|
||
likeCount: 0,
|
||
}),
|
||
);
|
||
vi.mocked(getRpgEntryWorldGalleryDetailFromClient).mockImplementation(
|
||
async (ownerUserId, profileId) =>
|
||
buildMockRpgGalleryDetail({
|
||
ownerUserId,
|
||
profileId,
|
||
publicWorkCode: null,
|
||
authorPublicUserCode: ownerUserId,
|
||
visibility: 'published',
|
||
publishedAt: '2026-04-16T12:00:00.000Z',
|
||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||
authorDisplayName: '测试作者',
|
||
worldName: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summaryText: '最近公开发布的世界。',
|
||
coverImageSrc: null,
|
||
themeMode: 'tide',
|
||
playableNpcCount: 0,
|
||
landmarkCount: 0,
|
||
likeCount: 0,
|
||
}),
|
||
);
|
||
vi.mocked(
|
||
rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail,
|
||
).mockImplementation(async (profileId: string) =>
|
||
buildMockRpgGalleryDetail({
|
||
ownerUserId: mockAuthUser.id,
|
||
profileId,
|
||
publicWorkCode: `work-${profileId}`,
|
||
authorPublicUserCode: mockAuthUser.publicUserCode,
|
||
visibility: 'published',
|
||
publishedAt: '2026-04-16T12:00:00.000Z',
|
||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||
authorDisplayName: mockAuthUser.displayName,
|
||
worldName: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summaryText: '最近公开发布的世界。',
|
||
coverImageSrc: null,
|
||
themeMode: 'tide',
|
||
playableNpcCount: 0,
|
||
landmarkCount: 0,
|
||
likeCount: 0,
|
||
}),
|
||
);
|
||
vi.mocked(listProfileBrowseHistory).mockResolvedValue([]);
|
||
vi.mocked(listProfileSaveArchives).mockResolvedValue([]);
|
||
vi.mocked(resumeProfileSaveArchive).mockResolvedValue({
|
||
entry: {
|
||
worldKey: 'custom:world-archive-1',
|
||
ownerUserId: null,
|
||
profileId: 'world-archive-1',
|
||
worldType: 'CUSTOM',
|
||
worldName: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summaryText: '回到旧灯塔继续推进调查。',
|
||
coverImageSrc: null,
|
||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||
},
|
||
snapshot: {
|
||
version: 2,
|
||
savedAt: '2026-04-19T12:00:00.000Z',
|
||
bottomTab: 'adventure',
|
||
currentStory: null,
|
||
gameState: {},
|
||
} as HydratedSavedGameSnapshot,
|
||
});
|
||
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
|
||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
|
||
vi.mocked(recordBigFishPlay).mockResolvedValue({ items: [] });
|
||
vi.mocked(recordRpgEntryWorldGalleryPlay).mockImplementation(
|
||
async (ownerUserId, profileId) => ({
|
||
ownerUserId,
|
||
profileId,
|
||
publicWorkCode: null,
|
||
authorPublicUserCode: ownerUserId,
|
||
profile: {
|
||
id: profileId,
|
||
name: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '最近公开发布的世界。',
|
||
tone: '压抑、潮湿、悬疑',
|
||
playerGoal: '查清旧案。',
|
||
majorFactions: ['守灯会'],
|
||
coreConflicts: ['雾潮正在逼近港口'],
|
||
playableNpcs: [],
|
||
storyNpcs: [],
|
||
landmarks: [],
|
||
} as never,
|
||
visibility: 'published',
|
||
publishedAt: '2026-04-16T12:00:00.000Z',
|
||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||
authorDisplayName: '测试作者',
|
||
worldName: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summaryText: '最近公开发布的世界。',
|
||
coverImageSrc: null,
|
||
themeMode: 'tide',
|
||
playableNpcCount: 0,
|
||
landmarkCount: 0,
|
||
likeCount: 0,
|
||
}),
|
||
);
|
||
vi.mocked(remixRpgEntryWorldGallery).mockRejectedValue(
|
||
new Error('未启用 remix'),
|
||
);
|
||
vi.mocked(getRpgEntryWorldGalleryDetailByCode).mockRejectedValue(
|
||
new Error('未找到公开作品'),
|
||
);
|
||
vi.mocked(upsertRpgWorldProfile).mockResolvedValue({
|
||
entry: {
|
||
ownerUserId: 'user-1',
|
||
profileId: 'agent-draft-custom-world-agent-session-1',
|
||
publicWorkCode: null,
|
||
authorPublicUserCode: null,
|
||
profile: {
|
||
id: 'agent-draft-custom-world-agent-session-1',
|
||
name: '潮雾列岛',
|
||
} as never,
|
||
visibility: 'draft',
|
||
publishedAt: null,
|
||
updatedAt: '2026-04-14T12:00:00.000Z',
|
||
authorDisplayName: '玩家',
|
||
worldName: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summaryText: '第一版世界底稿已经整理完成。',
|
||
coverImageSrc: null,
|
||
themeMode: 'tide',
|
||
playableNpcCount: 1,
|
||
landmarkCount: 1,
|
||
likeCount: 0,
|
||
},
|
||
entries: [],
|
||
});
|
||
vi.mocked(createRpgCreationSession).mockResolvedValue({
|
||
session: mockSession,
|
||
});
|
||
vi.mocked(getRpgCreationResultView).mockImplementation(async () =>
|
||
buildResultViewForSession(mockSession),
|
||
);
|
||
vi.mocked(createBigFishCreationSession).mockResolvedValue({
|
||
session: {
|
||
sessionId: 'big-fish-session-1',
|
||
currentTurn: 0,
|
||
progressPercent: 0,
|
||
stage: 'clarifying',
|
||
anchorPack: {
|
||
gameplayPromise: {
|
||
key: 'gameplay_promise',
|
||
label: '核心玩法',
|
||
value: '',
|
||
status: 'missing',
|
||
},
|
||
ecologyVisualTheme: {
|
||
key: 'ecology_visual_theme',
|
||
label: '生态视觉',
|
||
value: '',
|
||
status: 'missing',
|
||
},
|
||
growthLadder: {
|
||
key: 'growth_ladder',
|
||
label: '成长阶梯',
|
||
value: '',
|
||
status: 'missing',
|
||
},
|
||
riskTempo: {
|
||
key: 'risk_tempo',
|
||
label: '风险节奏',
|
||
value: '',
|
||
status: 'missing',
|
||
},
|
||
},
|
||
draft: null,
|
||
assetSlots: [],
|
||
assetCoverage: {
|
||
levelMainImageReadyCount: 0,
|
||
levelMotionReadyCount: 0,
|
||
backgroundReady: false,
|
||
requiredLevelCount: 0,
|
||
publishReady: false,
|
||
blockers: [],
|
||
},
|
||
messages: [],
|
||
lastAssistantReply: '先说说你想要什么样的大鱼生态。',
|
||
publishReady: false,
|
||
updatedAt: '2026-04-22T12:00:00.000Z',
|
||
},
|
||
});
|
||
vi.mocked(getBigFishCreationSession).mockResolvedValue({
|
||
session: {
|
||
sessionId: 'big-fish-session-1',
|
||
currentTurn: 2,
|
||
progressPercent: 90,
|
||
stage: 'draft_ready',
|
||
anchorPack: {
|
||
gameplayPromise: {
|
||
key: 'gameplay_promise',
|
||
label: '核心玩法',
|
||
value: '机械微生物吞并进化',
|
||
status: 'confirmed',
|
||
},
|
||
ecologyVisualTheme: {
|
||
key: 'ecology_visual_theme',
|
||
label: '生态视觉',
|
||
value: '深海机械浮游生态',
|
||
status: 'confirmed',
|
||
},
|
||
growthLadder: {
|
||
key: 'growth_ladder',
|
||
label: '成长阶梯',
|
||
value: '从微光孢子到深海巨鲲',
|
||
status: 'confirmed',
|
||
},
|
||
riskTempo: {
|
||
key: 'risk_tempo',
|
||
label: '风险节奏',
|
||
value: '快节奏吞并,后段压迫感增强',
|
||
status: 'confirmed',
|
||
},
|
||
},
|
||
draft: {
|
||
title: '机械深海 大鱼吃小鱼',
|
||
subtitle: '机械微生物吞并进化 · 偏爽快节奏',
|
||
coreFun: '吞并更小机械生命并持续合体成长',
|
||
ecologyTheme: '深海机械浮游生态',
|
||
levels: [
|
||
{
|
||
level: 1,
|
||
name: '微光孢子',
|
||
oneLineFantasy: '像发光尘埃一样在深海漂浮。',
|
||
textDescription:
|
||
'微光孢子是机械深海生态中的起始个体,体型最小,会先漂浮试探并寻找可吞并目标。',
|
||
silhouetteDirection: '圆润微型机械球',
|
||
sizeRatio: 1,
|
||
visualDescription:
|
||
'带有浅色发光核心的微型机械鱼苗或孢子体,轮廓圆润,表现出弱小但灵动的初始形象。',
|
||
visualPromptSeed: 'deep sea glowing mechanical spore',
|
||
idleMotionDescription:
|
||
'待机时轻轻漂浮,身体和尾部做小幅摆动,像在适应深海水流。',
|
||
moveMotionDescription:
|
||
'移动时核心前探,尾部快速摆动推进,带出轻盈的游动轨迹。',
|
||
motionPromptSeed: 'soft floating mechanical spore',
|
||
mergeSourceLevel: null,
|
||
preyWindow: [1],
|
||
threatWindow: [2],
|
||
isFinalLevel: false,
|
||
},
|
||
],
|
||
background: {
|
||
theme: '机械深海',
|
||
colorMood: '冷青色与暗金反光',
|
||
foregroundHints: '漂浮齿轮碎片',
|
||
midgroundComposition: '珊瑚状机械群落',
|
||
backgroundDepth: '深海远景光柱',
|
||
safePlayAreaHint: '中心区域留空',
|
||
spawnEdgeHint: '边缘暗流刷怪',
|
||
backgroundPromptSeed: 'mechanical deep sea arena',
|
||
},
|
||
runtimeParams: {
|
||
levelCount: 8,
|
||
mergeCountPerUpgrade: 3,
|
||
spawnTargetCount: 28,
|
||
leaderMoveSpeed: 1.2,
|
||
followerCatchUpSpeed: 1,
|
||
offscreenCullSeconds: 8,
|
||
preySpawnDeltaLevels: [-2, -1],
|
||
threatSpawnDeltaLevels: [1, 2],
|
||
winLevel: 8,
|
||
},
|
||
},
|
||
assetSlots: [],
|
||
assetCoverage: {
|
||
levelMainImageReadyCount: 0,
|
||
levelMotionReadyCount: 0,
|
||
backgroundReady: false,
|
||
requiredLevelCount: 8,
|
||
publishReady: false,
|
||
blockers: ['仍有主图、动作和背景未生成'],
|
||
},
|
||
messages: [
|
||
{
|
||
id: 'big-fish-message-1',
|
||
role: 'assistant',
|
||
kind: 'chat',
|
||
text: '先说说你想要什么样的大鱼生态。',
|
||
createdAt: '2026-04-22T12:00:00.000Z',
|
||
},
|
||
{
|
||
id: 'big-fish-message-2',
|
||
role: 'user',
|
||
kind: 'chat',
|
||
text: '我想做机械深海里微生物互相吞并进化。',
|
||
createdAt: '2026-04-22T12:01:00.000Z',
|
||
},
|
||
],
|
||
lastAssistantReply: '大鱼结果页草稿已经生成,可以补正式资产。',
|
||
publishReady: false,
|
||
updatedAt: '2026-04-22T12:10:00.000Z',
|
||
},
|
||
});
|
||
vi.mocked(createPuzzleAgentSession).mockResolvedValue({
|
||
session: {
|
||
sessionId: 'puzzle-session-1',
|
||
currentTurn: 0,
|
||
progressPercent: 0,
|
||
stage: 'collecting_anchors',
|
||
anchorPack: {
|
||
themePromise: {
|
||
key: 'theme_promise',
|
||
label: '主题承诺',
|
||
value: '',
|
||
status: 'missing',
|
||
},
|
||
visualSubject: {
|
||
key: 'visual_subject',
|
||
label: '视觉主体',
|
||
value: '',
|
||
status: 'missing',
|
||
},
|
||
visualMood: {
|
||
key: 'visual_mood',
|
||
label: '视觉气质',
|
||
value: '',
|
||
status: 'missing',
|
||
},
|
||
compositionHooks: {
|
||
key: 'composition_hooks',
|
||
label: '构图钩子',
|
||
value: '',
|
||
status: 'missing',
|
||
},
|
||
tagsAndForbidden: {
|
||
key: 'tags_and_forbidden',
|
||
label: '标签与禁区',
|
||
value: '',
|
||
status: 'missing',
|
||
},
|
||
},
|
||
draft: null,
|
||
messages: [],
|
||
lastAssistantReply: '先说一个你最想做成拼图的画面。',
|
||
publishedProfileId: null,
|
||
suggestedActions: [],
|
||
resultPreview: null,
|
||
updatedAt: '2026-04-22T12:00:00.000Z',
|
||
},
|
||
});
|
||
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
|
||
session: {
|
||
sessionId: 'puzzle-session-1',
|
||
currentTurn: 3,
|
||
progressPercent: 88,
|
||
stage: 'draft_ready',
|
||
anchorPack: {
|
||
themePromise: {
|
||
key: 'theme_promise',
|
||
label: '主题承诺',
|
||
value: '雨夜遗迹探索',
|
||
status: 'confirmed',
|
||
},
|
||
visualSubject: {
|
||
key: 'visual_subject',
|
||
label: '视觉主体',
|
||
value: '发光猫咪站在遗迹台阶上',
|
||
status: 'confirmed',
|
||
},
|
||
visualMood: {
|
||
key: 'visual_mood',
|
||
label: '视觉气质',
|
||
value: '潮湿、梦幻、轻悬疑',
|
||
status: 'confirmed',
|
||
},
|
||
compositionHooks: {
|
||
key: 'composition_hooks',
|
||
label: '构图钩子',
|
||
value: '台阶透视、倒影、门洞',
|
||
status: 'confirmed',
|
||
},
|
||
tagsAndForbidden: {
|
||
key: 'tags_and_forbidden',
|
||
label: '标签与禁区',
|
||
value: '雨夜、猫咪、遗迹;禁止文字水印',
|
||
status: 'confirmed',
|
||
},
|
||
},
|
||
draft: {
|
||
levelName: '雨夜猫塔',
|
||
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
|
||
themeTags: ['雨夜', '猫咪', '遗迹'],
|
||
forbiddenDirectives: ['文字水印'],
|
||
creatorIntent: null,
|
||
anchorPack: {
|
||
themePromise: {
|
||
key: 'theme_promise',
|
||
label: '主题承诺',
|
||
value: '雨夜遗迹探索',
|
||
status: 'confirmed',
|
||
},
|
||
visualSubject: {
|
||
key: 'visual_subject',
|
||
label: '视觉主体',
|
||
value: '发光猫咪站在遗迹台阶上',
|
||
status: 'confirmed',
|
||
},
|
||
visualMood: {
|
||
key: 'visual_mood',
|
||
label: '视觉气质',
|
||
value: '潮湿、梦幻、轻悬疑',
|
||
status: 'confirmed',
|
||
},
|
||
compositionHooks: {
|
||
key: 'composition_hooks',
|
||
label: '构图钩子',
|
||
value: '台阶透视、倒影、门洞',
|
||
status: 'confirmed',
|
||
},
|
||
tagsAndForbidden: {
|
||
key: 'tags_and_forbidden',
|
||
label: '标签与禁区',
|
||
value: '雨夜、猫咪、遗迹;禁止文字水印',
|
||
status: 'confirmed',
|
||
},
|
||
},
|
||
candidates: [],
|
||
selectedCandidateId: null,
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
generationStatus: 'idle',
|
||
},
|
||
messages: [
|
||
{
|
||
id: 'puzzle-message-1',
|
||
role: 'assistant',
|
||
kind: 'chat',
|
||
text: '先说一个你最想做成拼图的画面。',
|
||
createdAt: '2026-04-22T12:00:00.000Z',
|
||
},
|
||
{
|
||
id: 'puzzle-message-2',
|
||
role: 'user',
|
||
kind: 'chat',
|
||
text: '雨夜里有一只会发光的猫站在遗迹台阶上。',
|
||
createdAt: '2026-04-22T12:01:00.000Z',
|
||
},
|
||
],
|
||
lastAssistantReply: '拼图结果页草稿已经生成,可以开始出图并确认标签。',
|
||
publishedProfileId: null,
|
||
suggestedActions: [],
|
||
resultPreview: {
|
||
draft: {
|
||
levelName: '雨夜猫塔',
|
||
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
|
||
themeTags: ['雨夜', '猫咪', '遗迹'],
|
||
forbiddenDirectives: ['文字水印'],
|
||
creatorIntent: null,
|
||
anchorPack: {
|
||
themePromise: {
|
||
key: 'theme_promise',
|
||
label: '主题承诺',
|
||
value: '雨夜遗迹探索',
|
||
status: 'confirmed',
|
||
},
|
||
visualSubject: {
|
||
key: 'visual_subject',
|
||
label: '视觉主体',
|
||
value: '发光猫咪站在遗迹台阶上',
|
||
status: 'confirmed',
|
||
},
|
||
visualMood: {
|
||
key: 'visual_mood',
|
||
label: '视觉气质',
|
||
value: '潮湿、梦幻、轻悬疑',
|
||
status: 'confirmed',
|
||
},
|
||
compositionHooks: {
|
||
key: 'composition_hooks',
|
||
label: '构图钩子',
|
||
value: '台阶透视、倒影、门洞',
|
||
status: 'confirmed',
|
||
},
|
||
tagsAndForbidden: {
|
||
key: 'tags_and_forbidden',
|
||
label: '标签与禁区',
|
||
value: '雨夜、猫咪、遗迹;禁止文字水印',
|
||
status: 'confirmed',
|
||
},
|
||
},
|
||
candidates: [],
|
||
selectedCandidateId: null,
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
generationStatus: 'idle',
|
||
},
|
||
blockers: [
|
||
{
|
||
id: 'missing-cover-image',
|
||
code: 'MISSING_COVER_IMAGE',
|
||
message: '正式拼图图片尚未确定',
|
||
},
|
||
],
|
||
qualityFindings: [],
|
||
publishReady: false,
|
||
},
|
||
updatedAt: '2026-04-22T12:10:00.000Z',
|
||
},
|
||
});
|
||
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
|
||
vi.mocked(listRpgCreationWorks).mockResolvedValue([]);
|
||
vi.mocked(listBigFishWorks).mockResolvedValue({
|
||
items: [],
|
||
});
|
||
vi.mocked(listBigFishGallery).mockResolvedValue({
|
||
items: [],
|
||
});
|
||
vi.mocked(startBigFishRun).mockResolvedValue({
|
||
run: {
|
||
runId: 'big-fish-run-1',
|
||
sessionId: 'big-fish-session-public-1',
|
||
status: 'running',
|
||
tick: 0,
|
||
playerLevel: 1,
|
||
winLevel: 8,
|
||
leaderEntityId: 'owned-1',
|
||
ownedEntities: [
|
||
{
|
||
entityId: 'owned-1',
|
||
level: 1,
|
||
position: { x: 0, y: 0 },
|
||
radius: 12,
|
||
offscreenSeconds: 0,
|
||
},
|
||
],
|
||
wildEntities: [],
|
||
cameraCenter: { x: 0, y: 0 },
|
||
lastInput: { x: 0, y: 0 },
|
||
eventLog: ['机械鱼群开始巡游。'],
|
||
updatedAt: '2026-04-25T12:12:00.000Z',
|
||
},
|
||
});
|
||
vi.mocked(submitBigFishInput).mockImplementation(async (runId, payload) => ({
|
||
run: {
|
||
runId,
|
||
sessionId: 'big-fish-session-public-1',
|
||
status: 'running',
|
||
tick: 1,
|
||
playerLevel: 1,
|
||
winLevel: 8,
|
||
leaderEntityId: 'owned-1',
|
||
ownedEntities: [
|
||
{
|
||
entityId: 'owned-1',
|
||
level: 1,
|
||
position: payload,
|
||
radius: 12,
|
||
offscreenSeconds: 0,
|
||
},
|
||
],
|
||
wildEntities: [],
|
||
cameraCenter: payload,
|
||
lastInput: payload,
|
||
eventLog: ['机械鱼群继续巡游。'],
|
||
updatedAt: '2026-04-25T12:12:01.000Z',
|
||
},
|
||
}));
|
||
vi.mocked(recordBigFishPlay).mockResolvedValue({ items: [] });
|
||
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
|
||
session: buildMockMatch3DAgentSession(),
|
||
});
|
||
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
|
||
session: buildMockMatch3DAgentSession(),
|
||
});
|
||
vi.mocked(match3dCreationClient.streamMessage).mockResolvedValue(
|
||
buildMockMatch3DAgentSession(),
|
||
);
|
||
vi.mocked(match3dCreationClient.executeAction).mockResolvedValue({
|
||
session: buildMockMatch3DAgentSession(),
|
||
});
|
||
vi.mocked(listMatch3DWorks).mockResolvedValue({
|
||
items: [],
|
||
});
|
||
vi.mocked(listMatch3DGallery).mockResolvedValue({
|
||
items: [],
|
||
});
|
||
vi.mocked(getMatch3DWorkDetail).mockRejectedValue(
|
||
new Error('未找到抓大鹅作品'),
|
||
);
|
||
vi.mocked(deleteMatch3DWork).mockResolvedValue({
|
||
items: [],
|
||
});
|
||
vi.mocked(startMatch3DRun).mockRejectedValue(
|
||
new Error('未启动抓大鹅运行态'),
|
||
);
|
||
vi.mocked(clickMatch3DItem).mockRejectedValue(
|
||
new Error('未执行抓大鹅点击'),
|
||
);
|
||
vi.mocked(restartMatch3DRun).mockRejectedValue(
|
||
new Error('未重新开始抓大鹅运行态'),
|
||
);
|
||
vi.mocked(finishMatch3DTimeUp).mockResolvedValue({
|
||
run: buildMockMatch3DRun('match3d-profile-time-up'),
|
||
});
|
||
vi.mocked(stopMatch3DRun).mockResolvedValue({
|
||
run: buildMockMatch3DRun('match3d-profile-stopped'),
|
||
});
|
||
vi.mocked(squareHoleCreationClient.createSession).mockResolvedValue({
|
||
session: buildMockSquareHoleAgentSession(),
|
||
});
|
||
vi.mocked(squareHoleCreationClient.getSession).mockResolvedValue({
|
||
session: buildMockSquareHoleAgentSession(),
|
||
});
|
||
vi.mocked(squareHoleCreationClient.streamMessage).mockResolvedValue(
|
||
buildMockSquareHoleAgentSession(),
|
||
);
|
||
vi.mocked(squareHoleCreationClient.executeAction).mockResolvedValue({
|
||
session: buildMockSquareHoleAgentSession(),
|
||
});
|
||
vi.mocked(listSquareHoleWorks).mockResolvedValue({
|
||
items: [],
|
||
});
|
||
vi.mocked(listSquareHoleGallery).mockResolvedValue({
|
||
items: [],
|
||
});
|
||
vi.mocked(getSquareHoleWorkDetail).mockRejectedValue(
|
||
new Error('未找到方洞挑战作品'),
|
||
);
|
||
vi.mocked(deleteSquareHoleWork).mockResolvedValue({
|
||
items: [],
|
||
});
|
||
vi.mocked(startSquareHoleRun).mockResolvedValue({
|
||
run: buildMockSquareHoleRun('square-hole-profile-1'),
|
||
});
|
||
vi.mocked(dropSquareHoleShape).mockResolvedValue({
|
||
feedback: {
|
||
accepted: true,
|
||
rejectReason: null,
|
||
message: '投入成功',
|
||
},
|
||
run: buildMockSquareHoleRun('square-hole-profile-1'),
|
||
});
|
||
vi.mocked(restartSquareHoleRun).mockResolvedValue({
|
||
run: buildMockSquareHoleRun('square-hole-profile-1'),
|
||
});
|
||
vi.mocked(finishSquareHoleTimeUp).mockResolvedValue({
|
||
run: buildMockSquareHoleRun('square-hole-profile-1'),
|
||
});
|
||
vi.mocked(stopSquareHoleRun).mockResolvedValue({
|
||
run: buildMockSquareHoleRun('square-hole-profile-1'),
|
||
});
|
||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||
items: [],
|
||
});
|
||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||
items: [],
|
||
});
|
||
vi.mocked(generatePuzzleOnboardingWork).mockResolvedValue({
|
||
item: {
|
||
workId: 'onboarding-work-1',
|
||
profileId: 'onboarding-profile-1',
|
||
ownerUserId: 'onboarding-guest',
|
||
sourceSessionId: null,
|
||
authorDisplayName: '百梦主',
|
||
workTitle: '梦境拼图',
|
||
workDescription: '我想飞上天',
|
||
levelName: '云上飞行',
|
||
summary: '我想飞上天',
|
||
themeTags: ['新手引导', '拼图'],
|
||
coverImageSrc: 'data:image/svg+xml;utf8,onboarding',
|
||
coverAssetId: 'onboarding-asset-1',
|
||
publicationStatus: 'draft',
|
||
updatedAt: '2026-05-05T12:00:00.000Z',
|
||
publishedAt: null,
|
||
playCount: 0,
|
||
remixCount: 0,
|
||
likeCount: 0,
|
||
publishReady: true,
|
||
levels: [],
|
||
},
|
||
level: {
|
||
levelId: 'onboarding-level-1',
|
||
levelName: '云上飞行',
|
||
pictureDescription: '我想飞上天',
|
||
pictureReference: null,
|
||
candidates: [
|
||
{
|
||
candidateId: 'onboarding-candidate-1',
|
||
imageSrc: 'data:image/svg+xml;utf8,onboarding',
|
||
assetId: 'onboarding-asset-1',
|
||
prompt: '我想飞上天',
|
||
actualPrompt: '我想飞上天',
|
||
sourceType: 'generated',
|
||
selected: true,
|
||
},
|
||
],
|
||
selectedCandidateId: 'onboarding-candidate-1',
|
||
coverImageSrc: 'data:image/svg+xml;utf8,onboarding',
|
||
coverAssetId: 'onboarding-asset-1',
|
||
generationStatus: 'ready',
|
||
},
|
||
});
|
||
vi.mocked(savePuzzleOnboardingWork).mockResolvedValue({
|
||
item: {
|
||
workId: 'onboarding-work-saved',
|
||
profileId: 'onboarding-profile-saved',
|
||
ownerUserId: mockAuthUser.id,
|
||
sourceSessionId: 'puzzle-session-onboarding',
|
||
authorDisplayName: mockAuthUser.displayName,
|
||
workTitle: '梦境拼图',
|
||
workDescription: '我想飞上天',
|
||
levelName: '云上飞行',
|
||
summary: '我想飞上天',
|
||
themeTags: ['新手引导', '拼图'],
|
||
coverImageSrc: 'data:image/svg+xml;utf8,onboarding',
|
||
coverAssetId: 'onboarding-asset-1',
|
||
publicationStatus: 'draft',
|
||
updatedAt: '2026-05-05T12:00:00.000Z',
|
||
publishedAt: null,
|
||
playCount: 0,
|
||
remixCount: 0,
|
||
likeCount: 0,
|
||
publishReady: true,
|
||
levels: [],
|
||
anchorPack: {
|
||
themePromise: {
|
||
key: 'theme_promise',
|
||
label: '主题承诺',
|
||
value: '新手引导',
|
||
status: 'confirmed',
|
||
},
|
||
visualSubject: {
|
||
key: 'visual_subject',
|
||
label: '视觉主体',
|
||
value: '云上飞行',
|
||
status: 'confirmed',
|
||
},
|
||
visualMood: {
|
||
key: 'visual_mood',
|
||
label: '视觉气质',
|
||
value: '明亮',
|
||
status: 'confirmed',
|
||
},
|
||
compositionHooks: {
|
||
key: 'composition_hooks',
|
||
label: '构图钩子',
|
||
value: '天空',
|
||
status: 'confirmed',
|
||
},
|
||
tagsAndForbidden: {
|
||
key: 'tags_and_forbidden',
|
||
label: '标签与禁区',
|
||
value: '拼图',
|
||
status: 'confirmed',
|
||
},
|
||
},
|
||
},
|
||
});
|
||
vi.mocked(remixPuzzleGalleryWork).mockRejectedValue(
|
||
new Error('未启用拼图 remix'),
|
||
);
|
||
vi.mocked(startPuzzleRun).mockImplementation(async (payload) => {
|
||
const run = buildMockPuzzleRun(payload.profileId, '后端拼图关卡');
|
||
return {
|
||
run: {
|
||
...run,
|
||
currentLevel: run.currentLevel
|
||
? {
|
||
...run.currentLevel,
|
||
levelId: payload.levelId ?? run.currentLevel.levelId,
|
||
startedAtMs: Date.now(),
|
||
}
|
||
: run.currentLevel,
|
||
},
|
||
};
|
||
});
|
||
vi.mocked(advancePuzzleNextLevel).mockImplementation(async (runId) => ({
|
||
run: buildMockPuzzleRun(`${runId}-next-profile`, '后端推荐下一关'),
|
||
}));
|
||
vi.mocked(getPuzzleRun).mockImplementation(async (runId) => ({
|
||
run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'),
|
||
}));
|
||
vi.mocked(updatePuzzleRunPause).mockImplementation(async (runId) => ({
|
||
run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'),
|
||
}));
|
||
vi.mocked(usePuzzleRuntimeProp).mockImplementation(async (runId) => ({
|
||
run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'),
|
||
}));
|
||
vi.mocked(submitPuzzleLeaderboard).mockImplementation(
|
||
async (runId, payload) => ({
|
||
run: {
|
||
...buildMockPuzzleRun(payload.profileId, '服务端排行榜快照'),
|
||
runId,
|
||
entryProfileId: payload.profileId,
|
||
leaderboardEntries: [
|
||
{
|
||
rank: 1,
|
||
nickname: payload.nickname,
|
||
elapsedMs: payload.elapsedMs,
|
||
isCurrentPlayer: true,
|
||
},
|
||
],
|
||
currentLevel: {
|
||
...buildMockPuzzleRun(payload.profileId, '服务端排行榜快照')
|
||
.currentLevel!,
|
||
runId,
|
||
profileId: payload.profileId,
|
||
gridSize: payload.gridSize,
|
||
leaderboardEntries: [
|
||
{
|
||
rank: 1,
|
||
nickname: payload.nickname,
|
||
elapsedMs: payload.elapsedMs,
|
||
isCurrentPlayer: true,
|
||
},
|
||
],
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
vi.mocked(dragLocalPuzzlePiece).mockImplementation((run) => run);
|
||
vi.mocked(swapLocalPuzzlePieces).mockImplementation((run) => run);
|
||
vi.mocked(executeRpgCreationAction).mockResolvedValue({
|
||
operation: {
|
||
operationId: 'operation-draft-foundation-1',
|
||
type: 'draft_foundation',
|
||
status: 'queued',
|
||
phaseLabel: '已接收请求',
|
||
phaseDetail: '正在准备生成世界底稿。',
|
||
progress: 10,
|
||
error: null,
|
||
},
|
||
});
|
||
vi.mocked(getRpgCreationOperation).mockResolvedValue({
|
||
operationId: 'operation-draft-foundation-1',
|
||
type: 'draft_foundation',
|
||
status: 'running',
|
||
phaseLabel: '生成世界底稿',
|
||
phaseDetail: '正在根据已确认锚点编译第一版世界结构。',
|
||
progress: 38,
|
||
error: null,
|
||
});
|
||
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
|
||
vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession);
|
||
vi.mocked(createCreativeAgentSession).mockResolvedValue({
|
||
session: buildMockCreativeAgentSession(),
|
||
});
|
||
vi.mocked(streamCreativeAgentMessage).mockImplementation(
|
||
async (sessionId, payload) =>
|
||
buildMockCreativeAgentSession({
|
||
sessionId,
|
||
stage: 'collaborating',
|
||
messages: [
|
||
{
|
||
id: 'creative-agent-message-1',
|
||
role: 'assistant',
|
||
kind: 'chat',
|
||
text: '说一个灵感,我来帮你做成互动内容。',
|
||
createdAt: '2026-05-05T10:00:00.000Z',
|
||
},
|
||
{
|
||
id: payload.clientMessageId,
|
||
role: 'user',
|
||
kind: 'chat',
|
||
text: payload.content
|
||
.map((part) =>
|
||
part.type === 'input_text' ? part.text.trim() : '参考图',
|
||
)
|
||
.filter(Boolean)
|
||
.join(' / '),
|
||
createdAt: '2026-05-05T10:01:00.000Z',
|
||
},
|
||
{
|
||
id: 'creative-agent-message-2',
|
||
role: 'assistant',
|
||
kind: 'chat',
|
||
text: '收到,我先帮你整理成可创作方案。',
|
||
createdAt: '2026-05-05T10:01:01.000Z',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
vi.mocked(cancelCreativeAgentSession).mockResolvedValue({
|
||
session: buildMockCreativeAgentSession({ stage: 'failed' }),
|
||
});
|
||
vi.mocked(confirmCreativePuzzleTemplate).mockResolvedValue({
|
||
session: buildMockCreativeAgentSession(),
|
||
});
|
||
vi.mocked(streamCreativeDraftEdit).mockResolvedValue(
|
||
buildMockCreativeAgentSession(),
|
||
);
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllEnvs();
|
||
});
|
||
|
||
test('create tab shows template tabs and embeds puzzle form by default', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openCreateTemplateHub(user);
|
||
|
||
expect(screen.getByRole('tablist', { name: '选择模板' })).toBeTruthy();
|
||
expect(
|
||
screen.getByRole('tab', { name: '拼图' }).getAttribute('aria-selected'),
|
||
).toBe('true');
|
||
expect(
|
||
screen.getByRole('tab', { name: '拼图' }).querySelector('img')?.src,
|
||
).toContain('/creation-type-references/puzzle.webp');
|
||
expect(
|
||
screen.getByRole('tab', { name: '方洞挑战' }).querySelector('img')?.src,
|
||
).toContain('/creation-type-references/square-hole.webp');
|
||
expect(
|
||
screen.getByRole('tab', { name: '视觉小说' }).querySelector('img')?.src,
|
||
).toContain('/creation-type-references/visual-novel.webp');
|
||
expect(
|
||
screen.getByRole('tab', { name: 'AIRP' }).querySelector('img')?.src,
|
||
).toContain('/creation-type-references/airp.webp');
|
||
expect(
|
||
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-white'),
|
||
).toBeTruthy();
|
||
expect(
|
||
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-inherit'),
|
||
).toBeNull();
|
||
expect(screen.queryByRole('button', { name: /智能创作/u })).toBeNull();
|
||
expect(screen.queryByPlaceholderText('问一问百梦')).toBeNull();
|
||
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
|
||
expect(screen.getByRole('tab', { name: /抓大鹅/u })).toBeTruthy();
|
||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('embedded puzzle form routes through requireAuth while logged out', async () => {
|
||
const user = userEvent.setup();
|
||
const requireAuth = vi.fn();
|
||
|
||
render(
|
||
<TestWrapper
|
||
authValue={createAuthValue({
|
||
user: null,
|
||
openLoginModal: () => {},
|
||
requireAuth,
|
||
})}
|
||
/>,
|
||
);
|
||
|
||
await openCreateTemplateHub(user);
|
||
const generateButton = await screen.findByRole('button', {
|
||
name: /生成草稿/u,
|
||
});
|
||
|
||
await user.click(generateButton);
|
||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||
expect(createCreativeAgentSession).not.toHaveBeenCalled();
|
||
expect(streamCreativeAgentMessage).not.toHaveBeenCalled();
|
||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
|
||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('opening RPG agent workspace does not refetch session snapshot in a render loop', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||
{
|
||
workId: 'draft:custom-world-agent-session-1',
|
||
sourceType: 'agent_session',
|
||
status: 'draft',
|
||
title: '潮雾列岛',
|
||
subtitle: '补齐关键锚点',
|
||
summary: '玩家是失职返乡的守灯人。',
|
||
coverImageSrc: null,
|
||
coverRenderMode: 'image',
|
||
coverCharacterImageSrcs: [],
|
||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||
publishedAt: null,
|
||
stage: 'clarifying',
|
||
stageLabel: '补齐关键锚点',
|
||
playableNpcCount: 0,
|
||
landmarkCount: 0,
|
||
roleVisualReadyCount: 0,
|
||
roleAnimationReadyCount: 0,
|
||
roleAssetSummaryLabel: null,
|
||
sessionId: 'custom-world-agent-session-1',
|
||
profileId: null,
|
||
canResume: true,
|
||
canEnterWorld: false,
|
||
},
|
||
]);
|
||
vi.mocked(getRpgCreationResultView).mockResolvedValue({
|
||
...buildResultViewForSession(mockSession),
|
||
targetStage: 'agent-workspace',
|
||
resultViewSource: null,
|
||
});
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openExistingRpgDraft(user, /继续创作/u);
|
||
|
||
expect(
|
||
await screen.findByText(
|
||
'Agent工作区:custom-world-agent-session-1',
|
||
{},
|
||
{ timeout: 5000 },
|
||
),
|
||
).toBeTruthy();
|
||
|
||
await new Promise((resolve) => {
|
||
window.setTimeout(resolve, 120);
|
||
});
|
||
|
||
expect(getRpgCreationSession).toHaveBeenCalledTimes(1);
|
||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('create tab opens compiled agent draft in result refinement page', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||
{
|
||
workId: 'draft:custom-world-agent-session-1',
|
||
sourceType: 'agent_session',
|
||
status: 'draft',
|
||
title: '潮雾列岛',
|
||
subtitle: '待完善草稿',
|
||
summary: '玩家是失职返乡的守灯人。',
|
||
coverImageSrc: null,
|
||
coverRenderMode: 'image',
|
||
coverCharacterImageSrcs: [],
|
||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||
publishedAt: null,
|
||
stage: 'object_refining',
|
||
stageLabel: '待完善草稿',
|
||
playableNpcCount: 3,
|
||
landmarkCount: 4,
|
||
roleVisualReadyCount: 1,
|
||
roleAnimationReadyCount: 0,
|
||
roleAssetSummaryLabel: '沈砺 · 主图已生成',
|
||
sessionId: 'custom-world-agent-session-1',
|
||
profileId: null,
|
||
canResume: true,
|
||
canEnterWorld: false,
|
||
},
|
||
]);
|
||
vi.mocked(getRpgCreationSession).mockResolvedValue(compiledAgentDraftSession);
|
||
vi.mocked(getRpgCreationResultView).mockResolvedValue(
|
||
buildResultViewForSession(compiledAgentDraftSession),
|
||
);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openDraftHub(user);
|
||
|
||
expect(await screen.findByRole('button', { name: /继续完善/u })).toBeTruthy();
|
||
|
||
await user.click(await screen.findByRole('button', { name: /继续完善/u }));
|
||
|
||
await waitFor(
|
||
() => {
|
||
expect(screen.queryByText('正在加载世界编辑器...')).toBeNull();
|
||
},
|
||
{ timeout: 5000 },
|
||
);
|
||
|
||
expect(
|
||
await screen.findByText('世界档案', {}, { timeout: 5000 }),
|
||
).toBeTruthy();
|
||
expect(
|
||
screen.queryByText('Agent工作区:custom-world-agent-session-1'),
|
||
).toBeNull();
|
||
expect(screen.getByRole('button', { name: /返回创作/u })).toBeTruthy();
|
||
}, 10000);
|
||
|
||
test('create tab resumes agent workspace when draft has no compiled result yet', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||
{
|
||
workId: 'draft:custom-world-agent-session-1',
|
||
sourceType: 'agent_session',
|
||
status: 'draft',
|
||
title: '潮雾列岛',
|
||
subtitle: '补齐关键锚点',
|
||
summary: '玩家是失职返乡的守灯人。',
|
||
coverImageSrc: null,
|
||
coverRenderMode: 'image',
|
||
coverCharacterImageSrcs: [],
|
||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||
publishedAt: null,
|
||
stage: 'clarifying',
|
||
stageLabel: '补齐关键锚点',
|
||
playableNpcCount: 0,
|
||
landmarkCount: 0,
|
||
roleVisualReadyCount: 0,
|
||
roleAnimationReadyCount: 0,
|
||
roleAssetSummaryLabel: null,
|
||
sessionId: 'custom-world-agent-session-1',
|
||
profileId: null,
|
||
canResume: true,
|
||
canEnterWorld: false,
|
||
},
|
||
]);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openDraftHub(user);
|
||
|
||
expect(await screen.findByRole('button', { name: /继续创作/u })).toBeTruthy();
|
||
|
||
await user.click(await screen.findByRole('button', { name: /继续创作/u }));
|
||
|
||
expect(
|
||
await screen.findByText('Agent工作区:custom-world-agent-session-1'),
|
||
).toBeTruthy();
|
||
expect(screen.queryByText('世界档案')).toBeNull();
|
||
});
|
||
|
||
test('create tab resumes agent workspace when session has no draft profile even if summary counts look compiled', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||
{
|
||
workId: 'draft:custom-world-agent-session-1',
|
||
sourceType: 'agent_session',
|
||
status: 'draft',
|
||
title: '潮雾列岛',
|
||
subtitle: '待完善草稿',
|
||
summary: '作品卡摘要仍带着旧对象数量,但服务端还没有草稿 profile。',
|
||
coverImageSrc: null,
|
||
coverRenderMode: 'image',
|
||
coverCharacterImageSrcs: [],
|
||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||
publishedAt: null,
|
||
stage: 'clarifying',
|
||
stageLabel: '补齐关键锚点',
|
||
playableNpcCount: 2,
|
||
landmarkCount: 1,
|
||
roleVisualReadyCount: 0,
|
||
roleAnimationReadyCount: 0,
|
||
roleAssetSummaryLabel: null,
|
||
sessionId: 'custom-world-agent-session-1',
|
||
profileId: null,
|
||
canResume: true,
|
||
canEnterWorld: false,
|
||
},
|
||
]);
|
||
vi.mocked(getRpgCreationSession).mockResolvedValue({
|
||
...mockSession,
|
||
stage: 'clarifying',
|
||
draftProfile: null,
|
||
});
|
||
vi.mocked(getRpgCreationResultView).mockResolvedValue(
|
||
buildResultViewForSession({
|
||
...mockSession,
|
||
stage: 'clarifying',
|
||
draftProfile: null,
|
||
}),
|
||
);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openDraftHub(user);
|
||
expect(await screen.findByRole('button', { name: /继续完善/u })).toBeTruthy();
|
||
await user.click(await screen.findByRole('button', { name: /继续完善/u }));
|
||
|
||
expect(
|
||
await screen.findByText('Agent工作区:custom-world-agent-session-1'),
|
||
).toBeTruthy();
|
||
expect(screen.queryByText('世界档案')).toBeNull();
|
||
});
|
||
|
||
test('opening a compiled draft with a missing agent session falls back to draft hub', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
vi.mocked(listRpgCreationWorks)
|
||
.mockResolvedValueOnce([
|
||
{
|
||
workId: 'draft:custom-world-agent-session-missing',
|
||
sourceType: 'agent_session',
|
||
status: 'draft',
|
||
title: '潮雾列岛',
|
||
subtitle: '世界底稿已生成',
|
||
summary: '这是一份已经整理过首版结果页的草稿。',
|
||
coverImageSrc: null,
|
||
coverRenderMode: 'image',
|
||
coverCharacterImageSrcs: [],
|
||
updatedAt: '2026-04-20T11:00:00.000Z',
|
||
publishedAt: null,
|
||
stage: 'object_refining',
|
||
stageLabel: '整理关键对象',
|
||
playableNpcCount: 1,
|
||
landmarkCount: 1,
|
||
roleVisualReadyCount: 0,
|
||
roleAnimationReadyCount: 0,
|
||
roleAssetSummaryLabel: null,
|
||
sessionId: 'custom-world-agent-session-missing',
|
||
profileId: null,
|
||
canResume: true,
|
||
canEnterWorld: false,
|
||
},
|
||
])
|
||
.mockResolvedValueOnce([]);
|
||
|
||
const missingSessionError = new ApiClientError({
|
||
message: 'custom world agent session not found',
|
||
status: 404,
|
||
code: 'NOT_FOUND',
|
||
});
|
||
vi.mocked(getRpgCreationSession).mockRejectedValueOnce(missingSessionError);
|
||
vi.mocked(getRpgCreationResultView).mockRejectedValueOnce(
|
||
missingSessionError,
|
||
);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openDraftHub(user);
|
||
await user.click(await screen.findByRole('button', { name: /继续完善/u }));
|
||
|
||
const fallbackDraftPanel = getPlatformTabPanel('saves');
|
||
await waitFor(() => {
|
||
expect(fallbackDraftPanel.getAttribute('aria-hidden')).toBe('false');
|
||
expect(
|
||
within(fallbackDraftPanel).getByText(
|
||
'这份共创草稿已失效,已为你返回草稿列表,请重新开始创作。',
|
||
),
|
||
).toBeTruthy();
|
||
});
|
||
|
||
expect(window.location.search).toBe('');
|
||
expect(listRpgCreationWorks).toHaveBeenCalledTimes(2);
|
||
expect(within(fallbackDraftPanel).getByText('还没有作品')).toBeTruthy();
|
||
expect(
|
||
screen.queryByText('Agent工作区:custom-world-agent-session-missing'),
|
||
).toBeNull();
|
||
});
|
||
|
||
test('clicking a public work while logged out opens public detail without starting RPG', async () => {
|
||
const user = userEvent.setup();
|
||
const requireAuth = vi.fn();
|
||
|
||
vi.mocked(listRpgEntryWorldGallery).mockResolvedValue([
|
||
{
|
||
ownerUserId: 'author-1',
|
||
profileId: 'world-public-1',
|
||
publicWorkCode: 'work-public-1',
|
||
authorPublicUserCode: 'author-1',
|
||
visibility: 'published',
|
||
publishedAt: '2026-04-16T12:00:00.000Z',
|
||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||
worldName: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summaryText: '最近公开发布的世界。',
|
||
coverImageSrc: null,
|
||
themeMode: 'tide',
|
||
authorDisplayName: '潮汐作者',
|
||
playableNpcCount: 3,
|
||
landmarkCount: 4,
|
||
likeCount: 0,
|
||
},
|
||
]);
|
||
|
||
render(
|
||
<TestWrapper
|
||
authValue={createAuthValue({
|
||
user: null,
|
||
openLoginModal: () => {},
|
||
requireAuth,
|
||
})}
|
||
/>,
|
||
);
|
||
|
||
const workCards = await screen.findAllByRole('button', {
|
||
name: /潮雾列岛/u,
|
||
});
|
||
await user.click(workCards[0]!);
|
||
|
||
expect(await screen.findByText('详情')).toBeTruthy();
|
||
expect(requireAuth).not.toHaveBeenCalled();
|
||
await user.click(screen.getByRole('button', { name: '启动' }));
|
||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||
expect(recordRpgEntryWorldGalleryPlay).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('logged out public detail gates puzzle start and remix before real actions', async () => {
|
||
const user = userEvent.setup();
|
||
const requireAuth = vi.fn();
|
||
const publishedPuzzleWork = {
|
||
workId: 'puzzle-work-public-1',
|
||
profileId: 'puzzle-profile-public-1',
|
||
ownerUserId: 'user-2',
|
||
sourceSessionId: null,
|
||
authorDisplayName: '拼图作者',
|
||
levelName: '星桥机关',
|
||
summary: '旋转碎片并接通星桥机关。',
|
||
themeTags: ['机关', '星桥'],
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
publicationStatus: 'published',
|
||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||
playCount: 3,
|
||
remixCount: 0,
|
||
likeCount: 0,
|
||
publishReady: true,
|
||
} satisfies PuzzleWorkSummary;
|
||
|
||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||
items: [publishedPuzzleWork],
|
||
});
|
||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||
item: publishedPuzzleWork,
|
||
});
|
||
|
||
render(
|
||
<TestWrapper
|
||
authValue={createAuthValue({
|
||
user: null,
|
||
canAccessProtectedData: false,
|
||
openLoginModal: () => {},
|
||
requireAuth,
|
||
})}
|
||
/>,
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
|
||
});
|
||
|
||
const workCards = screen.getAllByRole('button', { name: /星桥机关/u });
|
||
await user.click(workCards[0]!);
|
||
expect(await screen.findByText('详情')).toBeTruthy();
|
||
|
||
await user.click(screen.getByRole('button', { name: '启动' }));
|
||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||
expect(startPuzzleRun).not.toHaveBeenCalled();
|
||
|
||
await user.click(screen.getByRole('button', { name: '作品改造' }));
|
||
expect(requireAuth).toHaveBeenCalledTimes(2);
|
||
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('owned public puzzle detail edits original draft instead of remixing', async () => {
|
||
const user = userEvent.setup();
|
||
const ownedPuzzleWork = {
|
||
workId: 'puzzle-work-owned-1',
|
||
profileId: 'puzzle-profile-owned-1',
|
||
ownerUserId: mockAuthUser.id,
|
||
sourceSessionId: 'puzzle-session-1',
|
||
authorDisplayName: mockAuthUser.displayName,
|
||
levelName: '星桥机关',
|
||
summary: '旋转碎片并接通星桥机关。',
|
||
themeTags: ['机关', '星桥'],
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
publicationStatus: 'published',
|
||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||
playCount: 3,
|
||
remixCount: 0,
|
||
likeCount: 0,
|
||
publishReady: true,
|
||
} satisfies PuzzleWorkSummary;
|
||
|
||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||
items: [ownedPuzzleWork],
|
||
});
|
||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||
item: ownedPuzzleWork,
|
||
});
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openDiscoverHub(user);
|
||
await waitFor(() => {
|
||
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
|
||
});
|
||
|
||
const workCards = screen.getAllByRole('button', { name: /星桥机关/u });
|
||
await user.click(workCards[0]!);
|
||
expect(await screen.findByText('详情')).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: '作品编辑' })).toBeTruthy();
|
||
expect(screen.queryByRole('button', { name: '作品改造' })).toBeNull();
|
||
|
||
await user.click(screen.getByRole('button', { name: '作品编辑' }));
|
||
|
||
expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1');
|
||
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
|
||
expect(await screen.findByText('拼图结果页')).toBeTruthy();
|
||
});
|
||
|
||
test('logged out public detail gates big fish start before local runtime', async () => {
|
||
const user = userEvent.setup();
|
||
const requireAuth = vi.fn();
|
||
const bigFishWork: BigFishWorkSummary = {
|
||
workId: 'big-fish-work-public-1',
|
||
sourceSessionId: 'big-fish-session-public-1',
|
||
ownerUserId: 'user-2',
|
||
authorDisplayName: '大鱼作者',
|
||
title: '机械深海 大鱼吃小鱼',
|
||
subtitle: '机械微生物吞并进化',
|
||
summary: '从微光孢子一路吞并成长到深海巨鲲。',
|
||
coverImageSrc: null,
|
||
status: 'published',
|
||
updatedAt: '2026-04-25T10:30:00.000Z',
|
||
publishReady: true,
|
||
levelCount: 8,
|
||
levelMainImageReadyCount: 8,
|
||
levelMotionReadyCount: 16,
|
||
backgroundReady: true,
|
||
};
|
||
|
||
vi.mocked(listBigFishGallery).mockResolvedValue({
|
||
items: [bigFishWork],
|
||
});
|
||
|
||
render(
|
||
<TestWrapper
|
||
authValue={createAuthValue({
|
||
user: null,
|
||
canAccessProtectedData: false,
|
||
openLoginModal: () => {},
|
||
requireAuth,
|
||
})}
|
||
/>,
|
||
);
|
||
|
||
await openDiscoverHub(user);
|
||
const searchInput = await screen.findByPlaceholderText(
|
||
'搜索作品号、名称、作者、描述',
|
||
);
|
||
await user.type(searchInput, 'BF-NPUBLIC1');
|
||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||
|
||
expect(await screen.findByText('详情')).toBeTruthy();
|
||
await user.click(screen.getByRole('button', { name: '启动' }));
|
||
|
||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||
expect(startBigFishRun).not.toHaveBeenCalled();
|
||
expect(recordBigFishPlay).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('public code search blocks edutainment work when entry switch is disabled', async () => {
|
||
vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false');
|
||
const user = userEvent.setup();
|
||
const edutainmentPuzzleWork: PuzzleWorkSummary = {
|
||
workId: 'puzzle-work-edutainment-1',
|
||
profileId: 'puzzle-profile-edutainment-1',
|
||
ownerUserId: 'user-2',
|
||
sourceSessionId: 'puzzle-session-edutainment-1',
|
||
authorDisplayName: '动作 Demo 作者',
|
||
levelName: '儿童动作热身 Demo',
|
||
summary: '寓教于乐专属动作 Demo。',
|
||
themeTags: ['运动', '安全', '拼图', '寓教于乐'],
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
publicationStatus: 'published',
|
||
updatedAt: '2026-05-09T10:00:00.000Z',
|
||
publishedAt: '2026-05-09T10:00:00.000Z',
|
||
playCount: 3,
|
||
remixCount: 0,
|
||
likeCount: 0,
|
||
publishReady: true,
|
||
};
|
||
|
||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||
items: [edutainmentPuzzleWork],
|
||
});
|
||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||
item: edutainmentPuzzleWork,
|
||
});
|
||
|
||
render(<TestWrapper withAuth />);
|
||
await openDiscoverHub(user);
|
||
|
||
const searchInput = await screen.findByPlaceholderText(
|
||
'搜索作品号、名称、作者、描述',
|
||
);
|
||
await user.type(searchInput, 'PZ-TMENT1');
|
||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||
|
||
expect(await screen.findByText('未找到结果')).toBeTruthy();
|
||
expect(screen.queryByText('儿童动作热身 Demo')).toBeNull();
|
||
expect(getPuzzleGalleryDetail).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('creation hub clears all private work shelves immediately after logout state', async () => {
|
||
const user = userEvent.setup();
|
||
const loggedInAuth = createAuthValue();
|
||
const loggedOutAuth = createAuthValue({
|
||
user: null,
|
||
canAccessProtectedData: false,
|
||
openLoginModal: () => {},
|
||
requireAuth: () => {},
|
||
});
|
||
|
||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||
{
|
||
workId: 'draft:rpg-logout-cache-1',
|
||
sourceType: 'agent_session',
|
||
status: 'draft',
|
||
title: 'RPG 退出缓存作品',
|
||
subtitle: '登出后不应继续可见',
|
||
summary: '这条 RPG 私有作品只能在登录态展示。',
|
||
coverImageSrc: null,
|
||
coverRenderMode: 'image',
|
||
coverCharacterImageSrcs: [],
|
||
updatedAt: '2026-04-25T10:00:00.000Z',
|
||
publishedAt: null,
|
||
stage: 'clarifying',
|
||
stageLabel: '补齐关键锚点',
|
||
playableNpcCount: 0,
|
||
landmarkCount: 0,
|
||
roleVisualReadyCount: 0,
|
||
roleAnimationReadyCount: 0,
|
||
roleAssetSummaryLabel: null,
|
||
sessionId: 'rpg-logout-cache-session',
|
||
profileId: null,
|
||
canResume: true,
|
||
canEnterWorld: false,
|
||
},
|
||
]);
|
||
vi.mocked(listBigFishWorks).mockResolvedValue({
|
||
items: [
|
||
{
|
||
workId: 'big-fish-logout-cache-1',
|
||
sourceSessionId: 'big-fish-logout-cache-session',
|
||
ownerUserId: 'user-1',
|
||
authorDisplayName: '测试玩家',
|
||
title: '大鱼退出缓存作品',
|
||
subtitle: '登出后不应继续可见',
|
||
summary: '这条大鱼私有作品只能在登录态展示。',
|
||
coverImageSrc: null,
|
||
status: 'draft',
|
||
updatedAt: '2026-04-25T10:05:00.000Z',
|
||
publishReady: false,
|
||
levelCount: 8,
|
||
levelMainImageReadyCount: 0,
|
||
levelMotionReadyCount: 0,
|
||
backgroundReady: false,
|
||
},
|
||
],
|
||
});
|
||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||
items: [
|
||
{
|
||
workId: 'puzzle-logout-cache-1',
|
||
profileId: 'puzzle-logout-cache-profile',
|
||
ownerUserId: 'user-1',
|
||
sourceSessionId: 'puzzle-logout-cache-session',
|
||
authorDisplayName: '测试玩家',
|
||
levelName: '拼图退出缓存作品',
|
||
summary: '这条拼图私有作品只能在登录态展示。',
|
||
themeTags: ['退出态'],
|
||
coverImageSrc: null,
|
||
publicationStatus: 'draft',
|
||
updatedAt: '2026-04-25T10:10:00.000Z',
|
||
publishedAt: null,
|
||
playCount: 0,
|
||
likeCount: 0,
|
||
publishReady: false,
|
||
},
|
||
],
|
||
});
|
||
|
||
const { rerender } = render(<TestWrapper authValue={loggedInAuth} />);
|
||
|
||
await openDraftHub(user);
|
||
const draftPanel = getPlatformTabPanel('saves');
|
||
|
||
await waitFor(() => {
|
||
expect(listRpgCreationWorks).toHaveBeenCalled();
|
||
});
|
||
expect(await within(draftPanel).findByText('拼图退出缓存作品')).toBeTruthy();
|
||
expect(within(draftPanel).queryByText('RPG 退出缓存作品')).toBeNull();
|
||
expect(within(draftPanel).queryByText('大鱼退出缓存作品')).toBeNull();
|
||
|
||
rerender(<TestWrapper authValue={loggedOutAuth} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.queryByText('RPG 退出缓存作品')).toBeNull();
|
||
expect(screen.queryByText('拼图退出缓存作品')).toBeNull();
|
||
});
|
||
});
|
||
|
||
test('published puzzle works appear on home and mobile game category channel', async () => {
|
||
const user = userEvent.setup();
|
||
const publishedPuzzleWork = {
|
||
workId: 'puzzle-work-public-1',
|
||
profileId: 'puzzle-profile-public-1',
|
||
ownerUserId: 'user-2',
|
||
sourceSessionId: 'puzzle-session-public-1',
|
||
authorDisplayName: '拼图作者',
|
||
levelName: '星桥机关',
|
||
summary: '旋转碎片并接通星桥机关。',
|
||
themeTags: ['机关', '星桥'],
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
publicationStatus: 'published',
|
||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||
playCount: 3,
|
||
likeCount: 0,
|
||
publishReady: true,
|
||
} satisfies PuzzleWorkSummary;
|
||
|
||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||
items: [publishedPuzzleWork],
|
||
});
|
||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||
item: publishedPuzzleWork,
|
||
});
|
||
|
||
render(<TestWrapper />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
|
||
});
|
||
|
||
await clickFirstButtonByName(user, '发现');
|
||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||
|
||
const discoverPanel = getPlatformTabPanel('category');
|
||
expect(
|
||
within(discoverPanel).getAllByText('星桥机关').length,
|
||
).toBeGreaterThan(0);
|
||
expect(
|
||
within(discoverPanel).getAllByRole('button', { name: /机关/u }).length,
|
||
).toBeGreaterThan(0);
|
||
expect(screen.queryByRole('button', { name: 'PC游戏' })).toBeNull();
|
||
expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull();
|
||
});
|
||
|
||
test('home recommendation starts embedded puzzle without global auth reset on local failure', async () => {
|
||
const publishedPuzzleWork = {
|
||
workId: 'puzzle-work-public-1',
|
||
profileId: 'puzzle-profile-public-1',
|
||
ownerUserId: 'user-2',
|
||
sourceSessionId: 'puzzle-session-public-1',
|
||
authorDisplayName: '拼图作者',
|
||
levelName: '星桥机关',
|
||
summary: '旋转碎片并接通星桥机关。',
|
||
themeTags: ['机关', '星桥'],
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
publicationStatus: 'published',
|
||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||
playCount: 3,
|
||
likeCount: 0,
|
||
publishReady: true,
|
||
} satisfies PuzzleWorkSummary;
|
||
|
||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||
items: [publishedPuzzleWork],
|
||
});
|
||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||
item: publishedPuzzleWork,
|
||
});
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await waitFor(() => {
|
||
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||
{
|
||
profileId: 'puzzle-profile-public-1',
|
||
levelId: null,
|
||
},
|
||
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||
);
|
||
});
|
||
});
|
||
|
||
test('home recommendation surfaces start failure instead of staying in loading state', async () => {
|
||
const publishedPuzzleWork = {
|
||
workId: 'puzzle-work-public-1',
|
||
profileId: 'puzzle-profile-public-1',
|
||
ownerUserId: 'user-2',
|
||
sourceSessionId: 'puzzle-session-public-1',
|
||
authorDisplayName: '拼图作者',
|
||
levelName: '星桥机关',
|
||
summary: '旋转碎片并接通星桥机关。',
|
||
themeTags: ['机关', '星桥'],
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
publicationStatus: 'published',
|
||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||
playCount: 3,
|
||
likeCount: 0,
|
||
publishReady: true,
|
||
} satisfies PuzzleWorkSummary;
|
||
|
||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||
items: [publishedPuzzleWork],
|
||
});
|
||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||
item: publishedPuzzleWork,
|
||
});
|
||
vi.mocked(startPuzzleRun).mockRejectedValueOnce(
|
||
new Error('启动拼图玩法失败'),
|
||
);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
expect(
|
||
await screen.findByText('作品暂时无法进入,请稍后再试。'),
|
||
).toBeTruthy();
|
||
expect(screen.queryByText('加载中...')).toBeNull();
|
||
});
|
||
|
||
test('published big fish works stay hidden from platform home and game category channel', async () => {
|
||
const user = userEvent.setup();
|
||
const publishedBigFishWork: BigFishWorkSummary = {
|
||
workId: 'big-fish-work-public-1',
|
||
sourceSessionId: 'big-fish-session-public-1',
|
||
ownerUserId: 'user-2',
|
||
authorDisplayName: '大鱼作者',
|
||
title: '机械深海 大鱼吃小鱼',
|
||
subtitle: '机械微生物吞并进化',
|
||
summary: '从微光孢子一路吞并成长到深海巨鲲。',
|
||
coverImageSrc: null,
|
||
status: 'published',
|
||
updatedAt: '2026-04-25T10:30:00.000Z',
|
||
publishReady: true,
|
||
levelCount: 8,
|
||
levelMainImageReadyCount: 8,
|
||
levelMotionReadyCount: 16,
|
||
backgroundReady: true,
|
||
};
|
||
|
||
vi.mocked(listBigFishGallery).mockResolvedValue({
|
||
items: [publishedBigFishWork],
|
||
});
|
||
|
||
render(<TestWrapper />);
|
||
|
||
await waitFor(() => {
|
||
expect(listBigFishGallery).not.toHaveBeenCalled();
|
||
});
|
||
expect(screen.queryByText('机械深海 大鱼吃小鱼')).toBeNull();
|
||
|
||
await clickFirstButtonByName(user, '发现');
|
||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||
|
||
const discoverPanel = getPlatformTabPanel('category');
|
||
expect(within(discoverPanel).queryByText('机械深海 大鱼吃小鱼')).toBeNull();
|
||
expect(
|
||
within(discoverPanel).queryAllByRole('button', { name: /大鱼/u }).length,
|
||
).toBe(0);
|
||
});
|
||
|
||
test('published puzzle detail returns to the ranking platform tab', async () => {
|
||
const user = userEvent.setup();
|
||
const publishedPuzzleWork = {
|
||
workId: 'puzzle-work-public-1',
|
||
profileId: 'puzzle-profile-public-1',
|
||
ownerUserId: 'user-2',
|
||
sourceSessionId: null,
|
||
authorDisplayName: '拼图作者',
|
||
levelName: '星桥机关',
|
||
summary: '旋转碎片并接通星桥机关。',
|
||
themeTags: ['机关', '星桥'],
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
publicationStatus: 'published',
|
||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||
playCount: 3,
|
||
likeCount: 0,
|
||
publishReady: true,
|
||
} satisfies PuzzleWorkSummary;
|
||
|
||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||
items: [publishedPuzzleWork],
|
||
});
|
||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||
item: publishedPuzzleWork,
|
||
});
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await clickFirstButtonByName(user, '发现');
|
||
await user.click(await screen.findByRole('button', { name: '排行' }));
|
||
await waitFor(() => {
|
||
expect(document.getElementById('platform-tab-panel-category')).toBeTruthy();
|
||
});
|
||
await waitFor(() => {
|
||
const rankingPanel = getPlatformTabPanel('category');
|
||
expect(
|
||
within(rankingPanel).getAllByText('星桥机关').length,
|
||
).toBeGreaterThan(0);
|
||
});
|
||
const rankingPanel = getPlatformTabPanel('category');
|
||
|
||
await user.click(
|
||
within(rankingPanel).getByRole('button', {
|
||
name: /星桥机关/u,
|
||
}),
|
||
);
|
||
await user.click(await screen.findByRole('button', { name: '启动' }));
|
||
expect(await screen.findByTestId('puzzle-board')).toBeTruthy();
|
||
|
||
await user.click(screen.getByRole('button', { name: '返回上一页' }));
|
||
await user.click(
|
||
within(
|
||
await screen.findByRole('dialog', {
|
||
name: /体验不佳?\s*试试改造功能!/u,
|
||
}),
|
||
).getByRole('button', { name: '保存并退出' }),
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByRole('button', { name: '启动' })).toBeTruthy();
|
||
});
|
||
|
||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||
|
||
await waitFor(() => {
|
||
const returnedRankingPanel = getPlatformTabPanel('category');
|
||
expect(returnedRankingPanel.getAttribute('aria-hidden')).toBe('false');
|
||
expect(
|
||
within(returnedRankingPanel).getAllByText('星桥机关').length,
|
||
).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
|
||
test('restoring an agent workspace while logged out opens login modal before loading the protected session', async () => {
|
||
const openLoginModal = vi.fn();
|
||
|
||
window.history.replaceState(
|
||
null,
|
||
'',
|
||
'/?customWorldSessionId=custom-world-agent-session-1',
|
||
);
|
||
|
||
render(
|
||
<TestWrapper
|
||
authValue={createAuthValue({
|
||
user: null,
|
||
openLoginModal,
|
||
requireAuth: vi.fn(),
|
||
})}
|
||
/>,
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
expect(openLoginModal).toHaveBeenCalledWith(expect.any(Function));
|
||
expect(getRpgCreationSession).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('restoring an agent workspace ignores a stored session owned by another user', async () => {
|
||
window.sessionStorage.setItem(
|
||
'genarrative.custom-world-agent-ui.v1',
|
||
JSON.stringify({
|
||
activeSessionId: 'custom-world-agent-session-other-user',
|
||
activeOperationId: null,
|
||
ownerUserId: 'user-other',
|
||
}),
|
||
);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
window.sessionStorage.getItem('genarrative.custom-world-agent-ui.v1'),
|
||
).toBeNull();
|
||
});
|
||
|
||
expect(getRpgCreationSession).not.toHaveBeenCalled();
|
||
expect(window.location.search).toBe('');
|
||
});
|
||
|
||
test('restoring an agent workspace ignores explicit session pointer without local owner after login', async () => {
|
||
window.history.replaceState(
|
||
null,
|
||
'',
|
||
'/?customWorldSessionId=custom-world-agent-session-legacy',
|
||
);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await waitFor(() => {
|
||
expect(window.location.search).toBe('');
|
||
});
|
||
|
||
expect(getRpgCreationSession).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('refreshing platform home ignores stored agent workspace pointer without explicit restore path', async () => {
|
||
window.sessionStorage.setItem(
|
||
'genarrative.custom-world-agent-ui.v1',
|
||
JSON.stringify({
|
||
activeSessionId: 'custom-world-agent-session-1',
|
||
activeOperationId: null,
|
||
ownerUserId: 'user-1',
|
||
}),
|
||
);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
expect(await screen.findByRole('button', { name: '创作' })).toBeTruthy();
|
||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||
expect(getRpgCreationSession).not.toHaveBeenCalled();
|
||
expect(window.location.pathname).toBe('/');
|
||
});
|
||
|
||
test('refreshing RPG agent path restores stored agent workspace pointer', async () => {
|
||
window.history.replaceState(null, '', '/creation/rpg/agent');
|
||
window.sessionStorage.setItem(
|
||
'genarrative.custom-world-agent-ui.v1',
|
||
JSON.stringify({
|
||
activeSessionId: 'custom-world-agent-session-1',
|
||
activeOperationId: null,
|
||
ownerUserId: 'user-1',
|
||
}),
|
||
);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await waitFor(() => {
|
||
expect(getRpgCreationSession).toHaveBeenCalledWith(
|
||
'custom-world-agent-session-1',
|
||
);
|
||
});
|
||
expect(
|
||
await screen.findByText('Agent工作区:custom-world-agent-session-1'),
|
||
).toBeTruthy();
|
||
});
|
||
|
||
test('embedded puzzle form maps raw bearer token errors to user-facing auth copy', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
vi.mocked(createPuzzleAgentSession).mockRejectedValueOnce(
|
||
new ApiClientError({
|
||
message: '缺少 Authorization Bearer Token',
|
||
status: 401,
|
||
code: 'UNAUTHORIZED',
|
||
}),
|
||
);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openCreateTemplateHub(user);
|
||
const generateButton = screen.getByRole('button', { name: /生成草稿/u });
|
||
|
||
expect((generateButton as HTMLButtonElement).disabled).toBe(false);
|
||
await user.click(generateButton);
|
||
|
||
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
|
||
expect(createCreativeAgentSession).not.toHaveBeenCalled();
|
||
expect(
|
||
await screen.findByText(
|
||
'当前登录状态已失效,请重新登录后继续。',
|
||
),
|
||
).toBeTruthy();
|
||
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
|
||
});
|
||
|
||
test('create tab does not render legacy gameplay creation entries', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openCreateTemplateHub(user);
|
||
|
||
expect(screen.queryByText('选择创作类型')).toBeNull();
|
||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||
expect(createBigFishCreationSession).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('embedded puzzle form timeout exits busy state and shows a readable error', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
vi.mocked(createPuzzleAgentSession).mockRejectedValueOnce(
|
||
Object.assign(new Error('请求超时:15000ms'), {
|
||
name: 'TimeoutError',
|
||
}),
|
||
);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openCreateTemplateHub(user);
|
||
|
||
const button = screen.getByRole('button', { name: /生成草稿/u });
|
||
await user.click(button);
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
screen.getAllByText(
|
||
'开启拼图创作工作台超时,请确认运行时后端已启动后重试。',
|
||
).length,
|
||
).toBeGreaterThan(0);
|
||
});
|
||
expect(button as HTMLButtonElement).toHaveProperty('disabled', false);
|
||
expect(createCreativeAgentSession).not.toHaveBeenCalled();
|
||
expect(streamCreativeAgentMessage).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('hidden match3d creation card stays closed even when public galleries fail', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
vi.mocked(listRpgEntryWorldGallery).mockRejectedValueOnce(
|
||
new Error('读取作品广场失败'),
|
||
);
|
||
vi.mocked(listMatch3DGallery).mockRejectedValueOnce(
|
||
new Error('读取抓大鹅广场失败'),
|
||
);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openCreateTemplateHub(user);
|
||
expect(screen.queryByText('读取作品广场失败')).toBeNull();
|
||
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
|
||
expect(
|
||
screen.queryByRole('tab', { name: /抓大鹅.*经典消除玩法/u }),
|
||
).toBeNull();
|
||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('puzzle draft result back button returns to creation hub', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||
items: [
|
||
{
|
||
workId: 'puzzle-work-session-1',
|
||
profileId: 'puzzle-profile-session-1',
|
||
ownerUserId: 'user-1',
|
||
sourceSessionId: 'puzzle-session-1',
|
||
authorDisplayName: '测试玩家',
|
||
levelName: '雨夜猫塔',
|
||
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
|
||
themeTags: ['雨夜', '猫咪', '遗迹'],
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
publicationStatus: 'draft',
|
||
updatedAt: '2026-04-22T12:10:00.000Z',
|
||
publishedAt: null,
|
||
playCount: 0,
|
||
likeCount: 0,
|
||
publishReady: false,
|
||
},
|
||
],
|
||
});
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openDraftHub(user);
|
||
|
||
expect(await screen.findByRole('button', { name: /继续创作/u })).toBeTruthy();
|
||
await user.click(await screen.findByRole('button', { name: /继续创作/u }));
|
||
|
||
await waitFor(() => {
|
||
expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1');
|
||
});
|
||
|
||
expect(await screen.findByText('拼图结果页')).toBeTruthy();
|
||
|
||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||
|
||
expect(
|
||
await screen.findByRole('tablist', { name: '选择模板' }),
|
||
).toBeTruthy();
|
||
expect(screen.getByText('雨夜里有一只会发光的猫站在遗迹台阶上。')).toBeTruthy();
|
||
expect(screen.queryByText('拼图结果页')).toBeNull();
|
||
});
|
||
|
||
test('published puzzle work card restores its source session for editing', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||
items: [
|
||
{
|
||
workId: 'puzzle-work-session-1',
|
||
profileId: 'puzzle-profile-session-1',
|
||
ownerUserId: 'user-1',
|
||
sourceSessionId: 'puzzle-session-1',
|
||
authorDisplayName: '测试玩家',
|
||
levelName: '雨夜猫塔',
|
||
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
|
||
themeTags: ['雨夜', '猫咪', '遗迹'],
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
publicationStatus: 'published',
|
||
updatedAt: '2026-04-25T12:10:00.000Z',
|
||
publishedAt: '2026-04-25T12:10:00.000Z',
|
||
playCount: 8,
|
||
likeCount: 0,
|
||
publishReady: true,
|
||
},
|
||
],
|
||
});
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openDraftHub(user);
|
||
|
||
expect(await screen.findByRole('button', { name: /继续创作/u })).toBeTruthy();
|
||
await user.click(await screen.findByRole('button', { name: /继续创作/u }));
|
||
|
||
await waitFor(() => {
|
||
expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1');
|
||
});
|
||
|
||
expect(getPuzzleGalleryDetail).not.toHaveBeenCalledWith(
|
||
'puzzle-profile-session-1',
|
||
);
|
||
expect(await screen.findByText('拼图结果页')).toBeTruthy();
|
||
expect(screen.getByDisplayValue('雨夜猫塔')).toBeTruthy();
|
||
});
|
||
|
||
test('first launch puzzle onboarding can be skipped from top right', async () => {
|
||
const user = userEvent.setup();
|
||
window.localStorage.removeItem(
|
||
'genarrative.puzzle-onboarding.first-visit.v1',
|
||
);
|
||
|
||
render(
|
||
<TestWrapper
|
||
authValue={createAuthValue({
|
||
user: null,
|
||
canAccessProtectedData: false,
|
||
openLoginModal: () => {},
|
||
requireAuth: () => {},
|
||
})}
|
||
/>,
|
||
);
|
||
|
||
expect(await screen.findByText('待定待定待定')).toBeTruthy();
|
||
await user.click(screen.getByRole('button', { name: '跳过' }));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.queryByText('待定待定待定')).toBeNull();
|
||
});
|
||
expect(
|
||
window.localStorage.getItem(
|
||
'genarrative.puzzle-onboarding.first-visit.v1',
|
||
),
|
||
).toBe('1');
|
||
expect(generatePuzzleOnboardingWork).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('first launch puzzle onboarding falls back to local run when generate route is missing', async () => {
|
||
const user = userEvent.setup();
|
||
window.localStorage.removeItem(
|
||
'genarrative.puzzle-onboarding.first-visit.v1',
|
||
);
|
||
vi.mocked(generatePuzzleOnboardingWork).mockRejectedValueOnce(
|
||
new ApiClientError({
|
||
message: '资源不存在',
|
||
status: 404,
|
||
code: 'NOT_FOUND',
|
||
}),
|
||
);
|
||
|
||
render(
|
||
<TestWrapper
|
||
authValue={createAuthValue({
|
||
user: null,
|
||
canAccessProtectedData: false,
|
||
openLoginModal: () => {},
|
||
requireAuth: () => {},
|
||
})}
|
||
/>,
|
||
);
|
||
|
||
await user.type(
|
||
await screen.findByPlaceholderText('把你的梦讲给我听吧'),
|
||
'我想飞上天',
|
||
);
|
||
await user.click(screen.getByRole('button', { name: '生成' }));
|
||
|
||
expect(
|
||
await screen.findByTestId('puzzle-board', undefined, { timeout: 3000 }),
|
||
).toBeTruthy();
|
||
expect(generatePuzzleOnboardingWork).toHaveBeenCalledWith({
|
||
promptText: '我想飞上天',
|
||
});
|
||
expect(screen.queryByText('资源不存在')).toBeNull();
|
||
expect(startPuzzleRun).not.toHaveBeenCalled();
|
||
expect(
|
||
window.localStorage.getItem(
|
||
'genarrative.puzzle-onboarding.first-visit.v1',
|
||
),
|
||
).toBe('1');
|
||
});
|
||
|
||
test('formal puzzle runtime uses frontend move merge logic and backend leaderboard next level', async () => {
|
||
const user = userEvent.setup();
|
||
const clearedFirstLevel = buildClearedPuzzleRun({
|
||
runId: 'run-puzzle-profile-public-1',
|
||
entryProfileId: 'puzzle-profile-public-1',
|
||
profileId: 'puzzle-profile-public-1',
|
||
levelName: '雨夜猫塔',
|
||
levelIndex: 1,
|
||
elapsedMs: 18_000,
|
||
});
|
||
const clearedFirstLevelWithNext = {
|
||
...clearedFirstLevel,
|
||
recommendedNextProfileId: 'puzzle-profile-public-1',
|
||
nextLevelMode: 'sameWork' as const,
|
||
nextLevelProfileId: 'puzzle-profile-public-1',
|
||
nextLevelId: 'puzzle-level-2',
|
||
recommendedNextWorks: [],
|
||
};
|
||
const leaderboardEntries = [
|
||
{
|
||
rank: 1,
|
||
nickname: '测试玩家',
|
||
elapsedMs: 18_000,
|
||
isCurrentPlayer: true,
|
||
},
|
||
];
|
||
const backendLeaderboardRun = {
|
||
...clearedFirstLevelWithNext,
|
||
leaderboardEntries,
|
||
currentLevel: {
|
||
...clearedFirstLevelWithNext.currentLevel!,
|
||
leaderboardEntries,
|
||
},
|
||
};
|
||
const backendSecondLevel = {
|
||
...buildMockPuzzleRun('puzzle-profile-public-1', '星桥机关'),
|
||
runId: clearedFirstLevel.runId,
|
||
entryProfileId: clearedFirstLevel.entryProfileId,
|
||
currentLevelIndex: 2,
|
||
currentLevel: {
|
||
...buildMockPuzzleRun('puzzle-profile-public-1', '星桥机关')
|
||
.currentLevel!,
|
||
runId: clearedFirstLevel.runId,
|
||
levelIndex: 2,
|
||
levelId: 'puzzle-level-2',
|
||
startedAtMs: Date.now(),
|
||
},
|
||
};
|
||
const backendStartedRun = buildMockPuzzleRun(
|
||
'puzzle-profile-public-1',
|
||
'雨夜猫塔',
|
||
);
|
||
vi.mocked(startPuzzleRun).mockResolvedValue({
|
||
run: {
|
||
...backendStartedRun,
|
||
currentLevel: {
|
||
...backendStartedRun.currentLevel!,
|
||
startedAtMs: Date.now(),
|
||
},
|
||
},
|
||
});
|
||
vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({
|
||
run: backendLeaderboardRun,
|
||
});
|
||
vi.mocked(advancePuzzleNextLevel).mockResolvedValue({
|
||
run: backendSecondLevel,
|
||
});
|
||
vi.mocked(dragLocalPuzzlePiece).mockReturnValue(clearedFirstLevel);
|
||
vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedFirstLevel);
|
||
|
||
const puzzleWork: PuzzleWorkSummary = {
|
||
workId: 'puzzle-work-public-1',
|
||
profileId: 'puzzle-profile-public-1',
|
||
ownerUserId: 'user-2',
|
||
sourceSessionId: null,
|
||
authorDisplayName: '拼图作者',
|
||
levelName: '雨夜猫塔',
|
||
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
|
||
themeTags: ['雨夜', '猫咪', '遗迹'],
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
publicationStatus: 'published',
|
||
updatedAt: '2026-04-25T12:10:00.000Z',
|
||
publishedAt: '2026-04-25T12:10:00.000Z',
|
||
playCount: 8,
|
||
likeCount: 0,
|
||
publishReady: true,
|
||
levels: [
|
||
{
|
||
levelId: 'puzzle-level-1',
|
||
levelName: '雨夜猫塔',
|
||
pictureDescription: '雨夜猫塔首关。',
|
||
candidates: [],
|
||
selectedCandidateId: null,
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
generationStatus: 'ready',
|
||
},
|
||
{
|
||
levelId: 'puzzle-level-2',
|
||
levelName: '星桥机关',
|
||
pictureDescription: '星桥机关第二关。',
|
||
candidates: [],
|
||
selectedCandidateId: null,
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
generationStatus: 'ready',
|
||
},
|
||
],
|
||
};
|
||
|
||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||
items: [puzzleWork],
|
||
});
|
||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||
item: puzzleWork,
|
||
});
|
||
render(<TestWrapper withAuth />);
|
||
await openDiscoverHub(user);
|
||
|
||
const searchInput = await screen.findByPlaceholderText(
|
||
'搜索作品号、名称、作者、描述',
|
||
);
|
||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||
|
||
await user.click(await screen.findByRole('button', { name: '启动' }));
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
|
||
});
|
||
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||
{
|
||
profileId: 'puzzle-profile-public-1',
|
||
levelId: null,
|
||
},
|
||
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||
);
|
||
vi.mocked(listProfileSaveArchives).mockClear();
|
||
vi.mocked(listProfileSaveArchives).mockRejectedValueOnce(
|
||
new Error('后台存档刷新 401'),
|
||
);
|
||
|
||
await user.click(document.querySelector('[data-piece-id="piece-0"]')!);
|
||
await user.click(document.querySelector('[data-piece-id="piece-1"]')!);
|
||
|
||
await waitFor(() => {
|
||
expect(swapLocalPuzzlePieces).toHaveBeenCalled();
|
||
});
|
||
expect(swapPuzzlePieces).not.toHaveBeenCalled();
|
||
expect(dragPuzzlePieceOrGroup).not.toHaveBeenCalled();
|
||
await waitFor(() => {
|
||
expect(submitPuzzleLeaderboard).toHaveBeenCalledWith(
|
||
clearedFirstLevel.runId,
|
||
{
|
||
profileId: 'puzzle-profile-public-1',
|
||
gridSize: 3,
|
||
elapsedMs: 18_000,
|
||
nickname: '测试玩家',
|
||
},
|
||
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||
);
|
||
});
|
||
|
||
const dialog = await screen.findByRole(
|
||
'dialog',
|
||
{ name: '通关完成' },
|
||
{ timeout: 3000 },
|
||
);
|
||
expect(dialog).toBeTruthy();
|
||
expect(screen.getByText('测试玩家')).toBeTruthy();
|
||
expect(listProfileSaveArchives).toHaveBeenCalledWith(
|
||
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||
);
|
||
|
||
await user.click(within(dialog).getByRole('button', { name: '下一关' }));
|
||
|
||
await waitFor(() => {
|
||
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
|
||
clearedFirstLevel.runId,
|
||
{},
|
||
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||
);
|
||
});
|
||
expect(
|
||
(await screen.findAllByText('星桥机关', undefined, {
|
||
timeout: 3000,
|
||
})).length,
|
||
).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('formal puzzle similar work keeps current run level progression', async () => {
|
||
const user = userEvent.setup();
|
||
const clearedThirdLevel = buildClearedPuzzleRun({
|
||
runId: 'run-puzzle-profile-public-1',
|
||
entryProfileId: 'puzzle-profile-public-1',
|
||
profileId: 'puzzle-profile-public-1',
|
||
levelName: '雨夜猫塔',
|
||
levelIndex: 3,
|
||
elapsedMs: 18_000,
|
||
recommendedNextProfileId: 'puzzle-profile-similar-2',
|
||
});
|
||
const clearedThirdLevelWithCandidates: PuzzleRunSnapshot = {
|
||
...clearedThirdLevel,
|
||
nextLevelMode: 'similarWorks',
|
||
nextLevelProfileId: 'puzzle-profile-similar-1',
|
||
nextLevelId: null,
|
||
recommendedNextWorks: [
|
||
{
|
||
profileId: 'puzzle-profile-similar-1',
|
||
levelName: '雾海遗迹',
|
||
authorDisplayName: '星桥旅人',
|
||
themeTags: ['奇幻', '遗迹'],
|
||
coverImageSrc: null,
|
||
similarityScore: 0.91,
|
||
},
|
||
{
|
||
profileId: 'puzzle-profile-similar-2',
|
||
levelName: '风塔试炼',
|
||
authorDisplayName: '晨风',
|
||
themeTags: ['奇幻', '机关'],
|
||
coverImageSrc: null,
|
||
similarityScore: 0.84,
|
||
},
|
||
],
|
||
};
|
||
const similarFourthLevel = {
|
||
...buildMockPuzzleRun('puzzle-profile-similar-2', '风塔试炼'),
|
||
runId: clearedThirdLevel.runId,
|
||
entryProfileId: clearedThirdLevel.entryProfileId,
|
||
currentLevelIndex: 4,
|
||
currentGridSize: 5 as const,
|
||
playedProfileIds: [
|
||
'puzzle-profile-public-1',
|
||
'puzzle-profile-similar-2',
|
||
],
|
||
currentLevel: {
|
||
...buildMockPuzzleRun('puzzle-profile-similar-2', '风塔试炼')
|
||
.currentLevel!,
|
||
runId: clearedThirdLevel.runId,
|
||
levelIndex: 4,
|
||
levelId: 'similar-level-1',
|
||
gridSize: 5 as const,
|
||
timeLimitMs: 210_000,
|
||
remainingMs: 210_000,
|
||
startedAtMs: Date.now(),
|
||
board: {
|
||
rows: 5,
|
||
cols: 5,
|
||
selectedPieceId: null,
|
||
allTilesResolved: false,
|
||
mergedGroups: [],
|
||
pieces: Array.from({ length: 25 }, (_, index) => ({
|
||
pieceId: `piece-${index}`,
|
||
correctRow: Math.floor(index / 5),
|
||
correctCol: index % 5,
|
||
currentRow: Math.floor(index / 5),
|
||
currentCol: index % 5,
|
||
mergedGroupId: null,
|
||
})),
|
||
},
|
||
},
|
||
};
|
||
const backendStartedRun = buildMockPuzzleRun(
|
||
'puzzle-profile-public-1',
|
||
'雨夜猫塔',
|
||
);
|
||
vi.mocked(startPuzzleRun).mockResolvedValue({
|
||
run: {
|
||
...backendStartedRun,
|
||
currentLevel: {
|
||
...backendStartedRun.currentLevel!,
|
||
startedAtMs: Date.now(),
|
||
},
|
||
},
|
||
});
|
||
vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({
|
||
run: clearedThirdLevelWithCandidates,
|
||
});
|
||
vi.mocked(advancePuzzleNextLevel).mockResolvedValue({
|
||
run: similarFourthLevel,
|
||
});
|
||
vi.mocked(dragLocalPuzzlePiece).mockReturnValue(clearedThirdLevel);
|
||
vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedThirdLevel);
|
||
|
||
const entryWork: PuzzleWorkSummary = {
|
||
workId: 'puzzle-work-public-1',
|
||
profileId: 'puzzle-profile-public-1',
|
||
ownerUserId: 'user-2',
|
||
sourceSessionId: null,
|
||
authorDisplayName: '拼图作者',
|
||
levelName: '雨夜猫塔',
|
||
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
|
||
themeTags: ['雨夜', '猫咪', '遗迹'],
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
publicationStatus: 'published',
|
||
updatedAt: '2026-04-25T12:10:00.000Z',
|
||
publishedAt: '2026-04-25T12:10:00.000Z',
|
||
playCount: 8,
|
||
likeCount: 0,
|
||
publishReady: true,
|
||
};
|
||
const similarWork: PuzzleWorkSummary = {
|
||
...entryWork,
|
||
workId: 'puzzle-work-similar-2',
|
||
profileId: 'puzzle-profile-similar-2',
|
||
levelName: '风塔试炼',
|
||
summary: '另一套奇幻机关拼图。',
|
||
};
|
||
|
||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||
items: [entryWork],
|
||
});
|
||
vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({
|
||
item: profileId === similarWork.profileId ? similarWork : entryWork,
|
||
}));
|
||
render(<TestWrapper withAuth />);
|
||
await openDiscoverHub(user);
|
||
|
||
const searchInput = await screen.findByPlaceholderText(
|
||
'搜索作品号、名称、作者、描述',
|
||
);
|
||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||
|
||
await user.click(await screen.findByRole('button', { name: '启动' }));
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
|
||
});
|
||
vi.mocked(startPuzzleRun).mockClear();
|
||
|
||
await user.click(document.querySelector('[data-piece-id="piece-0"]')!);
|
||
await user.click(document.querySelector('[data-piece-id="piece-1"]')!);
|
||
|
||
const dialog = await screen.findByRole(
|
||
'dialog',
|
||
{ name: '通关完成' },
|
||
{ timeout: 3000 },
|
||
);
|
||
await user.click(within(dialog).getByRole('button', { name: /风塔试炼/u }));
|
||
|
||
await waitFor(() => {
|
||
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
|
||
clearedThirdLevel.runId,
|
||
{ targetProfileId: 'puzzle-profile-similar-2' },
|
||
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||
);
|
||
});
|
||
expect(startPuzzleRun).not.toHaveBeenCalled();
|
||
expect(await screen.findByText('第 4 关')).toBeTruthy();
|
||
await waitFor(() => {
|
||
expect(document.querySelectorAll('[data-piece-id]').length).toBe(25);
|
||
});
|
||
});
|
||
|
||
test('first puzzle runtime back click can open remix result page', async () => {
|
||
const user = userEvent.setup();
|
||
const puzzleWork: PuzzleWorkSummary = {
|
||
workId: 'puzzle-work-public-1',
|
||
profileId: 'puzzle-profile-public-1',
|
||
ownerUserId: 'user-2',
|
||
sourceSessionId: null,
|
||
authorDisplayName: '拼图作者',
|
||
levelName: '雨夜猫塔',
|
||
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
|
||
themeTags: ['雨夜', '猫咪', '遗迹'],
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
publicationStatus: 'published',
|
||
updatedAt: '2026-04-25T12:10:00.000Z',
|
||
publishedAt: '2026-04-25T12:10:00.000Z',
|
||
playCount: 8,
|
||
remixCount: 0,
|
||
likeCount: 0,
|
||
publishReady: true,
|
||
};
|
||
const anchorPack = buildPuzzleAnchorPack();
|
||
const remixDraft: PuzzleResultDraft = {
|
||
workTitle: '改造后的雨夜猫塔',
|
||
workDescription: '准备改造的拼图草稿。',
|
||
levelName: '改造后的雨夜猫塔',
|
||
summary: '一只猫站在雨夜塔顶。',
|
||
themeTags: ['雨夜', '猫咪', '塔'],
|
||
forbiddenDirectives: [],
|
||
creatorIntent: null,
|
||
anchorPack,
|
||
candidates: [],
|
||
selectedCandidateId: null,
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
generationStatus: 'idle',
|
||
levels: [],
|
||
metadata: null,
|
||
};
|
||
const remixSession: PuzzleAgentSessionSnapshot = {
|
||
sessionId: 'puzzle-session-remix-1',
|
||
currentTurn: 1,
|
||
progressPercent: 100,
|
||
stage: 'ready_to_publish',
|
||
anchorPack,
|
||
draft: remixDraft,
|
||
messages: [],
|
||
lastAssistantReply: null,
|
||
publishedProfileId: null,
|
||
suggestedActions: [],
|
||
resultPreview: null,
|
||
updatedAt: '2026-04-25T12:12:00.000Z',
|
||
};
|
||
|
||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||
items: [puzzleWork],
|
||
});
|
||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||
item: puzzleWork,
|
||
});
|
||
vi.mocked(remixPuzzleGalleryWork).mockResolvedValue({
|
||
session: remixSession,
|
||
});
|
||
|
||
render(<TestWrapper withAuth />);
|
||
await openDiscoverHub(user);
|
||
|
||
const searchInput = await screen.findByPlaceholderText(
|
||
'搜索作品号、名称、作者、描述',
|
||
);
|
||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||
await user.click(await screen.findByRole('button', { name: '启动' }));
|
||
expect(await screen.findByTestId('puzzle-board')).toBeTruthy();
|
||
await user.click(await screen.findByRole('button', { name: '返回上一页' }));
|
||
|
||
const dialog = await screen.findByRole('dialog', {
|
||
name: /体验不佳?\s*试试改造功能!/u,
|
||
});
|
||
await user.click(within(dialog).getByRole('button', { name: '作品改造' }));
|
||
|
||
await waitFor(() => {
|
||
expect(remixPuzzleGalleryWork).toHaveBeenCalledWith(
|
||
'puzzle-profile-public-1',
|
||
);
|
||
});
|
||
expect(await screen.findByText('拼图结果页')).toBeTruthy();
|
||
expect(screen.getByDisplayValue('改造后的雨夜猫塔')).toBeTruthy();
|
||
});
|
||
|
||
test('public code search opens a published puzzle by PZ code', async () => {
|
||
const user = userEvent.setup();
|
||
const puzzleWork: PuzzleWorkSummary = {
|
||
workId: 'puzzle-work-public-1',
|
||
profileId: 'puzzle-profile-public-1',
|
||
ownerUserId: 'user-2',
|
||
sourceSessionId: null,
|
||
authorDisplayName: '拼图作者',
|
||
levelName: '雨夜猫塔',
|
||
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
|
||
themeTags: ['雨夜', '猫咪', '遗迹'],
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
publicationStatus: 'published',
|
||
updatedAt: '2026-04-25T12:10:00.000Z',
|
||
publishedAt: '2026-04-25T12:10:00.000Z',
|
||
playCount: 8,
|
||
likeCount: 0,
|
||
publishReady: true,
|
||
};
|
||
|
||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||
items: [puzzleWork],
|
||
});
|
||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||
item: puzzleWork,
|
||
});
|
||
|
||
render(<TestWrapper withAuth />);
|
||
await openDiscoverHub(user);
|
||
|
||
const searchInput = await screen.findByPlaceholderText(
|
||
'搜索作品号、名称、作者、描述',
|
||
);
|
||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||
|
||
await waitFor(() => {
|
||
expect(getPuzzleGalleryDetail).toHaveBeenCalledWith(
|
||
'puzzle-profile-public-1',
|
||
);
|
||
});
|
||
expect(await screen.findByText('详情')).toBeTruthy();
|
||
expect(screen.getByText('雨夜猫塔')).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: '启动' })).toBeTruthy();
|
||
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('missing puzzle public detail returns to platform home', async () => {
|
||
const user = userEvent.setup();
|
||
const missingPuzzleWork = {
|
||
workId: 'puzzle-work-missing-1',
|
||
profileId: 'puzzle-profile-missing-1',
|
||
ownerUserId: 'user-2',
|
||
sourceSessionId: null,
|
||
authorDisplayName: '拼图作者',
|
||
levelName: '失效拼图',
|
||
summary: '这个作品已经不可用。',
|
||
themeTags: ['失效'],
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
publicationStatus: 'published',
|
||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||
playCount: 1,
|
||
remixCount: 0,
|
||
likeCount: 0,
|
||
publishReady: true,
|
||
} satisfies PuzzleWorkSummary;
|
||
|
||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||
items: [missingPuzzleWork],
|
||
});
|
||
vi.mocked(getPuzzleGalleryDetail).mockRejectedValueOnce(
|
||
new ApiClientError({
|
||
message: '资源不存在',
|
||
status: 404,
|
||
code: 'NOT_FOUND',
|
||
}),
|
||
);
|
||
|
||
render(<TestWrapper />);
|
||
await openDiscoverHub(user);
|
||
|
||
const workCards = await screen.findAllByRole('button', { name: /失效拼图/u });
|
||
await user.click(workCards[0]!);
|
||
|
||
await waitFor(() => {
|
||
expect(window.location.pathname).toBe('/');
|
||
});
|
||
expect(getPlatformTabPanel('home').getAttribute('aria-hidden')).toBe('false');
|
||
expect(screen.queryByText('详情')).toBeNull();
|
||
expect(screen.queryByText('资源不存在')).toBeNull();
|
||
expect(startPuzzleRun).toHaveBeenCalledTimes(0);
|
||
});
|
||
|
||
test('public code search opens a published big fish work by BF code', async () => {
|
||
const user = userEvent.setup();
|
||
const bigFishWork: BigFishWorkSummary = {
|
||
workId: 'big-fish-work-public-1',
|
||
sourceSessionId: 'big-fish-session-public-1',
|
||
ownerUserId: 'user-2',
|
||
authorDisplayName: '大鱼作者',
|
||
title: '机械深海 大鱼吃小鱼',
|
||
subtitle: '机械微生物吞并进化',
|
||
summary: '从微光孢子一路吞并成长到深海巨鲲。',
|
||
coverImageSrc: null,
|
||
status: 'published',
|
||
updatedAt: '2026-04-25T10:30:00.000Z',
|
||
publishReady: true,
|
||
levelCount: 8,
|
||
levelMainImageReadyCount: 8,
|
||
levelMotionReadyCount: 16,
|
||
backgroundReady: true,
|
||
};
|
||
|
||
vi.mocked(listBigFishGallery).mockResolvedValue({
|
||
items: [bigFishWork],
|
||
});
|
||
|
||
render(<TestWrapper withAuth />);
|
||
await openDiscoverHub(user);
|
||
|
||
const searchInput = await screen.findByPlaceholderText(
|
||
'搜索作品号、名称、作者、描述',
|
||
);
|
||
await user.type(searchInput, 'BF-NPUBLIC1');
|
||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||
|
||
expect(await screen.findByText('详情')).toBeTruthy();
|
||
await user.click(screen.getByRole('button', { name: '启动' }));
|
||
|
||
await waitFor(() => {
|
||
expect(startBigFishRun).toHaveBeenCalledWith(
|
||
'big-fish-session-public-1',
|
||
);
|
||
});
|
||
expect(await screen.findByText('Lv.1/8 · 进行中')).toBeTruthy();
|
||
expect(getBigFishCreationSession).not.toHaveBeenCalledWith(
|
||
'big-fish-session-public-1',
|
||
);
|
||
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('public code search opens a published Match3D work by M3 code and starts runtime', async () => {
|
||
const user = userEvent.setup();
|
||
const match3dWork: Match3DWorkSummary = {
|
||
workId: 'match3d-work-public-1',
|
||
profileId: 'match3d-profile-public-1',
|
||
ownerUserId: 'user-2',
|
||
sourceSessionId: 'match3d-session-public-1',
|
||
gameName: '水果抓大鹅',
|
||
themeText: '水果消除',
|
||
summary: '把圆形空间里的水果全部消除。',
|
||
tags: ['水果', '消除'],
|
||
coverImageSrc: null,
|
||
referenceImageSrc: null,
|
||
clearCount: 4,
|
||
difficulty: 5,
|
||
publicationStatus: 'published',
|
||
playCount: 3,
|
||
updatedAt: '2026-04-25T10:30:00.000Z',
|
||
publishedAt: '2026-04-25T10:30:00.000Z',
|
||
publishReady: true,
|
||
};
|
||
|
||
vi.mocked(listMatch3DGallery).mockResolvedValue({
|
||
items: [match3dWork],
|
||
});
|
||
vi.mocked(startMatch3DRun).mockResolvedValue({
|
||
run: buildMockMatch3DRun(match3dWork.profileId),
|
||
});
|
||
|
||
render(<TestWrapper withAuth />);
|
||
await openDiscoverHub(user);
|
||
|
||
const searchInput = await screen.findByPlaceholderText(
|
||
'搜索作品号、名称、作者、描述',
|
||
);
|
||
await user.type(searchInput, 'M3-EPUBLIC1');
|
||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||
|
||
expect(await screen.findByText('详情')).toBeTruthy();
|
||
expect(screen.getByText('水果抓大鹅')).toBeTruthy();
|
||
await user.click(screen.getByRole('button', { name: '启动' }));
|
||
|
||
await waitFor(() => {
|
||
expect(startMatch3DRun).toHaveBeenCalledWith('match3d-profile-public-1');
|
||
});
|
||
expect(
|
||
await screen.findByText('抓大鹅运行态:match3d-run-match3d-profile-public-1'),
|
||
).toBeTruthy();
|
||
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||
{
|
||
workId: 'draft:custom-world-agent-session-1',
|
||
sourceType: 'agent_session',
|
||
status: 'draft',
|
||
title: '潮雾列岛',
|
||
subtitle: '补齐关键锚点',
|
||
summary: '玩家是失职返乡的守灯人。',
|
||
coverImageSrc: null,
|
||
coverRenderMode: 'image',
|
||
coverCharacterImageSrcs: [],
|
||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||
publishedAt: null,
|
||
stage: 'clarifying',
|
||
stageLabel: '补齐关键锚点',
|
||
playableNpcCount: 0,
|
||
landmarkCount: 0,
|
||
roleVisualReadyCount: 0,
|
||
roleAnimationReadyCount: 0,
|
||
roleAssetSummaryLabel: null,
|
||
sessionId: 'custom-world-agent-session-1',
|
||
profileId: null,
|
||
canResume: true,
|
||
canEnterWorld: false,
|
||
},
|
||
]);
|
||
vi.mocked(getRpgCreationResultView).mockResolvedValue({
|
||
...buildResultViewForSession(mockSession),
|
||
targetStage: 'agent-workspace',
|
||
resultViewSource: null,
|
||
});
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openExistingRpgDraft(user, /继续创作/u);
|
||
|
||
expect(
|
||
await screen.findByText('Agent工作区:custom-world-agent-session-1'),
|
||
).toBeTruthy();
|
||
|
||
await user.click(screen.getByRole('button', { name: '开始生成草稿' }));
|
||
|
||
await waitFor(() => {
|
||
expect(executeRpgCreationAction).toHaveBeenCalledWith(
|
||
'custom-world-agent-session-1',
|
||
{
|
||
action: 'draft_foundation',
|
||
},
|
||
);
|
||
});
|
||
|
||
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
|
||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
|
||
expect(screen.getByText('当前世界信息')).toBeTruthy();
|
||
expect(screen.queryByText('回到工作区')).toBeNull();
|
||
expect(screen.getByText('世界承诺')).toBeTruthy();
|
||
expect(screen.getByText(/灯塔与禁航令共同决定谁能穿过死潮/u)).toBeTruthy();
|
||
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();
|
||
});
|
||
|
||
test('refresh restores running draft generation progress instead of agent workspace', async () => {
|
||
window.history.replaceState(
|
||
null,
|
||
'',
|
||
'/?customWorldSessionId=custom-world-agent-session-1&customWorldOperationId=operation-draft-foundation-1&customWorldGenerationSource=agent-draft-foundation',
|
||
);
|
||
vi.mocked(getRpgCreationOperation).mockResolvedValue({
|
||
operationId: 'operation-draft-foundation-1',
|
||
type: 'draft_foundation',
|
||
status: 'running',
|
||
phaseLabel: '生成世界底稿',
|
||
phaseDetail: '正在根据已确认锚点编译第一版世界结构。',
|
||
progress: 38,
|
||
error: null,
|
||
});
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
|
||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('failed draft work continues on generation progress view instead of agent workspace', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||
{
|
||
workId: 'draft:custom-world-agent-session-1',
|
||
sourceType: 'agent_session',
|
||
status: 'draft',
|
||
title: '失败中的潮雾列岛',
|
||
subtitle: '生成失败待处理',
|
||
summary: '草稿生成过程中失败,需要继续处理。',
|
||
coverImageSrc: null,
|
||
coverRenderMode: 'image',
|
||
coverCharacterImageSrcs: [],
|
||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||
publishedAt: null,
|
||
stage: 'clarifying',
|
||
stageLabel: '生成失败待处理',
|
||
playableNpcCount: 0,
|
||
landmarkCount: 0,
|
||
roleVisualReadyCount: 0,
|
||
roleAnimationReadyCount: 0,
|
||
roleAssetSummaryLabel: null,
|
||
sessionId: 'custom-world-agent-session-1',
|
||
profileId: null,
|
||
canResume: true,
|
||
canEnterWorld: false,
|
||
},
|
||
]);
|
||
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
|
||
vi.mocked(getRpgCreationResultView).mockResolvedValue({
|
||
...buildResultViewForSession({
|
||
...mockSession,
|
||
stage: 'error',
|
||
}),
|
||
targetStage: 'custom-world-generating',
|
||
generationViewSource: 'agent-draft-foundation',
|
||
recoveryAction: 'resume_generation',
|
||
});
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openDraftHub(user);
|
||
expect(await screen.findByText('失败中的潮雾列岛')).toBeTruthy();
|
||
await user.click(await screen.findByRole('button', { name: /继续创作/u }));
|
||
|
||
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
|
||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||
});
|
||
|
||
test('existing draft sessions open result page refinement instead of agent dialog', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
vi.mocked(getRpgCreationOperation).mockResolvedValue({
|
||
operationId: 'operation-draft-foundation-1',
|
||
type: 'draft_foundation',
|
||
status: 'completed',
|
||
phaseLabel: '世界底稿已生成',
|
||
phaseDetail: '第一版世界底稿和 4 张草稿卡已经整理完成。',
|
||
progress: 100,
|
||
error: null,
|
||
});
|
||
vi.mocked(getRpgCreationSession).mockResolvedValue(compiledAgentDraftSession);
|
||
vi.mocked(getRpgCreationResultView).mockResolvedValue(
|
||
buildResultViewForSession(compiledAgentDraftSession),
|
||
);
|
||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||
{
|
||
workId: 'draft:custom-world-agent-session-1',
|
||
sourceType: 'agent_session',
|
||
status: 'draft',
|
||
title: '潮雾列岛',
|
||
subtitle: '待完善草稿',
|
||
summary: '玩家是失职返乡的守灯人。',
|
||
coverImageSrc: null,
|
||
coverRenderMode: 'image',
|
||
coverCharacterImageSrcs: [],
|
||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||
publishedAt: null,
|
||
stage: 'object_refining',
|
||
stageLabel: '待完善草稿',
|
||
playableNpcCount: 3,
|
||
landmarkCount: 4,
|
||
roleVisualReadyCount: 1,
|
||
roleAnimationReadyCount: 0,
|
||
roleAssetSummaryLabel: '沈砺 · 主图已生成',
|
||
sessionId: 'custom-world-agent-session-1',
|
||
profileId: null,
|
||
canResume: true,
|
||
canEnterWorld: false,
|
||
},
|
||
]);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openExistingRpgDraft(user, /继续完善/u);
|
||
|
||
await waitFor(
|
||
async () => {
|
||
expect(await screen.findByText('世界档案')).toBeTruthy();
|
||
expect(screen.getByText('已自动保存')).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: '作品测试' })).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
|
||
},
|
||
{ timeout: 2500 },
|
||
);
|
||
|
||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||
expect(screen.queryByRole('button', { name: /^锚点/u })).toBeNull();
|
||
expect(screen.getByText(/基本设定/u)).toBeTruthy();
|
||
expect(screen.queryByRole('button', { name: /新增场景角色/u })).toBeNull();
|
||
|
||
await user.click(screen.getByRole('button', { name: /场景角色/u }));
|
||
expect(screen.getByRole('button', { name: /顾潮音/u })).toBeTruthy();
|
||
await user.click(screen.getByRole('button', { name: /顾潮音/u }));
|
||
expect(await screen.findByText(/编辑场景角色:顾潮音/u)).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: /AI生成/u })).toBeTruthy();
|
||
});
|
||
|
||
test('agent result view shows publish blocker dialog before publish action when preview gate is not ready', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
vi.mocked(getRpgCreationOperation).mockResolvedValue({
|
||
operationId: 'operation-draft-foundation-1',
|
||
type: 'draft_foundation',
|
||
status: 'completed',
|
||
phaseLabel: '世界底稿已生成',
|
||
phaseDetail: '第一版世界底稿和 4 张草稿卡已经整理完成。',
|
||
progress: 100,
|
||
error: null,
|
||
});
|
||
const blockedSession = {
|
||
...compiledAgentDraftSession,
|
||
resultPreview: {
|
||
...compiledAgentDraftSession.resultPreview!,
|
||
publishReady: false,
|
||
blockers: [
|
||
{
|
||
id: 'publish-role-assets-incomplete',
|
||
code: 'publish_role_assets_incomplete',
|
||
message: '仍有角色缺少正式主图或动作资产,发布前需要先补齐。',
|
||
},
|
||
],
|
||
},
|
||
} satisfies CustomWorldAgentSessionSnapshot;
|
||
vi.mocked(getRpgCreationSession).mockResolvedValue(blockedSession);
|
||
vi.mocked(getRpgCreationResultView).mockResolvedValue(
|
||
buildResultViewForSession(blockedSession),
|
||
);
|
||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||
{
|
||
workId: 'draft:custom-world-agent-session-1',
|
||
sourceType: 'agent_session',
|
||
status: 'draft',
|
||
title: '潮雾列岛',
|
||
subtitle: '待发布草稿',
|
||
summary: '当前草稿已经补齐八锚点与第一幕。',
|
||
coverImageSrc: null,
|
||
coverRenderMode: 'image',
|
||
coverCharacterImageSrcs: [],
|
||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||
publishedAt: null,
|
||
stage: 'ready_to_publish',
|
||
stageLabel: '待发布草稿',
|
||
playableNpcCount: 3,
|
||
landmarkCount: 1,
|
||
roleVisualReadyCount: 1,
|
||
roleAnimationReadyCount: 0,
|
||
roleAssetSummaryLabel: null,
|
||
sessionId: 'custom-world-agent-session-1',
|
||
profileId: null,
|
||
canResume: true,
|
||
canEnterWorld: false,
|
||
},
|
||
]);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openExistingRpgDraft(user, /继续完善/u);
|
||
|
||
const actionButton = await screen.findByRole(
|
||
'button',
|
||
{ name: '发布' },
|
||
{ timeout: 5000 },
|
||
);
|
||
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
|
||
|
||
const publishWorldCallCountBeforeClick = vi
|
||
.mocked(executeRpgCreationAction)
|
||
.mock.calls.filter(
|
||
([sessionId, payload]) =>
|
||
sessionId === 'custom-world-agent-session-1' &&
|
||
payload?.action === 'publish_world',
|
||
).length;
|
||
|
||
await user.click(actionButton);
|
||
|
||
expect(await screen.findByRole('dialog', { name: '发布作品' })).toBeTruthy();
|
||
expect(screen.getByText('发布检查')).toBeTruthy();
|
||
expect(screen.getByText('封面设置')).toBeTruthy();
|
||
expect(screen.getByText(/仍有角色缺少正式主图或动作资产/u)).toBeTruthy();
|
||
|
||
const publishWorldCallCountAfterClick = vi
|
||
.mocked(executeRpgCreationAction)
|
||
.mock.calls.filter(
|
||
([sessionId, payload]) =>
|
||
sessionId === 'custom-world-agent-session-1' &&
|
||
payload?.action === 'publish_world',
|
||
).length;
|
||
expect(publishWorldCallCountAfterClick).toBe(
|
||
publishWorldCallCountBeforeClick,
|
||
);
|
||
});
|
||
|
||
test('agent draft result publishes to gallery from publish panel', async () => {
|
||
const user = userEvent.setup();
|
||
const handleCustomWorldSelect = vi.fn();
|
||
|
||
const publishReadyDraftSession = {
|
||
...compiledAgentDraftSession,
|
||
stage: 'ready_to_publish' as const,
|
||
resultPreview: {
|
||
...compiledAgentDraftSession.resultPreview!,
|
||
publishReady: true,
|
||
canEnterWorld: false,
|
||
blockers: [],
|
||
},
|
||
} satisfies CustomWorldAgentSessionSnapshot;
|
||
|
||
const publishedSession = {
|
||
...publishReadyDraftSession,
|
||
stage: 'published' as const,
|
||
resultPreview: {
|
||
...publishReadyDraftSession.resultPreview!,
|
||
publishReady: true,
|
||
canEnterWorld: true,
|
||
blockers: [],
|
||
preview: {
|
||
...publishReadyDraftSession.resultPreview!.preview,
|
||
id: 'agent-draft-custom-world-agent-session-1',
|
||
name: '潮雾列岛·已发布',
|
||
summary: '发布完成后应直接使用已发布预览进入世界。',
|
||
},
|
||
},
|
||
} satisfies CustomWorldAgentSessionSnapshot;
|
||
|
||
let hasPublishedWorld = false;
|
||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||
{
|
||
workId: 'draft:custom-world-agent-session-1',
|
||
sourceType: 'agent_session',
|
||
status: 'draft',
|
||
title: '潮雾列岛',
|
||
subtitle: '待发布草稿',
|
||
summary: '当前草稿已经准备发布。',
|
||
coverImageSrc: null,
|
||
coverRenderMode: 'image',
|
||
coverCharacterImageSrcs: [],
|
||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||
publishedAt: null,
|
||
stage: 'ready_to_publish',
|
||
stageLabel: '待发布草稿',
|
||
playableNpcCount: 3,
|
||
landmarkCount: 1,
|
||
roleVisualReadyCount: 1,
|
||
roleAnimationReadyCount: 0,
|
||
roleAssetSummaryLabel: null,
|
||
sessionId: 'custom-world-agent-session-1',
|
||
profileId: null,
|
||
canResume: true,
|
||
canEnterWorld: false,
|
||
},
|
||
]);
|
||
vi.mocked(getRpgCreationOperation).mockResolvedValue({
|
||
operationId: 'operation-publish-world-1',
|
||
type: 'publish_world',
|
||
status: 'completed',
|
||
phaseLabel: '世界已发布',
|
||
phaseDetail: '正式世界档案已写入作品库。',
|
||
progress: 100,
|
||
error: null,
|
||
});
|
||
vi.mocked(executeRpgCreationAction).mockImplementation(async (_, payload) => {
|
||
if (payload.action === 'publish_world') {
|
||
hasPublishedWorld = true;
|
||
}
|
||
|
||
return {
|
||
operation: {
|
||
operationId: 'operation-publish-world-1',
|
||
type: 'publish_world',
|
||
status: 'queued',
|
||
phaseLabel: '执行发布校验',
|
||
phaseDetail: '正在检查角色资产、场景图和主线草稿是否满足发布门槛。',
|
||
progress: 28,
|
||
error: null,
|
||
},
|
||
};
|
||
});
|
||
vi.mocked(getRpgCreationSession).mockImplementation(async () =>
|
||
hasPublishedWorld ? publishedSession : publishReadyDraftSession,
|
||
);
|
||
vi.mocked(getRpgCreationResultView).mockImplementation(async () =>
|
||
buildResultViewForSession(
|
||
hasPublishedWorld ? publishedSession : publishReadyDraftSession,
|
||
),
|
||
);
|
||
|
||
function PublishFlowWrapper() {
|
||
const [selectionStage, setSelectionStage] =
|
||
useState<SelectionStage>('platform');
|
||
|
||
return (
|
||
<AuthUiContext.Provider value={createAuthValue()}>
|
||
<RpgEntryFlowShell
|
||
selectionStage={selectionStage}
|
||
setSelectionStage={setSelectionStage}
|
||
hasSavedGame={false}
|
||
savedSnapshot={null}
|
||
handleContinueGame={() => {}}
|
||
handleStartNewGame={() => {}}
|
||
handleCustomWorldSelect={handleCustomWorldSelect}
|
||
/>
|
||
</AuthUiContext.Provider>
|
||
);
|
||
}
|
||
|
||
render(<PublishFlowWrapper />);
|
||
|
||
await openExistingRpgDraft(user, /继续完善/u);
|
||
|
||
const actionButton = await screen.findByRole(
|
||
'button',
|
||
{
|
||
name: '发布',
|
||
},
|
||
{ timeout: 5000 },
|
||
);
|
||
await user.click(actionButton);
|
||
await user.click(await screen.findByRole('button', { name: '发布到广场' }));
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
vi
|
||
.mocked(executeRpgCreationAction)
|
||
.mock.calls.some(
|
||
([sessionId, payload]) =>
|
||
sessionId === 'custom-world-agent-session-1' &&
|
||
payload?.action === 'publish_world',
|
||
),
|
||
).toBe(true);
|
||
});
|
||
expect(handleCustomWorldSelect).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('agent draft result test button enters current draft without publish gate', async () => {
|
||
const user = userEvent.setup();
|
||
const handleCustomWorldSelect = vi.fn();
|
||
|
||
vi.mocked(getRpgCreationOperation).mockResolvedValue({
|
||
operationId: 'operation-draft-foundation-1',
|
||
type: 'draft_foundation',
|
||
status: 'completed',
|
||
phaseLabel: '世界底稿已生成',
|
||
phaseDetail: '第一版世界底稿和 4 张草稿卡已经整理完成。',
|
||
progress: 100,
|
||
error: null,
|
||
});
|
||
const testDraftSession = {
|
||
...compiledAgentDraftSession,
|
||
stage: 'ready_to_publish',
|
||
resultPreview: {
|
||
...compiledAgentDraftSession.resultPreview!,
|
||
publishReady: false,
|
||
canEnterWorld: true,
|
||
blockers: [
|
||
{
|
||
id: 'missing-cover-image',
|
||
code: 'MISSING_COVER_IMAGE',
|
||
message: '发布前需要补齐作品封面。',
|
||
},
|
||
],
|
||
},
|
||
} satisfies CustomWorldAgentSessionSnapshot;
|
||
vi.mocked(getRpgCreationSession).mockResolvedValue(testDraftSession);
|
||
vi.mocked(getRpgCreationResultView).mockResolvedValue(
|
||
buildResultViewForSession(testDraftSession),
|
||
);
|
||
mockExistingRpgDraftShelf({
|
||
subtitle: '待发布草稿',
|
||
summary: '当前草稿已经准备测试。',
|
||
stage: 'ready_to_publish',
|
||
stageLabel: '待发布草稿',
|
||
landmarkCount: 1,
|
||
roleAssetSummaryLabel: null,
|
||
});
|
||
|
||
function TestDraftWrapper() {
|
||
const [selectionStage, setSelectionStage] =
|
||
useState<SelectionStage>('platform');
|
||
|
||
return (
|
||
<AuthUiContext.Provider value={createAuthValue()}>
|
||
<RpgEntryFlowShell
|
||
selectionStage={selectionStage}
|
||
setSelectionStage={setSelectionStage}
|
||
hasSavedGame={false}
|
||
savedSnapshot={null}
|
||
handleContinueGame={() => {}}
|
||
handleStartNewGame={() => {}}
|
||
handleCustomWorldSelect={handleCustomWorldSelect}
|
||
/>
|
||
</AuthUiContext.Provider>
|
||
);
|
||
}
|
||
|
||
render(<TestDraftWrapper />);
|
||
|
||
await openExistingRpgDraft(user, /继续完善/u);
|
||
await screen.findByText('世界档案', {}, { timeout: 5000 });
|
||
await user.click(
|
||
await screen.findByRole(
|
||
'button',
|
||
{ name: '作品测试' },
|
||
{ timeout: 5000 },
|
||
),
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
|
||
expect.objectContaining({ name: '潮雾列岛' }),
|
||
expect.objectContaining({
|
||
mode: 'play',
|
||
disablePersistence: true,
|
||
returnStage: 'custom-world-result',
|
||
}),
|
||
);
|
||
});
|
||
expect(
|
||
vi
|
||
.mocked(executeRpgCreationAction)
|
||
.mock.calls.some(
|
||
([sessionId, payload]) =>
|
||
sessionId === 'custom-world-agent-session-1' &&
|
||
payload?.action === 'publish_world',
|
||
),
|
||
).toBe(false);
|
||
}, 10_000);
|
||
|
||
test('agent result view does not keep legacy publish blockers when preview uses anchorContent and sceneChapterBlueprints', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||
{
|
||
workId: 'draft:custom-world-agent-session-1',
|
||
sourceType: 'agent_session',
|
||
status: 'draft',
|
||
title: '潮雾列岛',
|
||
subtitle: '待发布草稿',
|
||
summary: '当前草稿已经补齐八锚点与第一幕。',
|
||
coverImageSrc: null,
|
||
coverRenderMode: 'image',
|
||
coverCharacterImageSrcs: [],
|
||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||
publishedAt: null,
|
||
stage: 'ready_to_publish',
|
||
stageLabel: '待发布草稿',
|
||
playableNpcCount: 3,
|
||
landmarkCount: 1,
|
||
roleVisualReadyCount: 1,
|
||
roleAnimationReadyCount: 0,
|
||
roleAssetSummaryLabel: null,
|
||
sessionId: 'custom-world-agent-session-1',
|
||
profileId: null,
|
||
canResume: true,
|
||
canEnterWorld: false,
|
||
},
|
||
]);
|
||
vi.mocked(getRpgCreationOperation).mockResolvedValue({
|
||
operationId: 'operation-draft-foundation-1',
|
||
type: 'draft_foundation',
|
||
status: 'completed',
|
||
phaseLabel: '世界底稿已生成',
|
||
phaseDetail: '第一版世界底稿和 4 张草稿卡已经整理完成。',
|
||
progress: 100,
|
||
error: null,
|
||
});
|
||
const publishGateSession = {
|
||
...compiledAgentDraftSession,
|
||
stage: 'ready_to_publish',
|
||
resultPreview: {
|
||
...compiledAgentDraftSession.resultPreview!,
|
||
publishReady: true,
|
||
blockers: [],
|
||
preview: {
|
||
...compiledAgentDraftSession.resultPreview!.preview,
|
||
settingText: '被海雾吞没的旧航路群岛',
|
||
anchorContent: {
|
||
worldPromise:
|
||
'被海雾吞没的旧航路群岛,灯塔与禁航令共同决定谁能穿过死潮,体验压抑、潮湿、悬疑。',
|
||
playerFantasy:
|
||
'玩家是被迫返乡的守灯人继承者,追查沉船夜与假航灯的关系,风险是失去家族最后一条可信航线。',
|
||
themeBoundary: '压抑、悬疑;潮湿群岛、冷雾港口;避免轻喜冒险。',
|
||
playerEntryPoint:
|
||
'玩家以返乡守灯人继承者身份切入,回港首夜撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。',
|
||
coreConflict:
|
||
'守灯会与航运公会争夺航路解释权,有人在借假航灯持续清洗旧案证据,玩家返乡当夜就被卷进封航冲突。',
|
||
keyRelationships: null,
|
||
hiddenLines:
|
||
'沉船夜与假航灯骗局属于同一操盘链条,表面像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。',
|
||
iconicElements:
|
||
'假航灯、沉钟回响、旧灯塔、禁航碑;错误航灯会把船引进必死水域。',
|
||
},
|
||
creatorIntent: {
|
||
sourceMode: 'card',
|
||
rawSettingText: '',
|
||
worldHook: '被海雾吞没的旧航路群岛',
|
||
themeKeywords: ['海雾', '旧航路'],
|
||
toneDirectives: ['压抑', '悬疑'],
|
||
playerPremise: '玩家回到群岛调查沉船真相。',
|
||
openingSituation: '首夜就有陌生船只闯入禁航区。',
|
||
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
|
||
keyFactions: [],
|
||
keyCharacters: [],
|
||
keyLandmarks: [],
|
||
iconicElements: ['会移动的海雾'],
|
||
forbiddenDirectives: [],
|
||
},
|
||
sceneChapterBlueprints: [
|
||
{
|
||
id: 'scene-chapter-1',
|
||
sceneId: 'landmark-1',
|
||
title: '沉钟栈桥章节',
|
||
summary: '围绕沉钟栈桥推进的三幕结构。',
|
||
linkedThreadIds: [],
|
||
linkedLandmarkIds: ['landmark-1'],
|
||
acts: [
|
||
{
|
||
id: 'scene-act-1',
|
||
sceneId: 'landmark-1',
|
||
title: '潮声逼近',
|
||
summary: '第一幕先把潮声与旧钟压上来。',
|
||
stageCoverage: ['opening'],
|
||
encounterNpcIds: ['story-1'],
|
||
primaryNpcId: 'story-1',
|
||
linkedThreadIds: [],
|
||
advanceRule: 'after_primary_contact',
|
||
actGoal: '接住首幕压力',
|
||
transitionHook: '继续逼近钟楼深处。',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
},
|
||
} satisfies CustomWorldAgentSessionSnapshot;
|
||
vi.mocked(getRpgCreationSession).mockResolvedValue(publishGateSession);
|
||
vi.mocked(getRpgCreationResultView).mockResolvedValue(
|
||
buildResultViewForSession(publishGateSession),
|
||
);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openDraftHub(user);
|
||
expect(await screen.findByRole('button', { name: /继续完善/u })).toBeTruthy();
|
||
await user.click(await screen.findByRole('button', { name: /继续完善/u }));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByRole('button', { name: '作品测试' })).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
|
||
});
|
||
|
||
expect(screen.queryByText(/当前还有 4 个发布阻断项/u)).toBeNull();
|
||
const actionButton = screen.getByRole('button', {
|
||
name: '发布',
|
||
});
|
||
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
|
||
});
|
||
|
||
test('agent draft result back button returns to creation hub without syncing result profile', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
const resultSession = {
|
||
...mockSession,
|
||
stage: 'object_refining' as const,
|
||
creatorIntent: {
|
||
sourceMode: 'card',
|
||
worldHook: '被海雾吞没的旧航路群岛',
|
||
playerPremise: '玩家回到群岛调查沉船真相。',
|
||
themeKeywords: ['海雾', '旧航路'],
|
||
toneDirectives: ['压抑', '悬疑'],
|
||
openingSituation: '首夜就有陌生船只闯入禁航区。',
|
||
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
|
||
keyFactions: [],
|
||
keyCharacters: [],
|
||
keyLandmarks: [],
|
||
iconicElements: ['会移动的海雾'],
|
||
forbiddenDirectives: [],
|
||
rawSettingText: '',
|
||
},
|
||
draftProfile: {
|
||
name: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '第一版世界底稿已经整理完成。',
|
||
tone: '压抑、潮湿、悬疑',
|
||
playerGoal: '查清沉船与禁航区异动的真相。',
|
||
majorFactions: ['守灯会', '航运公会'],
|
||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||
playableNpcs: [
|
||
{
|
||
id: 'playable-1',
|
||
name: '沈砺',
|
||
title: '旧航路引路人',
|
||
role: '关键同行者',
|
||
publicIdentity: '最熟悉旧航路的人。',
|
||
publicMask: '看上去像可靠旧友。',
|
||
currentPressure: '他必须在两股势力间站队。',
|
||
hiddenHook: '暗中替沉船商盟引路。',
|
||
relationToPlayer: '旧友兼潜在背叛者',
|
||
threadIds: ['thread-1'],
|
||
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
|
||
},
|
||
],
|
||
storyNpcs: [
|
||
{
|
||
id: 'story-1',
|
||
name: '顾潮音',
|
||
title: '守灯会值夜人',
|
||
role: '场景关键角色',
|
||
publicIdentity: '负责夜间巡灯与封锁。',
|
||
publicMask: '对外一直冷静克制。',
|
||
currentPressure: '她知道更多禁航区真相。',
|
||
hiddenHook: '曾亲眼见过失控海雾吞船。',
|
||
relationToPlayer: '最早愿意交换线索的人',
|
||
threadIds: ['thread-1'],
|
||
summary: '她总像比所有人更早知道海雾会往哪一侧压下来。',
|
||
},
|
||
],
|
||
landmarks: [
|
||
{
|
||
id: 'landmark-1',
|
||
name: '回潮旧灯塔',
|
||
purpose: '观察雾潮与往来船只',
|
||
mood: '潮湿、压抑、风声不止',
|
||
importance: '开局核心场景',
|
||
characterIds: ['story-1'],
|
||
threadIds: ['thread-1'],
|
||
summary: '旧灯塔是整片群岛最先看见异动的地方。',
|
||
},
|
||
],
|
||
factions: [],
|
||
threads: [],
|
||
chapters: [],
|
||
worldHook: '被海雾吞没的旧航路群岛',
|
||
playerPremise: '玩家回到群岛调查沉船真相。',
|
||
openingSituation: '首夜就有陌生船只闯入禁航区。',
|
||
iconicElements: ['会移动的海雾'],
|
||
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
|
||
legacyResultProfile: {
|
||
id: 'agent-draft-custom-world-agent-session-1',
|
||
settingText: '被海雾吞没的旧航路群岛',
|
||
name: '潮雾列岛·同步后',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '同步后的结果页快照已经回写到 session。',
|
||
tone: '压抑、潮湿、悬疑',
|
||
playerGoal: '查清沉船与禁航区异动的真相。',
|
||
templateWorldType: 'WUXIA',
|
||
majorFactions: ['守灯会', '航运公会'],
|
||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||
playableNpcs: [],
|
||
storyNpcs: [],
|
||
items: [],
|
||
landmarks: [],
|
||
generationMode: 'full',
|
||
generationStatus: 'complete',
|
||
},
|
||
},
|
||
draftCards: [
|
||
{
|
||
id: 'world-foundation',
|
||
kind: 'world',
|
||
title: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '第一版世界底稿已经整理完成。',
|
||
status: 'warning',
|
||
linkedIds: ['playable-1', 'story-1', 'landmark-1'],
|
||
warningCount: 0,
|
||
},
|
||
],
|
||
resultPreview: {
|
||
source: 'session_preview' as const,
|
||
preview: {
|
||
id: 'agent-draft-custom-world-agent-session-1',
|
||
settingText: '被海雾吞没的旧航路群岛',
|
||
name: '潮雾列岛·同步后',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '同步后的结果页快照已经回写到 session。',
|
||
tone: '压抑、潮湿、悬疑',
|
||
playerGoal: '查清沉船与禁航区异动的真相。',
|
||
templateWorldType: 'WUXIA',
|
||
majorFactions: ['守灯会', '航运公会'],
|
||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||
playableNpcs: [],
|
||
storyNpcs: [],
|
||
items: [],
|
||
landmarks: [],
|
||
generationMode: 'full',
|
||
generationStatus: 'complete',
|
||
sessionId: 'custom-world-agent-session-1',
|
||
},
|
||
generatedAt: '2026-04-20T12:00:00.000Z',
|
||
qualityFindings: [],
|
||
blockers: [],
|
||
publishReady: false,
|
||
canEnterWorld: false,
|
||
},
|
||
} satisfies CustomWorldAgentSessionSnapshot;
|
||
vi.mocked(getRpgCreationSession).mockResolvedValue(resultSession);
|
||
vi.mocked(getRpgCreationResultView).mockResolvedValue(
|
||
buildResultViewForSession(resultSession),
|
||
);
|
||
mockExistingRpgDraftShelf({
|
||
summary: '同步后的结果页快照已经回写到 session。',
|
||
});
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openExistingRpgDraft(user, /继续完善/u);
|
||
|
||
await waitFor(
|
||
async () => {
|
||
expect(await screen.findByText('世界档案')).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: /返回创作/u })).toBeTruthy();
|
||
},
|
||
{ timeout: 2500 },
|
||
);
|
||
|
||
await user.click(screen.getByRole('button', { name: /返回创作/u }));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByRole('tablist', { name: '选择模板' })).toBeTruthy();
|
||
});
|
||
|
||
expect(
|
||
vi
|
||
.mocked(executeRpgCreationAction)
|
||
.mock.calls.some(
|
||
([sessionId, payload]) =>
|
||
sessionId === 'custom-world-agent-session-1' &&
|
||
payload?.action === 'sync_result_profile',
|
||
),
|
||
).toBe(false);
|
||
expect(screen.queryByText('世界档案')).toBeNull();
|
||
});
|
||
|
||
test('agent draft result auto-save syncs result profile before persisting backend result view', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
const syncedSession = {
|
||
...mockSession,
|
||
stage: 'object_refining' as const,
|
||
creatorIntent: {
|
||
sourceMode: 'card',
|
||
worldHook: '被海雾吞没的旧航路群岛',
|
||
playerPremise: '玩家回到群岛调查沉船真相。',
|
||
themeKeywords: ['海雾', '旧航路'],
|
||
toneDirectives: ['压抑', '悬疑'],
|
||
openingSituation: '首夜就有陌生船只闯入禁航区。',
|
||
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
|
||
keyFactions: [],
|
||
keyCharacters: [],
|
||
keyLandmarks: [],
|
||
iconicElements: ['会移动的海雾'],
|
||
forbiddenDirectives: [],
|
||
rawSettingText: '',
|
||
},
|
||
draftProfile: {
|
||
name: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '第一版世界底稿已经整理完成。',
|
||
tone: '压抑、潮湿、悬疑',
|
||
playerGoal: '查清沉船与禁航区异动的真相。',
|
||
majorFactions: ['守灯会', '航运公会'],
|
||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||
playableNpcs: [],
|
||
storyNpcs: [],
|
||
landmarks: [],
|
||
factions: [],
|
||
threads: [],
|
||
chapters: [],
|
||
worldHook: '被海雾吞没的旧航路群岛',
|
||
playerPremise: '玩家回到群岛调查沉船真相。',
|
||
openingSituation: '首夜就有陌生船只闯入禁航区。',
|
||
iconicElements: ['会移动的海雾'],
|
||
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
|
||
legacyResultProfile: {
|
||
id: 'agent-draft-custom-world-agent-session-1',
|
||
settingText: '被海雾吞没的旧航路群岛',
|
||
name: '潮雾列岛·session最新版',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '作品库应该保存这份同步后的最新快照。',
|
||
tone: '压抑、潮湿、悬疑',
|
||
playerGoal: '查清沉船与禁航区异动的真相。',
|
||
templateWorldType: 'WUXIA',
|
||
majorFactions: ['守灯会', '航运公会'],
|
||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||
playableNpcs: [],
|
||
storyNpcs: [],
|
||
items: [],
|
||
landmarks: [],
|
||
generationMode: 'full',
|
||
generationStatus: 'complete',
|
||
},
|
||
},
|
||
draftCards: [
|
||
{
|
||
id: 'world-foundation',
|
||
kind: 'world',
|
||
title: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '第一版世界底稿已经整理完成。',
|
||
status: 'warning',
|
||
linkedIds: [],
|
||
warningCount: 0,
|
||
},
|
||
],
|
||
resultPreview: {
|
||
source: 'session_preview' as const,
|
||
preview: {
|
||
id: 'agent-draft-custom-world-agent-session-1',
|
||
settingText: '被海雾吞没的旧航路群岛',
|
||
name: '潮雾列岛·session最新版',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '作品库应该保存这份同步后的最新快照。',
|
||
tone: '压抑、潮湿、悬疑',
|
||
playerGoal: '查清沉船与禁航区异动的真相。',
|
||
templateWorldType: 'WUXIA',
|
||
majorFactions: ['守灯会', '航运公会'],
|
||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||
playableNpcs: [],
|
||
storyNpcs: [],
|
||
items: [],
|
||
landmarks: [],
|
||
generationMode: 'full',
|
||
generationStatus: 'complete',
|
||
sessionId: 'custom-world-agent-session-1',
|
||
},
|
||
generatedAt: '2026-04-20T12:00:00.000Z',
|
||
qualityFindings: [],
|
||
blockers: [],
|
||
publishReady: false,
|
||
canEnterWorld: false,
|
||
},
|
||
} satisfies CustomWorldAgentSessionSnapshot;
|
||
vi.mocked(getRpgCreationSession).mockResolvedValue(syncedSession);
|
||
vi.mocked(getRpgCreationResultView).mockResolvedValue(
|
||
buildResultViewForSession(syncedSession),
|
||
);
|
||
mockExistingRpgDraftShelf({
|
||
summary: '作品库应该保存这份同步后的最新快照。',
|
||
});
|
||
vi.mocked(executeRpgCreationAction).mockImplementation(
|
||
async (_, payload) => ({
|
||
operation: {
|
||
operationId:
|
||
payload.action === 'sync_result_profile'
|
||
? 'operation-sync-result-profile-1'
|
||
: 'operation-draft-foundation-1',
|
||
type: payload.action,
|
||
status: 'queued',
|
||
phaseLabel: '已接收请求',
|
||
phaseDetail:
|
||
payload.action === 'sync_result_profile'
|
||
? '正在同步结果页档案。'
|
||
: '正在准备生成世界底稿。',
|
||
progress: 10,
|
||
error: null,
|
||
},
|
||
}),
|
||
);
|
||
vi.mocked(getRpgCreationOperation).mockResolvedValue({
|
||
operationId: 'operation-sync-result-profile-1',
|
||
type: 'sync_result_profile',
|
||
status: 'completed',
|
||
phaseLabel: '结果页档案已同步',
|
||
phaseDetail: '服务端已根据最新结果页档案刷新会话预览。',
|
||
progress: 100,
|
||
error: null,
|
||
});
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openExistingRpgDraft(user, /继续完善/u);
|
||
|
||
await waitFor(
|
||
async () => {
|
||
expect(await screen.findByText('世界档案')).toBeTruthy();
|
||
expect(screen.getByText('已自动保存')).toBeTruthy();
|
||
},
|
||
{ timeout: 2500 },
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(upsertRpgWorldProfile).toHaveBeenCalled();
|
||
});
|
||
|
||
const latestSavedProfile = vi
|
||
.mocked(upsertRpgWorldProfile)
|
||
.mock.calls.at(-1)?.[0];
|
||
const latestSaveRequest = vi
|
||
.mocked(upsertRpgWorldProfile)
|
||
.mock.calls.at(-1)?.[1];
|
||
expect(latestSavedProfile?.name).toBe('潮雾列岛·session最新版');
|
||
expect(latestSavedProfile?.summary).toBe(
|
||
'作品库应该保存这份同步后的最新快照。',
|
||
);
|
||
expect(latestSaveRequest).toEqual({
|
||
sourceAgentSessionId: 'custom-world-agent-session-1',
|
||
});
|
||
expect(
|
||
vi
|
||
.mocked(executeRpgCreationAction)
|
||
.mock.calls.some(
|
||
([sessionId, payload]) =>
|
||
sessionId === 'custom-world-agent-session-1' &&
|
||
payload?.action === 'sync_result_profile',
|
||
),
|
||
).toBe(true);
|
||
});
|
||
|
||
test('agent draft result can open from server result preview without embedded legacyResultProfile', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
const previewOnlySession = {
|
||
...compiledAgentDraftSession,
|
||
draftProfile: {
|
||
...compiledAgentDraftSession.draftProfile,
|
||
playableNpcs: [],
|
||
storyNpcs: [],
|
||
landmarks: [],
|
||
},
|
||
resultPreview: {
|
||
source: 'session_preview' as const,
|
||
preview: {
|
||
id: 'agent-draft-custom-world-agent-session-1',
|
||
settingText: '被海雾吞没的旧航路群岛',
|
||
name: '潮雾列岛·服务端预览',
|
||
subtitle: '结果页改为优先消费 session.resultPreview',
|
||
summary:
|
||
'即使 draft 中没有 legacyResultProfile,也应该正常打开结果页。',
|
||
tone: '压抑、潮湿、悬疑',
|
||
playerGoal: '查清沉船与禁航区异动的真相。',
|
||
templateWorldType: 'WUXIA',
|
||
majorFactions: ['守灯会', '航运公会'],
|
||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||
playableNpcs: [],
|
||
storyNpcs: [],
|
||
items: [],
|
||
landmarks: [],
|
||
generationMode: 'full',
|
||
generationStatus: 'complete',
|
||
sessionId: 'custom-world-agent-session-1',
|
||
},
|
||
generatedAt: '2026-04-20T12:00:00.000Z',
|
||
qualityFindings: [],
|
||
blockers: [],
|
||
},
|
||
} satisfies CustomWorldAgentSessionSnapshot;
|
||
|
||
vi.mocked(getRpgCreationSession).mockResolvedValue(previewOnlySession);
|
||
vi.mocked(getRpgCreationResultView).mockResolvedValue(
|
||
buildResultViewForSession(previewOnlySession),
|
||
);
|
||
mockExistingRpgDraftShelf({
|
||
summary: '即使 draft 中没有 legacyResultProfile,也应该正常打开结果页。',
|
||
});
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openExistingRpgDraft(user, /继续完善/u);
|
||
|
||
await waitFor(
|
||
async () => {
|
||
expect(await screen.findByText('世界档案')).toBeTruthy();
|
||
expect(screen.getByText('潮雾列岛·服务端预览')).toBeTruthy();
|
||
expect(
|
||
screen.getAllByText('结果页改为优先消费 session.resultPreview').length,
|
||
).toBeGreaterThan(0);
|
||
},
|
||
{ timeout: 2500 },
|
||
);
|
||
});
|
||
|
||
test('authenticated users can open save archives from the profile played panel', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
vi.mocked(listProfileSaveArchives).mockResolvedValue([
|
||
{
|
||
worldKey: 'custom:world-1',
|
||
ownerUserId: null,
|
||
profileId: 'world-1',
|
||
worldType: 'CUSTOM',
|
||
worldName: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summaryText: '回到旧灯塔继续推进调查。',
|
||
coverImageSrc: null,
|
||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||
},
|
||
]);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openProfilePlayedWorks(user);
|
||
|
||
expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0);
|
||
expect(screen.getAllByText('旧灯塔与失控航路').length).toBeGreaterThan(0);
|
||
expect(screen.queryByText('SAVE ARCHIVE')).toBeNull();
|
||
expect(screen.queryByText('ARCHIVE')).toBeNull();
|
||
expect(screen.queryByText('最近存档')).toBeNull();
|
||
});
|
||
|
||
test('puzzle save archive highlights work title and level subtitle', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
vi.mocked(listProfileSaveArchives).mockResolvedValue([
|
||
{
|
||
worldKey: 'puzzle:puzzle-profile-1',
|
||
ownerUserId: 'user-2',
|
||
profileId: 'puzzle-profile-1',
|
||
worldType: 'PUZZLE',
|
||
worldName: '雨夜猫塔',
|
||
subtitle: '第 2 关 · 星桥机关',
|
||
summaryText: '拼图进行中',
|
||
coverImageSrc: '/generated-puzzle-assets/puzzle-1/level-2.png',
|
||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||
},
|
||
]);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openProfilePlayedWorks(user);
|
||
|
||
expect((await screen.findAllByText('雨夜猫塔')).length).toBeGreaterThan(0);
|
||
expect(screen.getAllByText('第 2 关 · 星桥机关').length).toBeGreaterThan(0);
|
||
expect(screen.queryByText('ARCHIVE')).toBeNull();
|
||
expect(screen.queryByText('最近存档')).toBeNull();
|
||
});
|
||
|
||
test('manual tab switch is preserved after platform bootstrap requests finish', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
let resolveGalleryRequest!: (value: []) => void;
|
||
const delayedGalleryRequest = new Promise<[]>((resolve) => {
|
||
resolveGalleryRequest = resolve;
|
||
});
|
||
|
||
vi.mocked(listRpgEntryWorldGallery).mockReturnValueOnce(
|
||
delayedGalleryRequest as Promise<[]>,
|
||
);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await clickFirstButtonByName(user, '创作');
|
||
expect(
|
||
await screen.findByRole('tablist', { name: '选择模板' }),
|
||
).toBeTruthy();
|
||
|
||
resolveGalleryRequest([]);
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
within(getPlatformTabPanel('create')).getByRole('tablist', {
|
||
name: '选择模板',
|
||
}),
|
||
).toBeTruthy();
|
||
});
|
||
|
||
expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe(
|
||
'false',
|
||
);
|
||
expect(getPlatformTabPanel('home').getAttribute('aria-hidden')).toBe('true');
|
||
});
|
||
|
||
test('save tab can resume a selected archive directly into the game', async () => {
|
||
const user = userEvent.setup();
|
||
const handleContinueGame = vi.fn();
|
||
|
||
vi.mocked(listProfileSaveArchives).mockResolvedValue([
|
||
{
|
||
worldKey: 'custom:world-1',
|
||
ownerUserId: null,
|
||
profileId: 'world-1',
|
||
worldType: 'CUSTOM',
|
||
worldName: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summaryText: '回到旧灯塔继续推进调查。',
|
||
coverImageSrc: null,
|
||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||
},
|
||
]);
|
||
vi.mocked(resumeProfileSaveArchive).mockResolvedValue({
|
||
entry: {
|
||
worldKey: 'custom:world-1',
|
||
ownerUserId: null,
|
||
profileId: 'world-1',
|
||
worldType: 'CUSTOM',
|
||
worldName: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summaryText: '回到旧灯塔继续推进调查。',
|
||
coverImageSrc: null,
|
||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||
},
|
||
snapshot: {
|
||
version: 2,
|
||
savedAt: '2026-04-19T12:00:00.000Z',
|
||
bottomTab: 'adventure',
|
||
currentStory: null,
|
||
gameState: {
|
||
worldType: 'CUSTOM',
|
||
},
|
||
} as HydratedSavedGameSnapshot,
|
||
});
|
||
|
||
render(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
|
||
|
||
await openProfilePlayedWorks(user);
|
||
|
||
await clickFirstAsyncButtonByName(user, /潮雾列岛/u);
|
||
|
||
await waitFor(() => {
|
||
expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1');
|
||
expect(handleContinueGame).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
test('creation hub published work can open detail view before deleting from detail page', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
const publishedWork = {
|
||
workId: 'published:world-delete-1',
|
||
sourceType: 'published_profile' as const,
|
||
status: 'published' as const,
|
||
title: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '用于测试删除流程的作品。',
|
||
coverImageSrc: null,
|
||
coverRenderMode: 'image' as const,
|
||
coverCharacterImageSrcs: [],
|
||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||
publishedAt: '2026-04-16T12:00:00.000Z',
|
||
stage: null,
|
||
stageLabel: '已发布',
|
||
playableNpcCount: 0,
|
||
landmarkCount: 0,
|
||
roleVisualReadyCount: 0,
|
||
roleAnimationReadyCount: 0,
|
||
roleAssetSummaryLabel: null,
|
||
sessionId: null,
|
||
profileId: 'world-delete-1',
|
||
canResume: false,
|
||
canEnterWorld: true,
|
||
};
|
||
const publishedLibraryEntry = {
|
||
ownerUserId: 'user-1',
|
||
profileId: 'world-delete-1',
|
||
publicWorkCode: 'work-delete-1',
|
||
authorPublicUserCode: 'user-1',
|
||
profile: {
|
||
id: 'world-delete-1',
|
||
name: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '用于测试删除流程的作品。',
|
||
tone: '压抑、潮湿、悬疑',
|
||
playerGoal: '查清旧案。',
|
||
majorFactions: ['守灯会'],
|
||
coreConflicts: ['雾潮正在逼近港口'],
|
||
playableNpcs: [],
|
||
storyNpcs: [],
|
||
landmarks: [],
|
||
} as never,
|
||
visibility: 'published' as const,
|
||
publishedAt: '2026-04-16T12:00:00.000Z',
|
||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||
authorDisplayName: '测试玩家',
|
||
worldName: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summaryText: '用于测试删除流程的作品。',
|
||
coverImageSrc: null,
|
||
themeMode: 'tide' as const,
|
||
playableNpcCount: 0,
|
||
landmarkCount: 0,
|
||
likeCount: 0,
|
||
};
|
||
|
||
vi.mocked(listRpgCreationWorks)
|
||
.mockResolvedValueOnce([publishedWork])
|
||
.mockResolvedValue([]);
|
||
vi.mocked(listRpgEntryWorldLibrary)
|
||
.mockResolvedValueOnce([publishedLibraryEntry])
|
||
.mockResolvedValue([]);
|
||
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openDraftHub(user);
|
||
await user.click(await screen.findByRole('button', { name: /查看详情/u }));
|
||
|
||
expect(await screen.findByText('详情')).toBeTruthy();
|
||
expect(screen.getByText('潮雾列岛')).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: '启动' })).toBeTruthy();
|
||
expect(screen.queryByRole('button', { name: '删除作品' })).toBeNull();
|
||
expect(deleteRpgEntryWorldProfile).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('creation hub published work enters existing detail view', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||
{
|
||
workId: 'published:world-public-1',
|
||
sourceType: 'published_profile',
|
||
status: 'published',
|
||
title: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '已经发布的群岛世界作品。',
|
||
coverImageSrc: null,
|
||
coverRenderMode: 'image',
|
||
coverCharacterImageSrcs: [],
|
||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||
publishedAt: '2026-04-20T10:00:00.000Z',
|
||
stage: null,
|
||
stageLabel: '已发布',
|
||
playableNpcCount: 3,
|
||
landmarkCount: 4,
|
||
roleVisualReadyCount: 1,
|
||
roleAnimationReadyCount: 0,
|
||
roleAssetSummaryLabel: null,
|
||
sessionId: null,
|
||
profileId: 'world-public-1',
|
||
canResume: false,
|
||
canEnterWorld: true,
|
||
},
|
||
]);
|
||
vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([
|
||
{
|
||
ownerUserId: 'user-1',
|
||
profileId: 'world-public-1',
|
||
publicWorkCode: 'work-public-1',
|
||
authorPublicUserCode: 'user-1',
|
||
profile: {
|
||
id: 'world-public-1',
|
||
name: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '已经发布的群岛世界作品。',
|
||
tone: '压抑、潮湿、悬疑',
|
||
playerGoal: '查清群岛旧案。',
|
||
majorFactions: ['守灯会'],
|
||
coreConflicts: ['假航灯正在扰乱航线'],
|
||
playableNpcs: [],
|
||
storyNpcs: [],
|
||
landmarks: [],
|
||
} as never,
|
||
visibility: 'published',
|
||
publishedAt: '2026-04-20T10:00:00.000Z',
|
||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||
authorDisplayName: '测试玩家',
|
||
worldName: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summaryText: '已经发布的群岛世界作品。',
|
||
coverImageSrc: null,
|
||
themeMode: 'tide',
|
||
playableNpcCount: 3,
|
||
landmarkCount: 4,
|
||
likeCount: 0,
|
||
},
|
||
]);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openDraftHub(user);
|
||
await user.click(await screen.findByRole('button', { name: /查看详情/u }));
|
||
|
||
expect(await screen.findByText('详情')).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: '启动' })).toBeTruthy();
|
||
expect(screen.getByText('潮雾列岛')).toBeTruthy();
|
||
});
|
||
|
||
test('creation hub published work experience button enters world directly', async () => {
|
||
const user = userEvent.setup();
|
||
const handleCustomWorldSelect = vi.fn();
|
||
|
||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||
{
|
||
workId: 'published:world-experience-1',
|
||
sourceType: 'published_profile',
|
||
status: 'published',
|
||
title: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '已经发布的群岛世界作品。',
|
||
coverImageSrc: null,
|
||
coverRenderMode: 'image',
|
||
coverCharacterImageSrcs: [],
|
||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||
publishedAt: '2026-04-20T10:00:00.000Z',
|
||
stage: null,
|
||
stageLabel: '已发布',
|
||
playableNpcCount: 3,
|
||
landmarkCount: 4,
|
||
roleVisualReadyCount: 1,
|
||
roleAnimationReadyCount: 0,
|
||
roleAssetSummaryLabel: null,
|
||
sessionId: null,
|
||
profileId: 'world-experience-1',
|
||
canResume: false,
|
||
canEnterWorld: true,
|
||
},
|
||
]);
|
||
vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([
|
||
{
|
||
ownerUserId: 'user-1',
|
||
profileId: 'world-experience-1',
|
||
publicWorkCode: 'work-experience-1',
|
||
authorPublicUserCode: 'user-1',
|
||
profile: {
|
||
id: 'world-experience-1',
|
||
name: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '已经发布的群岛世界作品。',
|
||
tone: '压抑、潮湿、悬疑',
|
||
playerGoal: '查清群岛旧案。',
|
||
majorFactions: ['守灯会'],
|
||
coreConflicts: ['假航灯正在扰乱航线'],
|
||
playableNpcs: [],
|
||
storyNpcs: [],
|
||
landmarks: [],
|
||
} as never,
|
||
visibility: 'published',
|
||
publishedAt: '2026-04-20T10:00:00.000Z',
|
||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||
authorDisplayName: '测试玩家',
|
||
worldName: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summaryText: '已经发布的群岛世界作品。',
|
||
coverImageSrc: null,
|
||
themeMode: 'tide',
|
||
playableNpcCount: 3,
|
||
landmarkCount: 4,
|
||
likeCount: 0,
|
||
},
|
||
]);
|
||
|
||
render(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
|
||
|
||
await openDraftHub(user);
|
||
await user.click(await screen.findByRole('button', { name: /查看详情/u }));
|
||
await user.click(await screen.findByRole('button', { name: '启动' }));
|
||
|
||
await waitFor(() => {
|
||
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
id: 'world-experience-1',
|
||
name: '潮雾列岛',
|
||
}),
|
||
);
|
||
});
|
||
expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
test('creation hub published work card keeps delete action guarded by detail flow', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
const publishedWork = {
|
||
workId: 'published:world-card-delete-1',
|
||
sourceType: 'published_profile' as const,
|
||
status: 'published' as const,
|
||
title: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '用于测试卡片删除流程的作品。',
|
||
coverImageSrc: null,
|
||
coverRenderMode: 'image' as const,
|
||
coverCharacterImageSrcs: [],
|
||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||
publishedAt: '2026-04-16T12:00:00.000Z',
|
||
stage: null,
|
||
stageLabel: '已发布',
|
||
playableNpcCount: 0,
|
||
landmarkCount: 0,
|
||
roleVisualReadyCount: 0,
|
||
roleAnimationReadyCount: 0,
|
||
roleAssetSummaryLabel: null,
|
||
sessionId: null,
|
||
profileId: 'world-card-delete-1',
|
||
canResume: false,
|
||
canEnterWorld: true,
|
||
};
|
||
const publishedLibraryEntry = {
|
||
ownerUserId: 'user-1',
|
||
profileId: 'world-card-delete-1',
|
||
publicWorkCode: 'work-card-delete-1',
|
||
authorPublicUserCode: 'user-1',
|
||
profile: {
|
||
id: 'world-card-delete-1',
|
||
name: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '用于测试卡片删除流程的作品。',
|
||
tone: '压抑、潮湿、悬疑',
|
||
playerGoal: '查清旧案。',
|
||
majorFactions: ['守灯会'],
|
||
coreConflicts: ['雾潮正在逼近港口'],
|
||
playableNpcs: [],
|
||
storyNpcs: [],
|
||
landmarks: [],
|
||
} as never,
|
||
visibility: 'published' as const,
|
||
publishedAt: '2026-04-16T12:00:00.000Z',
|
||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||
authorDisplayName: '测试玩家',
|
||
worldName: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summaryText: '用于测试卡片删除流程的作品。',
|
||
coverImageSrc: null,
|
||
themeMode: 'tide' as const,
|
||
playableNpcCount: 0,
|
||
landmarkCount: 0,
|
||
likeCount: 0,
|
||
};
|
||
|
||
vi.mocked(listRpgCreationWorks)
|
||
.mockResolvedValueOnce([publishedWork])
|
||
.mockResolvedValue([]);
|
||
vi.mocked(listRpgEntryWorldLibrary)
|
||
.mockResolvedValueOnce([publishedLibraryEntry])
|
||
.mockResolvedValue([]);
|
||
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
|
||
|
||
render(<TestWrapper withAuth />);
|
||
|
||
await openDraftHub(user);
|
||
|
||
expect(await screen.findByRole('button', { name: /查看详情/u })).toBeTruthy();
|
||
await user.click(screen.getByRole('button', { name: '删除' }));
|
||
|
||
const dialog = await screen.findByRole('dialog', { name: '删除作品' });
|
||
expect(dialog.parentElement?.className).toContain('platform-theme--light');
|
||
expect(dialog.parentElement?.className).toContain('!items-center');
|
||
expect(dialog.className).toContain('platform-modal-shell');
|
||
expect(dialog.className).toContain('platform-remap-surface');
|
||
expect(dialog.className).toContain('rounded-[1.75rem]');
|
||
expect(
|
||
within(dialog).getByText('确认删除《潮雾列岛》吗?'),
|
||
).toBeTruthy();
|
||
expect(
|
||
within(dialog).getByRole('button', { name: '确认删除' }),
|
||
).toBeTruthy();
|
||
expect(deleteRpgEntryWorldProfile).not.toHaveBeenCalled();
|
||
});
|