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

10114 lines
319 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @vitest-environment jsdom */
import { act, 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 { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
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 {
BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
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 { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
PuzzleAnchorPack,
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type {
CreatePuzzleAgentSessionRequest,
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 { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
readPublicWorkCodeFromLocationSearch,
resolveSelectionStageFromPath,
} from '../../routing/appPageRoutes';
import { ApiClientError } from '../../services/apiClient';
import type { AuthUser } from '../../services/authService';
import {
createBarkBattleDraft,
generateAllBarkBattleImageAssets,
listBarkBattleGallery,
listBarkBattleWorks,
publishBarkBattleWork,
updateBarkBattleDraftConfig,
} from '../../services/bark-battle-creation';
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 {
type CreationEntryConfig,
fetchCreationEntryConfig,
} from '../../services/creationEntryConfigService';
import {
cancelCreativeAgentSession,
confirmCreativePuzzleTemplate,
createCreativeAgentSession,
streamCreativeAgentMessage,
streamCreativeDraftEdit,
} from '../../services/creative-agent';
import {
createBabyObjectMatchDraft,
deleteLocalBabyObjectMatchDraft,
listLocalBabyObjectMatchDrafts,
publishBabyObjectMatchWork,
regenerateBabyObjectMatchDraftAssets,
saveBabyObjectMatchDraft,
} from '../../services/edutainment-baby-object';
import { match3dCreationClient } from '../../services/match3d-creation';
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
import {
deleteMatch3DWork,
getMatch3DWorkDetail,
listMatch3DGallery,
listMatch3DWorks,
} from '../../services/match3d-works';
import * as match3dGeneratedModelCache from '../../services/match3dGeneratedModelCache';
import {
createPuzzleAgentSession,
executePuzzleAgentAction,
getPuzzleAgentSession,
} from '../../services/puzzle-agent';
import {
getPuzzleGalleryDetail,
listPuzzleGallery,
remixPuzzleGalleryWork,
} from '../../services/puzzle-gallery';
import {
generatePuzzleOnboardingWork,
savePuzzleOnboardingWork,
} from '../../services/puzzle-onboarding';
import {
advancePuzzleNextLevel,
dragPuzzlePieceOrGroup,
getPuzzleRun,
startPuzzleRun,
submitPuzzleLeaderboard,
swapPuzzlePieces,
updatePuzzleRunPause,
usePuzzleRuntimeProp,
} from '../../services/puzzle-runtime';
import {
dragLocalPuzzlePiece,
startLocalPuzzleRun,
swapLocalPuzzlePieces,
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
import {
listPuzzleWorks,
updatePuzzleWork,
} 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 { listVisualNovelGallery } from '../../services/visual-novel-runtime';
import { listVisualNovelWorks } from '../../services/visual-novel-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 = await screen.findAllByRole('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, '创作');
const panel = getPlatformTabPanel('create');
await waitFor(() => {
expect(panel.getAttribute('aria-hidden')).toBe('false');
});
expect(
await within(panel).findByRole('tablist', { name: '玩法模板分类' }),
).toBeTruthy();
expect(
await within(panel).findByRole('button', { name: //u }),
).toBeTruthy();
expect(within(panel).queryByText('拼图工作区missing-session')).toBeNull();
return panel;
}
async function findCreationTypeButton(name: string | RegExp) {
const matcher =
typeof name === 'string' ? new RegExp(name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'u') : name;
return within(getPlatformTabPanel('create')).findByRole('button', { name: matcher });
}
function queryCreationTypeButton(name: string | RegExp) {
const matcher =
typeof name === 'string' ? new RegExp(name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'u') : name;
return within(getPlatformTabPanel('create')).queryByRole('button', { name: matcher });
}
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('tab', { name: //u }),
).toBeTruthy();
}
async function expectDraftHubGeneratingBadgeCountAtLeast(count: number) {
const panel = getPlatformTabPanel('saves');
await waitFor(() => {
expect(
within(panel).getAllByLabelText('生成中').length,
).toBeGreaterThanOrEqual(count);
});
}
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 testCreationEntryConfig = {
startCard: {
title: '新建作品',
description: '选择模板后进入对应的创作表单。',
idleBadge: '模板 Tab',
busyBadge: '正在开启',
},
typeModal: {
title: '选择创作类型',
description: '先选玩法类型,再进入对应创作工作台。',
},
eventBanner: {
title: '泥点挑战',
description: '创作活动测试横幅。',
coverImageSrc: '/creation-type-references/puzzle.webp',
prizePoolMudPoints: 1000,
startsAtText: '2026-05-01',
endsAtText: '2026-05-31',
},
creationTypes: [
{
id: 'rpg',
title: '文字冒险',
subtitle: '经典 RPG 体验',
badge: '可创建',
imageSrc: '/creation-type-references/rpg.webp',
visible: true,
open: true,
sortOrder: 10,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
id: 'puzzle',
title: '拼图',
subtitle: '拼图关卡创作',
badge: '可创建',
imageSrc: '/creation-type-references/puzzle.webp',
visible: true,
open: true,
sortOrder: 30,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
id: 'match3d',
title: '抓大鹅',
subtitle: '3D 消除关卡',
badge: '可创建',
imageSrc: '/creation-type-references/match3d.webp',
visible: true,
open: true,
sortOrder: 40,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
id: 'bark-battle',
title: '汪汪声浪',
subtitle: '声控狗狗对战',
badge: '可创建',
imageSrc: '/creation-type-references/bark-battle.webp',
visible: true,
open: true,
sortOrder: 45,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
id: 'square-hole',
title: '方洞挑战',
subtitle: '形状投放挑战',
badge: '可创建',
imageSrc: '/creation-type-references/square-hole.webp',
visible: false,
open: true,
sortOrder: 50,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
id: 'visual-novel',
title: '视觉小说',
subtitle: '分支叙事体验',
badge: '敬请期待',
imageSrc: '/creation-type-references/visual-novel.webp',
visible: false,
open: false,
sortOrder: 60,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
id: 'airp',
title: 'AIRP',
subtitle: '敬请期待',
badge: '即将开放',
imageSrc: '/creation-type-references/airp.webp',
visible: true,
open: false,
sortOrder: 70,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
id: 'creative-agent',
title: '智能创作',
subtitle: '对话式创作实验',
badge: '内测',
imageSrc: '/creation-type-references/creative-agent.webp',
visible: false,
open: true,
sortOrder: 80,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
id: 'baby-object-match',
title: '宝贝识物',
subtitle: '亲子识物分类',
badge: '可创建',
imageSrc: '/child-motion-demo/picture-book-grass-stage.png',
visible: true,
open: true,
sortOrder: 90,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
],
} satisfies CreationEntryConfig;
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/creationEntryConfigService', () => ({
fetchCreationEntryConfig: vi.fn(),
}));
vi.mock('../../services/puzzle-works', () => ({
listPuzzleWorks: vi.fn(),
updatePuzzleWork: 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/bark-battle-creation', () => ({
createBarkBattleDraft: vi.fn(),
generateAllBarkBattleImageAssets: vi.fn(),
listBarkBattleGallery: vi.fn(),
listBarkBattleWorks: vi.fn(),
publishBarkBattleWork: vi.fn(),
regenerateBarkBattleImageAsset: vi.fn(),
updateBarkBattleDraftConfig: vi.fn(),
uploadBarkBattleAsset: vi.fn(),
}));
vi.mock('../../services/edutainment-baby-object', () => ({
createBabyObjectMatchDraft: vi.fn(),
deleteLocalBabyObjectMatchDraft: vi.fn(),
hasBabyObjectMatchPlaceholderAssets: vi.fn(() => false),
listLocalBabyObjectMatchDrafts: vi.fn(),
publishBabyObjectMatchWork: vi.fn(),
regenerateBabyObjectMatchDraftAssets: vi.fn(),
saveBabyObjectMatchDraft: 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(),
updateMatch3DGeneratedItemAssets: vi.fn(),
}));
vi.mock('../../services/match3dGeneratedModelCache', () => ({
hasMatch3DGeneratedImageAsset: vi.fn(
(assets: Match3DWorkSummary['generatedItemAssets']) =>
Boolean(
assets?.some(
(asset) =>
asset.imageSrc?.trim() ||
asset.imageObjectKey?.trim() ||
asset.imageViews?.some(
(view) => view.imageSrc?.trim() || view.imageObjectKey?.trim(),
),
),
),
),
normalizeMatch3DGeneratedItemAssetsForRuntime: vi.fn(
(assets: Match3DWorkSummary['generatedItemAssets']) =>
assets ? [...assets] : [],
),
mergeMatch3DGeneratedItemAssetsForRuntime: vi.fn(
(
primaryAssets: Match3DWorkSummary['generatedItemAssets'],
fallbackAssets: Match3DWorkSummary['generatedItemAssets'],
) => (primaryAssets ? [...primaryAssets] : fallbackAssets ? [...fallbackAssets] : []),
),
preloadMatch3DGeneratedRuntimeAssets: vi.fn(() => Promise.resolve()),
}));
const match3dRuntimeServiceMocks = vi.hoisted(() => ({
createServerMatch3DRuntimeAdapter: vi.fn(),
}));
const match3dServerRuntimeAdapterMock = vi.hoisted(() => ({
clickItem: vi.fn(),
finishTimeUp: vi.fn(),
getRun: vi.fn(),
restartRun: vi.fn(),
startRun: vi.fn(),
stopRun: vi.fn(),
}));
vi.mock('../../services/match3d-runtime', async () => {
const actual = await vi.importActual<
typeof import('../../services/match3d-runtime')
>('../../services/match3d-runtime');
return {
...actual,
...match3dRuntimeServiceMocks,
};
});
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/visual-novel-runtime', () => ({
listVisualNovelGallery: vi.fn(),
startVisualNovelRun: vi.fn(),
streamVisualNovelRuntimeAction: vi.fn(),
}));
vi.mock('../../services/visual-novel-works', () => ({
deleteVisualNovelWork: vi.fn(),
getVisualNovelWorkDetail: vi.fn(),
listVisualNovelWorks: vi.fn(),
publishVisualNovelWork: vi.fn(),
updateVisualNovelWork: vi.fn(),
}));
vi.mock('../../services/visual-novel-creation', () => ({
compileVisualNovelWorkProfile: vi.fn(),
createVisualNovelSession: vi.fn(),
executeVisualNovelAction: vi.fn(),
getVisualNovelSession: vi.fn(),
streamVisualNovelMessage: 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),
startLocalPuzzleRun: vi.fn(
(...args: Parameters<typeof actual.startLocalPuzzleRun>) =>
actual.startLocalPuzzleRun(...args),
),
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,
onExecuteAction,
onCreateFromForm,
}: {
session: { sessionId: string; messages: Array<{ text: string }> } | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
onCreateFromForm?: (payload: CreatePuzzleAgentSessionRequest) => void;
}) => (
<div className="puzzle-agent-workspace-mock">
<div>{session?.sessionId ?? 'missing-session'}</div>
<div data-testid="puzzle-workspace-busy-state">
{isBusy ? 'busy' : 'idle'}
</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={() => {
const payload = {
seedText: '暖灯猫街',
workTitle: '暖灯猫街',
workDescription: '一套雨夜猫街主题拼图。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
};
if (session) {
onExecuteAction({
action: 'compile_puzzle_draft',
promptText: payload.pictureDescription,
...payload,
candidateCount: 1,
});
return;
}
onCreateFromForm?.(payload);
}}
>
稿
</button>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../puzzle-result/PuzzleResultView', () => ({
PuzzleResultView: ({
isBusy,
onExecuteAction,
onStartTestRun,
session,
onBack,
}: {
isBusy?: boolean;
onExecuteAction: (payload: {
action: string;
levelId?: string;
promptText?: string;
}) => void;
onStartTestRun?: (draft: PuzzleResultDraft) => void;
session: { draft?: PuzzleResultDraft | null };
onBack: () => void;
}) => (
<div className="puzzle-result-view-mock">
<div></div>
<label>
<input readOnly value={session.draft?.levelName ?? ''} />
</label>
<button
type="button"
onClick={() => {
onExecuteAction({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
promptText: '重新生成猫街',
});
}}
>
</button>
<button
type="button"
disabled={!session.draft}
onClick={() => {
if (session.draft) {
onStartTestRun?.(session.draft);
}
}}
>
</button>
<button type="button" disabled={isBusy}>
</button>
<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-result/Match3DResultView', () => ({
Match3DResultView: ({
draft,
onBack,
onStartTestRun,
profile,
}: {
draft?: { gameName?: string | null } | null;
onBack: () => void;
onStartTestRun: (profile: Match3DWorkSummary) => void;
profile: Match3DWorkSummary;
}) => (
<div className="match3d-result-view-mock">
<div></div>
<div>{draft?.gameName ?? profile.gameName}</div>
<button type="button" onClick={() => onStartTestRun(profile)}>
</button>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
Match3DAgentWorkspace: ({
session,
isBusy,
error,
onCreateFromForm,
}: {
session: { sessionId: string; messages: Array<{ text: string }> } | null;
isBusy?: boolean;
error?: string | null;
onCreateFromForm?: (payload: {
seedText: string;
themeText: string;
referenceImageSrc: string | null;
clearCount: number;
difficulty: number;
generateClickSound?: boolean;
}) => void;
}) => (
<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 data-testid="match3d-workspace-busy-state">
{isBusy ? 'busy' : 'idle'}
</div>
{error ? <div>{error}</div> : null}
<button
type="button"
disabled={isBusy}
onClick={() => {
onCreateFromForm?.({
seedText: '赛博水果摊题材消除9次难度6',
themeText: '赛博水果摊',
referenceImageSrc: null,
clearCount: 9,
difficulty: 6,
});
}}
>
稿
</button>
</div>
),
}));
vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
Match3DRuntimeShell: ({
run,
generatedItemAssets = [],
generatedBackgroundAsset = null,
onBack,
}: {
run: Match3DRunSnapshot | null;
generatedItemAssets?: Match3DWorkSummary['generatedItemAssets'];
generatedBackgroundAsset?: Match3DWorkSummary['generatedBackgroundAsset'];
onBack: () => void;
}) => (
<div className="match3d-runtime-shell-mock">
<div>{run?.runId ?? 'missing-run'}</div>
<div data-testid="match3d-runtime-generated-model-count">
{
generatedItemAssets.filter(
(asset) => asset.modelSrc?.trim() || asset.modelObjectKey?.trim(),
).length
}
</div>
<div data-testid="match3d-runtime-generated-asset-count">
{
generatedItemAssets.filter(
(asset) =>
asset.modelSrc?.trim() ||
asset.modelObjectKey?.trim() ||
asset.imageSrc?.trim() ||
asset.imageObjectKey?.trim() ||
asset.imageViews?.some(
(view) => view.imageSrc?.trim() || view.imageObjectKey?.trim(),
) ||
asset.backgroundMusic?.audioSrc?.trim() ||
asset.clickSound?.audioSrc?.trim() ||
asset.backgroundAsset?.imageSrc?.trim() ||
asset.backgroundAsset?.imageObjectKey?.trim() ||
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim(),
).length
}
</div>
<div data-testid="match3d-runtime-generated-item-image-count">
{
generatedItemAssets.filter(
(asset) =>
asset.imageSrc?.trim() ||
asset.imageObjectKey?.trim() ||
asset.imageViews?.some(
(view) => view.imageSrc?.trim() || view.imageObjectKey?.trim(),
),
).length
}
</div>
<div data-testid="match3d-runtime-background-music-count">
{
generatedItemAssets.filter((asset) =>
asset.backgroundMusic?.audioSrc?.trim(),
).length
}
</div>
<div data-testid="match3d-runtime-container-ui-count">
{
generatedItemAssets.filter(
(asset) =>
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim(),
).length
}
</div>
<div data-testid="match3d-runtime-top-level-background-count">
{
generatedBackgroundAsset?.imageSrc?.trim() ||
generatedBackgroundAsset?.imageObjectKey?.trim()
? 1
: 0
}
</div>
<div data-testid="match3d-runtime-top-level-container-ui-count">
{
generatedBackgroundAsset?.containerImageSrc?.trim() ||
generatedBackgroundAsset?.containerImageObjectKey?.trim()
? 1
: 0
}
</div>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({
BarkBattleConfigEditor: ({
error,
isBusy,
showBackButton,
title,
onPreview,
}: {
error?: string | null;
isBusy?: boolean;
showBackButton?: boolean;
title?: string | null;
onPreview: (payload: {
title: string;
description: string;
themeDescription: string;
playerImageDescription: string;
opponentImageDescription: string;
difficultyPreset: 'normal';
}) => void;
}) => (
<div className="bark-battle-config-editor-mock">
<div></div>
<label>
<input aria-label="汪汪作品标题" defaultValue="汪汪测试杯" />
</label>
<div data-testid="bark-battle-editor-back-state">
{showBackButton ? 'back-visible' : 'back-hidden'}
</div>
<div data-testid="bark-battle-editor-title-state">
{title === null ? 'title-hidden' : title}
</div>
<div data-testid="bark-battle-editor-busy-state">
{isBusy ? 'busy' : 'idle'}
</div>
{error ? <div>{error}</div> : null}
<button
type="button"
disabled={isBusy}
onClick={() => {
onPreview({
title: '汪汪测试杯',
description: '',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
difficultyPreset: 'normal',
});
}}
>
稿
</button>
</div>
),
}));
vi.mock('../bark-battle-creation/BarkBattleResultView', () => ({
BarkBattleResultView: ({
draft,
onBack,
onPublish,
onStartTestRun,
}: {
draft: {
title: string;
draftId: string;
workId?: string;
};
onBack: () => void;
onPublish: (draft: unknown) => void;
onStartTestRun: (draft: unknown) => void;
}) => (
<div className="bark-battle-result-view-mock">
<div>{draft.title}</div>
<div>稿ID{draft.draftId}</div>
<div>ID{draft.workId ?? 'missing-work'}</div>
<button type="button" onClick={() => onStartTestRun(draft)}>
</button>
<button type="button" onClick={() => onPublish(draft)}>
</button>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../edutainment-result/BabyObjectMatchResultView', () => ({
BabyObjectMatchResultView: ({
draft,
onBack,
onStartTestRun,
}: {
draft: BabyObjectMatchDraft;
onBack: () => void;
onStartTestRun?: (draft: BabyObjectMatchDraft) => void;
}) => (
<div className="baby-object-match-result-view-mock">
<div></div>
<div>{draft.workTitle}</div>
<button
type="button"
onClick={() => {
onStartTestRun?.(draft);
}}
>
</button>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../edutainment-runtime/BabyObjectMatchRuntimeShell', () => ({
BabyObjectMatchRuntimeShell: ({
draft,
onBack,
}: {
draft: BabyObjectMatchDraft;
onBack?: () => void;
}) => (
<div className="baby-object-match-runtime-shell-mock">
<div>{draft.profileId}</div>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../../games/bark-battle/ui/BarkBattleRuntimeShell', () => ({
BarkBattleRuntimeShell: ({
title,
workId,
runtimeMode,
publishedConfig,
onExit,
}: {
title?: string;
workId?: string;
runtimeMode?: string;
publishedConfig?: { workId?: string; playerCharacterImageSrc?: string | null } | null;
onExit?: () => void;
}) => (
<div className="bark-battle-runtime-shell-mock">
<div>{title ?? '未命名'} / {workId ?? 'missing-work'}</div>
<div data-testid="bark-battle-runtime-mode">
{runtimeMode ?? 'missing-mode'}
</div>
<div data-testid="bark-battle-runtime-work-id">
{publishedConfig?.workId ?? 'missing-config-work'}
</div>
<div data-testid="bark-battle-runtime-player-src">
{publishedConfig?.playerCharacterImageSrc ?? 'missing-player-src'}
</div>
<button type="button" onClick={onExit}>
</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 buildMockBabyObjectMatchDraft(
overrides: Partial<BabyObjectMatchDraft> = {},
): BabyObjectMatchDraft {
const itemNames = overrides.itemNames ?? ['苹果', '香蕉'];
const now = '2026-05-14T10:00:00.000Z';
return {
draftId: 'baby-object-draft-red-dot',
profileId: 'baby-object-profile-red-dot',
templateId: 'baby-object-match',
templateName: '宝贝识物',
workTitle: '宝贝识物红点草稿',
workDescription: `${itemNames[0]}${itemNames[1]}识物分类`,
itemNames,
itemAssets: [
{
itemId: 'baby-object-item-a',
itemName: itemNames[0],
imageSrc: '/baby-object/apple.png',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: itemNames[0],
},
{
itemId: 'baby-object-item-b',
itemName: itemNames[1],
imageSrc: '/baby-object/banana.png',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: itemNames[1],
},
],
visualPackage: null,
themeTags: ['寓教于乐', '宝贝识物'],
publicationStatus: 'draft',
createdAt: now,
updatedAt: now,
publishedAt: null,
...overrides,
};
}
function buildMockBarkBattleWork(
overrides: Partial<BarkBattleWorkSummary> = {},
): BarkBattleWorkSummary {
return {
workId: 'BB-C661A45F',
draftId: 'bark-battle-draft-public-1',
ownerUserId: 'user-1',
authorDisplayName: '测试玩家',
title: '汪汪公开杯',
summary: '',
themeDescription: '霓虹城市公园里的声浪擂台',
playerImageDescription: '戴红围巾的柴犬主角',
opponentImageDescription: '戴蓝色头带的哈士奇对手',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'normal',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 0,
finishCount: 0,
updatedAt: '2026-05-14T10:00:00.000Z',
publishedAt: '2026-05-14T10: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 buildMockPuzzleAgentSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
return {
sessionId: 'puzzle-session-1',
seedText: '暖灯猫街',
currentTurn: 0,
progressPercent: 0,
stage: 'collecting_anchors',
anchorPack: buildPuzzleAnchorPack(),
draft: null,
messages: [],
lastAssistantReply: '先说一个你最想做成拼图的画面。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-05-14T10:00:00.000Z',
...overrides,
};
}
function buildReadyPuzzleDraft(
overrides: Partial<PuzzleResultDraft> = {},
): PuzzleResultDraft {
return {
workTitle: '自动恢复拼图',
workDescription: '前端断连后复读 session 恢复的拼图。',
levelName: '雨夜猫街',
summary: '屋檐下的猫与暖灯街角。',
themeTags: ['猫咪', '雨夜', '拼图'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack: buildPuzzleAnchorPack(),
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle/recovered-candidate.png',
assetId: 'asset-1',
prompt: '雨夜猫街',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle/recovered-candidate.png',
coverAssetId: 'asset-1',
generationStatus: 'ready',
levels: [
{
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
pictureDescription: '屋檐下的猫与暖灯街角。',
pictureReference: null,
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle/recovered-candidate.png',
assetId: 'asset-1',
prompt: '雨夜猫街',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle/recovered-candidate.png',
coverAssetId: 'asset-1',
uiBackgroundPrompt: '雨夜猫街竖屏纯背景',
uiBackgroundImageSrc:
'/generated-puzzle-assets/puzzle-session-recovered/ui/background.png',
uiBackgroundImageObjectKey:
'generated-puzzle-assets/puzzle-session-recovered/ui/background.png',
generationStatus: 'ready',
},
],
...overrides,
};
}
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,
};
}
const match3DGeneratedUiAsset = {
prompt: '果园竖屏纯背景',
imageSrc: '/generated-match3d-assets/session/profile/background/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/background.png',
containerPrompt: '果园浅盘容器',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/container.png',
status: 'image_ready',
error: null,
} satisfies NonNullable<Match3DWorkSummary['generatedBackgroundAsset']>;
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,
},
};
const compiledAgentResultPreview = normalizeCustomWorldProfileRecord(
compiledAgentDraftSession.resultPreview?.preview,
);
if (!compiledAgentResultPreview) {
throw new Error('failed to normalize compiled agent result preview');
}
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>(() =>
resolveSelectionStageFromPath(window.location.pathname),
);
const [initialPublicWorkCode] = useState(() =>
readPublicWorkCodeFromLocationSearch(window.location.search),
);
const content = (
<RpgEntryFlowShell
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
initialPublicWorkCode={initialPublicWorkCode}
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();
vi.mocked(
match3dGeneratedModelCache.hasMatch3DGeneratedImageAsset,
).mockImplementation((assets) =>
Boolean(
assets?.some(
(asset) =>
asset.imageSrc?.trim() ||
asset.imageObjectKey?.trim() ||
asset.imageViews?.some(
(view) => view.imageSrc?.trim() || view.imageObjectKey?.trim(),
),
),
),
);
vi.mocked(
match3dGeneratedModelCache.normalizeMatch3DGeneratedItemAssetsForRuntime,
).mockImplementation((assets) => {
if (!assets?.length) {
return [];
}
const musicCarrier = assets.find((asset) =>
asset.backgroundMusic?.audioSrc?.trim(),
);
if (!musicCarrier) {
return [...assets];
}
return assets.map((asset, index) =>
index === 0
? {
...asset,
backgroundMusic: asset.backgroundMusic ?? musicCarrier.backgroundMusic,
}
: {
...asset,
backgroundMusic: null,
backgroundMusicTitle: null,
backgroundMusicStyle: null,
backgroundMusicPrompt: null,
}
);
});
vi.mocked(
match3dGeneratedModelCache.mergeMatch3DGeneratedItemAssetsForRuntime,
).mockImplementation((primaryAssets, fallbackAssets) => {
const primary = primaryAssets ?? [];
const fallback = fallbackAssets ?? [];
if (primary.length <= 0) {
return match3dGeneratedModelCache.normalizeMatch3DGeneratedItemAssetsForRuntime(
fallback,
);
}
if (fallback.length <= 0) {
return match3dGeneratedModelCache.normalizeMatch3DGeneratedItemAssetsForRuntime(
primary,
);
}
const fallbackById = new Map(fallback.map((asset) => [asset.itemId, asset]));
return match3dGeneratedModelCache.normalizeMatch3DGeneratedItemAssetsForRuntime(
primary.map((asset) => {
const fallbackAsset = fallbackById.get(asset.itemId);
return fallbackAsset
? {
...asset,
imageSrc: asset.imageSrc ?? fallbackAsset.imageSrc ?? null,
imageObjectKey:
asset.imageObjectKey ?? fallbackAsset.imageObjectKey ?? null,
imageViews:
asset.imageViews && asset.imageViews.length > 0
? asset.imageViews
: (fallbackAsset.imageViews ?? []),
backgroundMusic:
asset.backgroundMusic ?? fallbackAsset.backgroundMusic ?? null,
backgroundAsset:
asset.backgroundAsset ?? fallbackAsset.backgroundAsset ?? null,
}
: asset;
}),
);
});
vi.mocked(
match3dGeneratedModelCache.preloadMatch3DGeneratedRuntimeAssets,
).mockResolvedValue(undefined);
vi.mocked(createServerMatch3DRuntimeAdapter).mockReturnValue(
match3dServerRuntimeAdapterMock,
);
match3dServerRuntimeAdapterMock.startRun.mockRejectedValue(
new Error('未启动抓大鹅运行态'),
);
match3dServerRuntimeAdapterMock.clickItem.mockRejectedValue(
new Error('未执行抓大鹅点击'),
);
match3dServerRuntimeAdapterMock.restartRun.mockRejectedValue(
new Error('未重新开始抓大鹅运行态'),
);
match3dServerRuntimeAdapterMock.finishTimeUp.mockResolvedValue({
run: buildMockMatch3DRun('match3d-profile-time-up'),
});
match3dServerRuntimeAdapterMock.stopRun.mockResolvedValue({
run: buildMockMatch3DRun('match3d-profile-stopped'),
});
window.history.replaceState(null, '', '/');
window.sessionStorage.clear();
window.localStorage.clear();
window.localStorage.setItem(
'genarrative.puzzle-onboarding.first-visit.v1',
'1',
);
vi.mocked(fetchCreationEntryConfig).mockResolvedValue(
testCreationEntryConfig,
);
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 20,
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(listVisualNovelGallery).mockResolvedValue({ works: [] });
vi.mocked(listVisualNovelWorks).mockResolvedValue({ works: [] });
vi.mocked(listLocalBabyObjectMatchDrafts).mockResolvedValue([]);
vi.mocked(deleteLocalBabyObjectMatchDraft).mockResolvedValue([]);
vi.mocked(saveBabyObjectMatchDraft).mockImplementation(async (payload) => ({
draft: payload.draft,
}));
vi.mocked(regenerateBabyObjectMatchDraftAssets).mockImplementation(
async (draft) => ({ draft }),
);
vi.mocked(publishBabyObjectMatchWork).mockImplementation(async (payload) => ({
draft: {
...payload.draft,
publicationStatus: 'published',
publishedAt: '2026-05-14T10:10:00.000Z',
},
publicWorkCode: `BO-${payload.draft.profileId}`,
}));
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(createBarkBattleDraft).mockResolvedValue({
draftId: 'bark-battle-draft-1',
workId: 'bark-battle-work-1',
title: '汪汪测试杯',
description: '',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
difficultyPreset: 'normal',
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
updatedAt: '2026-05-14T10:00:00.000Z',
});
vi.mocked(generateAllBarkBattleImageAssets).mockResolvedValue({
assets: {
'player-character': {
imageSrc: '/generated-bark-battle/player.png',
assetId: 'asset-player',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-player',
prompt: 'player',
},
'opponent-character': {
imageSrc: '/generated-bark-battle/opponent.png',
assetId: 'asset-opponent',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-opponent',
prompt: 'opponent',
},
'ui-background': {
imageSrc: '/generated-bark-battle/background.png',
assetId: 'asset-background',
model: 'gpt-image-2-all',
size: '1024*1792',
taskId: 'task-background',
prompt: 'background',
},
},
failures: {},
});
vi.mocked(updateBarkBattleDraftConfig).mockImplementation(async (payload) => ({
draftId: payload.draftId,
workId: payload.workId ?? 'bark-battle-work-1',
title: payload.title,
description: payload.description,
themeDescription: payload.themeDescription,
playerImageDescription: payload.playerImageDescription,
opponentImageDescription: payload.opponentImageDescription,
playerCharacterImageSrc: payload.playerCharacterImageSrc,
opponentCharacterImageSrc: payload.opponentCharacterImageSrc,
uiBackgroundImageSrc: payload.uiBackgroundImageSrc,
difficultyPreset: payload.difficultyPreset,
configVersion: (payload.configVersion ?? 1) + 1,
rulesetVersion: payload.rulesetVersion ?? 'bark-battle-ruleset-v1',
updatedAt: '2026-05-14T10:01:00.000Z',
}));
vi.mocked(listBarkBattleWorks).mockResolvedValue({ items: [] });
vi.mocked(listBarkBattleGallery).mockResolvedValue({ items: [] });
vi.mocked(publishBarkBattleWork).mockResolvedValue({
workId: 'bark-battle-work-1',
draftId: 'bark-battle-draft-1',
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
playTypeId: 'bark-battle',
title: '汪汪测试杯',
description: '',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
difficultyPreset: 'normal',
updatedAt: '2026-05-14T10:00:00.000Z',
publishedAt: '2026-05-14T10:00:00.000Z',
});
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(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(updatePuzzleWork).mockImplementation(async (profileId, payload) => ({
item: {
workId: `puzzle-work-${profileId}`,
profileId,
ownerUserId: mockAuthUser.id,
sourceSessionId: null,
authorDisplayName: mockAuthUser.displayName,
workTitle: payload.workTitle ?? payload.levelName,
workDescription: payload.workDescription ?? payload.summary,
levelName: payload.levelName,
summary: payload.summary,
themeTags: payload.themeTags,
coverImageSrc: payload.coverImageSrc ?? null,
coverAssetId: payload.coverAssetId ?? null,
publicationStatus: 'draft',
updatedAt: '2026-05-12T10:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
levels: payload.levels,
anchorPack: buildPuzzleAnchorPack(),
},
}));
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(startLocalPuzzleRun).mockImplementation((item, levelId) => {
const runId = `local-puzzle-run-${item.profileId}`;
const firstLevel = item.levels?.[0] ?? null;
return {
...buildMockPuzzleRun(item.profileId, firstLevel?.levelName ?? item.levelName),
runId,
entryProfileId: item.profileId,
currentLevel: {
...buildMockPuzzleRun(item.profileId, firstLevel?.levelName ?? item.levelName)
.currentLevel!,
runId,
levelId: levelId ?? firstLevel?.levelId ?? null,
coverImageSrc: firstLevel?.coverImageSrc ?? item.coverImageSrc,
uiBackgroundImageSrc:
firstLevel?.uiBackgroundImageSrc ??
(firstLevel?.uiBackgroundImageObjectKey
? `/${firstLevel.uiBackgroundImageObjectKey.replace(/^\/+/u, '')}`
: null),
uiBackgroundImageObjectKey:
firstLevel?.uiBackgroundImageObjectKey ?? null,
backgroundMusic: firstLevel?.backgroundMusic ?? null,
},
};
});
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('tablist', { name: '玩法模板分类' }).className,
).toContain(
'scroll-px-3',
);
expect(
screen.getByRole('tab', { name: '最近创作' }).getAttribute('aria-selected'),
).toBe('true');
expect(
await findCreationTypeButton('拼图'),
).toBeTruthy();
expect(
await findCreationTypeButton('文字冒险'),
).toBeTruthy();
expect(
await findCreationTypeButton('抓大鹅'),
).toBeTruthy();
expect(
await findCreationTypeButton('汪汪声浪'),
).toBeTruthy();
expect(
await findCreationTypeButton('宝贝识物'),
).toBeTruthy();
expect(
queryCreationTypeButton('智能创作'),
).toBeNull();
expect(
screen
.getByRole('tab', { name: '最近创作' })
.querySelector('[class*="bg-[#d9793f]"]'),
).toBeTruthy();
expect(screen.queryByRole('button', { name: /智能创作/u })).toBeNull();
expect(screen.queryByPlaceholderText('问一问陶泥儿')).toBeNull();
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
});
test('create tab opens match3d entry form from the template card', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('抓大鹅'));
expect(await screen.findByText('抓大鹅工作区missing-session')).toBeTruthy();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});
test('create tab opens puzzle entry form from the template card', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
expect(await screen.findByText('拼图工作区missing-session')).toBeTruthy();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
});
test('create tab opens bark battle entry form from the template card', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('汪汪声浪'));
expect(await screen.findByText('汪汪声浪配置表单')).toBeTruthy();
expect(screen.queryByText('汪汪声浪运行态')).toBeNull();
expect(createBarkBattleDraft).not.toHaveBeenCalled();
expect(publishBarkBattleWork).not.toHaveBeenCalled();
});
test('bark battle draft result can test before publish and publish to work detail', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('汪汪声浪'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(createBarkBattleDraft).toHaveBeenCalledWith({
title: '汪汪测试杯',
description: '',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
difficultyPreset: 'normal',
});
await waitFor(() => {
expect(updateBarkBattleDraftConfig).toHaveBeenCalledWith(
expect.objectContaining({
draftId: 'bark-battle-draft-1',
workId: 'bark-battle-work-1',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
}),
);
});
expect(await screen.findByText(/汪汪声浪结果页:汪汪测试杯/u)).toBeTruthy();
expect(await screen.findByText('作品IDbark-battle-work-1')).toBeTruthy();
expect(publishBarkBattleWork).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: '试玩' }));
expect(await screen.findByText(/汪汪声浪运行态:汪汪测试杯/u)).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回配置' }));
expect(await screen.findByText(/汪汪声浪结果页:汪汪测试杯/u)).toBeTruthy();
await user.click(screen.getByRole('button', { name: '发布' }));
expect(publishBarkBattleWork).toHaveBeenCalledWith({
draftId: 'bark-battle-draft-1',
workId: 'bark-battle-work-1',
publishedSnapshot: expect.objectContaining({
title: '汪汪测试杯',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
}),
});
await waitFor(() => {
expect(window.location.pathname).toBe('/works/detail');
expect(window.location.search).toBe('?work=BB-TLEWORK1');
});
expect(await screen.findByText('分享给朋友')).toBeTruthy();
expect(screen.getByText(/作品号BB-TLEWORK1/u)).toBeTruthy();
expect(screen.queryByText(/汪汪声浪运行态:汪汪测试杯/u)).toBeNull();
});
test('direct bark battle runtime public code opens published runtime', async () => {
const publicWork = buildMockBarkBattleWork();
vi.mocked(listBarkBattleGallery).mockResolvedValueOnce({
items: [publicWork],
});
window.history.replaceState(
null,
'',
'/runtime/bark-battle?work=BB-C661A45F',
);
render(<TestWrapper withAuth />);
expect(await screen.findByText(/汪汪声浪运行态:汪汪公开杯/u)).toBeTruthy();
expect(screen.getByTestId('bark-battle-runtime-mode').textContent).toBe(
'published',
);
expect(screen.getByTestId('bark-battle-runtime-work-id').textContent).toBe(
'BB-C661A45F',
);
expect(screen.getByTestId('bark-battle-runtime-player-src').textContent).toBe(
'/generated-bark-battle/player.png',
);
expect(screen.queryByText('分享给朋友')).toBeNull();
});
test('bark battle form checks mud points before creating image assets', async () => {
const user = userEvent.setup();
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 2,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-05-14T10:00:00.000Z',
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('汪汪声浪'));
const titleInput = await screen.findByLabelText('汪汪作品标题');
await user.clear(titleInput);
await user.type(titleInput, '自定义声浪杯');
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
within(noticeDialog).getByText('本次需要 3 泥点,当前 2 泥点。'),
).toBeTruthy();
expect(screen.getByText('汪汪声浪配置表单')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull();
expect((screen.getByLabelText('汪汪作品标题') as HTMLInputElement).value).toBe(
'自定义声浪杯',
);
expect(createBarkBattleDraft).not.toHaveBeenCalled();
expect(generateAllBarkBattleImageAssets).not.toHaveBeenCalled();
});
test('bark battle draft is visible in draft shelf while image assets are generating', async () => {
const user = userEvent.setup();
vi.mocked(generateAllBarkBattleImageAssets).mockImplementation(
() =>
new Promise<Awaited<ReturnType<typeof generateAllBarkBattleImageAssets>>>(
() => undefined,
),
);
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('汪汪声浪'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('自动生成素材')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回编辑' }));
await openDraftHub(user);
const panel = getPlatformTabPanel('saves');
expect(
await within(panel).findByRole('button', {
name: /继续创作《汪汪测试杯》/u,
}),
).toBeTruthy();
await expectDraftHubGeneratingBadgeCountAtLeast(1);
expect(listBarkBattleWorks).toHaveBeenCalled();
});
test('published bark battle stays visible when refresh temporarily returns only the duplicate draft', async () => {
const user = userEvent.setup();
vi.mocked(listBarkBattleWorks).mockResolvedValueOnce({
items: [
{
workId: 'bark-battle-work-1',
draftId: 'bark-battle-draft-1',
ownerUserId: 'user-1',
authorDisplayName: '测试玩家',
title: '汪汪测试杯',
summary: '',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'normal',
status: 'draft',
generationStatus: 'ready',
publishReady: true,
playCount: 0,
updatedAt: '2026-05-14T10:01:00.000Z',
publishedAt: null,
},
],
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('汪汪声浪'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
await waitFor(() => {
expect(updateBarkBattleDraftConfig).toHaveBeenCalledWith(
expect.objectContaining({
draftId: 'bark-battle-draft-1',
workId: 'bark-battle-work-1',
}),
);
});
expect(await screen.findByText(/汪汪声浪结果页:汪汪测试杯/u)).toBeTruthy();
await user.click(screen.getByRole('button', { name: '发布' }));
await waitFor(() => {
expect(window.location.pathname).toBe('/works/detail');
});
await user.click(await screen.findByRole('button', { name: '返回' }));
await openDraftHub(user);
const panel = getPlatformTabPanel('saves');
await user.click(within(panel).getByRole('button', { name: /已发布/u }));
expect(await within(panel).findByText('汪汪测试杯')).toBeTruthy();
expect(within(panel).getByRole('button', { name: /查看详情《汪汪测试杯》/u })).toBeTruthy();
});
test('running match3d form generation can return to draft tab and reopen progress', async () => {
const user = userEvent.setup();
const runningSession = buildMockMatch3DAgentSession({
draft: null,
stage: 'collecting_config',
});
let resolveCompile!: (value: {
session: Match3DAgentSessionSnapshot;
}) => void;
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: runningSession,
});
vi.mocked(match3dCreationClient.executeAction).mockReturnValue(
new Promise((resolve) => {
resolveCompile = resolve;
}),
);
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
expect(await screen.findByText('抓大鹅草稿')).toBeTruthy();
await expectDraftHubGeneratingBadgeCountAtLeast(1);
await user.click(
screen.getByRole('button', { name: /继续创作《抓大鹅草稿》/u }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
await act(async () => {
resolveCompile({ session: buildMockMatch3DAgentSession() });
});
});
test('running match3d persisted draft reopens progress instead of unfinished result', async () => {
const user = userEvent.setup();
const runningSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-running-persisted-session',
draft: null,
stage: 'collecting_config',
});
const persistedRunningSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-running-persisted-session',
stage: 'draft_ready',
draft: {
profileId: 'match3d-running-persisted-profile',
gameName: '赛博水果摊',
themeText: '赛博水果摊',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets: [],
},
});
const persistedRunningWork: Match3DWorkSummary = {
workId: 'match3d-running-persisted-work',
profileId: 'match3d-running-persisted-profile',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-running-persisted-session',
gameName: '赛博水果摊',
themeText: '赛博水果摊',
summary: '正在生成玩法素材。',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-14T10:30:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets: [],
};
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: runningSession,
});
vi.mocked(match3dCreationClient.executeAction).mockRejectedValueOnce(
new Error('素材生成仍在后台处理'),
);
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: persistedRunningSession,
});
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [persistedRunningWork],
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: persistedRunningWork,
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findAllByText('素材生成仍在后台处理'),
).not.toHaveLength(0);
vi.mocked(match3dCreationClient.getSession).mockClear();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await expectDraftHubGeneratingBadgeCountAtLeast(1);
await user.click(
await screen.findByRole('button', { name: /继续创作《赛博水果摊》/u }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
expect(match3dCreationClient.getSession).toHaveBeenCalledWith(
'match3d-running-persisted-session',
);
});
test('persisted generating match3d draft opens generation progress after refresh', async () => {
const user = userEvent.setup();
const persistedGeneratingWork: Match3DWorkSummary = {
workId: 'match3d-work-generating',
profileId: 'match3d-profile-generating',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-generating',
gameName: '生成中抓鹅',
themeText: '霓虹水果摊',
summary: '刷新后仍应回到抓大鹅生成面板。',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-18T12:05:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
generatedItemAssets: [],
};
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [persistedGeneratingWork],
});
vi.mocked(match3dCreationClient.getSession).mockResolvedValueOnce({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-session-generating',
draft: {
profileId: 'match3d-profile-generating',
gameName: '生成中抓鹅',
themeText: '霓虹水果摊',
summary: '刷新后仍应回到抓大鹅生成面板。',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets: [],
},
stage: 'draft_ready',
lastAssistantReply: '正在生成抓大鹅素材。',
updatedAt: '2026-05-18T12:05:00.000Z',
}),
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
await user.click(
await screen.findByRole('button', { name: /继续创作《生成中抓鹅》/u }),
);
await waitFor(() => {
expect(match3dCreationClient.getSession).toHaveBeenCalledWith(
'match3d-session-generating',
);
});
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
expect(
screen
.getByRole('progressbar', { name: '抓大鹅草稿生成进度' })
.getAttribute('aria-valuenow'),
).toBe('0');
expect(screen.getByText('0%')).toBeTruthy();
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
'match3d-profile-generating',
);
});
test('running match3d form generation keeps other creation templates available', async () => {
const user = userEvent.setup();
const runningSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-running-session',
draft: null,
stage: 'collecting_config',
});
let resolveCompile!: (value: {
session: Match3DAgentSessionSnapshot;
}) => void;
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: runningSession,
});
vi.mocked(match3dCreationClient.executeAction).mockReturnValue(
new Promise((resolve) => {
resolveCompile = resolve;
}),
);
const puzzleReadySession: PuzzleAgentSessionSnapshot = {
sessionId: 'puzzle-session-parallel-1',
seedText: '暖灯猫街',
currentTurn: 1,
progressPercent: 100,
stage: 'ready_to_publish',
anchorPack: buildPuzzleAnchorPack(),
draft: {
workTitle: '并行拼图',
workDescription: '抓大鹅后台生成时创建的新拼图。',
levelName: '并行拼图',
summary: '抓大鹅后台生成时创建的新拼图。',
themeTags: ['并行创作'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack: buildPuzzleAnchorPack(),
candidates: [
{
candidateId: 'candidate-parallel-1',
imageSrc: '/puzzle/parallel-candidate.png',
assetId: 'asset-parallel-1',
prompt: '暖灯猫街',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-parallel-1',
coverImageSrc: '/puzzle/parallel-candidate.png',
coverAssetId: 'asset-parallel-1',
generationStatus: 'ready',
levels: [
{
levelId: 'puzzle-level-parallel-1',
levelName: '并行拼图',
pictureDescription: '一只猫在雨夜灯牌下回头。',
pictureReference: null,
candidates: [
{
candidateId: 'candidate-parallel-1',
imageSrc: '/puzzle/parallel-candidate.png',
assetId: 'asset-parallel-1',
prompt: '暖灯猫街',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-parallel-1',
coverImageSrc: '/puzzle/parallel-candidate.png',
coverAssetId: 'asset-parallel-1',
generationStatus: 'ready',
},
],
},
messages: [],
lastAssistantReply: '拼图草稿已经生成。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-05-13T10:00:00.000Z',
};
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
operation: {
operationId: 'compile-puzzle-parallel-1',
type: 'compile_puzzle_draft',
status: 'completed',
phaseLabel: '已完成',
phaseDetail: '草稿已生成',
progress: 1,
},
session: puzzleReadySession,
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
const puzzleTab = await screen.findByRole('tab', { name: '拼图' });
expect((puzzleTab as HTMLButtonElement).disabled).toBe(false);
await user.click(puzzleTab);
const generatePuzzleButton = await screen.findByRole('button', {
name: '生成草稿',
});
expect((generatePuzzleButton as HTMLButtonElement).disabled).toBe(false);
await user.click(generatePuzzleButton);
await waitFor(() => {
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
});
expect(executePuzzleAgentAction).toHaveBeenCalledWith(
'puzzle-session-1',
expect.objectContaining({
action: 'compile_puzzle_draft',
}),
);
expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(1);
await act(async () => {
resolveCompile({ session: buildMockMatch3DAgentSession() });
});
});
test('running match3d form generation keeps same template generation available', async () => {
const user = userEvent.setup();
const firstSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-parallel-session-1',
draft: null,
stage: 'collecting_config',
});
const secondSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-parallel-session-2',
draft: null,
stage: 'collecting_config',
});
let resolveFirstCompile!: (value: {
session: Match3DAgentSessionSnapshot;
}) => void;
let resolveSecondCompile!: (value: {
session: Match3DAgentSessionSnapshot;
}) => void;
vi.mocked(match3dCreationClient.createSession)
.mockResolvedValueOnce({ session: firstSession })
.mockResolvedValueOnce({ session: secondSession });
vi.mocked(match3dCreationClient.executeAction)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirstCompile = resolve;
}),
)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveSecondCompile = resolve;
}),
);
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
const match3dTab = await screen.findByRole('tab', { name: '抓大鹅' });
expect((match3dTab as HTMLButtonElement).disabled).toBe(false);
await user.click(match3dTab);
const secondGenerateButton = await screen.findByRole('button', {
name: '生成抓大鹅草稿',
});
expect((secondGenerateButton as HTMLButtonElement).disabled).toBe(false);
expect(screen.getByTestId('match3d-workspace-busy-state')).toHaveProperty(
'textContent',
'idle',
);
await user.click(secondGenerateButton);
await waitFor(() => {
expect(match3dCreationClient.createSession).toHaveBeenCalledTimes(2);
});
expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(2);
expect(match3dCreationClient.executeAction).toHaveBeenNthCalledWith(
1,
'match3d-parallel-session-1',
expect.objectContaining({ action: 'match3d_compile_draft' }),
);
expect(match3dCreationClient.executeAction).toHaveBeenNthCalledWith(
2,
'match3d-parallel-session-2',
expect.objectContaining({ action: 'match3d_compile_draft' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await waitFor(() => {
expect(screen.getAllByText('抓大鹅草稿').length).toBeGreaterThanOrEqual(2);
});
await expectDraftHubGeneratingBadgeCountAtLeast(2);
await act(async () => {
resolveFirstCompile({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-parallel-session-1',
}),
});
resolveSecondCompile({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-parallel-session-2',
}),
});
});
});
test('running puzzle form generation creates a new puzzle draft on same template submit', async () => {
const user = userEvent.setup();
const firstSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-parallel-session-1',
});
const secondSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-parallel-session-2',
});
let resolveFirstCompile!: (value: {
operation: {
operationId: string;
type: 'compile_puzzle_draft';
status: 'completed';
phaseLabel: string;
phaseDetail: string;
progress: number;
};
session: PuzzleAgentSessionSnapshot;
}) => void;
let resolveSecondCompile!: (value: {
operation: {
operationId: string;
type: 'compile_puzzle_draft';
status: 'completed';
phaseLabel: string;
phaseDetail: string;
progress: number;
};
session: PuzzleAgentSessionSnapshot;
}) => void;
vi.mocked(createPuzzleAgentSession)
.mockResolvedValueOnce({ session: firstSession })
.mockResolvedValueOnce({ session: secondSession });
vi.mocked(executePuzzleAgentAction)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirstCompile = resolve;
}),
)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveSecondCompile = resolve;
}),
);
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
const puzzleTab = await screen.findByRole('tab', { name: '拼图' });
expect((puzzleTab as HTMLButtonElement).disabled).toBe(false);
await user.click(puzzleTab);
expect(await screen.findByText('拼图工作区missing-session')).toBeTruthy();
expect(screen.getByTestId('puzzle-workspace-busy-state')).toHaveProperty(
'textContent',
'idle',
);
const secondGenerateButton = await screen.findByRole('button', {
name: '生成草稿',
});
expect((secondGenerateButton as HTMLButtonElement).disabled).toBe(false);
await user.click(secondGenerateButton);
await waitFor(() => {
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(2);
});
expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2);
expect(executePuzzleAgentAction).toHaveBeenNthCalledWith(
1,
'puzzle-parallel-session-1',
expect.objectContaining({ action: 'compile_puzzle_draft' }),
);
expect(executePuzzleAgentAction).toHaveBeenNthCalledWith(
2,
'puzzle-parallel-session-2',
expect.objectContaining({ action: 'compile_puzzle_draft' }),
);
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await waitFor(() => {
expect(screen.getAllByText('拼图草稿').length).toBeGreaterThanOrEqual(2);
});
await expectDraftHubGeneratingBadgeCountAtLeast(2);
await act(async () => {
resolveFirstCompile({
operation: {
operationId: 'compile-puzzle-parallel-1',
type: 'compile_puzzle_draft',
status: 'completed',
phaseLabel: '已完成',
phaseDetail: '草稿已生成',
progress: 1,
},
session: buildMockPuzzleAgentSession({
sessionId: 'puzzle-parallel-session-1',
}),
});
resolveSecondCompile({
operation: {
operationId: 'compile-puzzle-parallel-2',
type: 'compile_puzzle_draft',
status: 'completed',
phaseLabel: '已完成',
phaseDetail: '草稿已生成',
progress: 1,
},
session: buildMockPuzzleAgentSession({
sessionId: 'puzzle-parallel-session-2',
}),
});
});
});
test('running puzzle draft opens generation progress from draft tab', async () => {
const user = userEvent.setup();
const runningSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-running-session',
draft: null,
stage: 'collecting_anchors',
progressPercent: 20,
});
let resolveCompile!: (value: {
operation: {
operationId: string;
type: 'compile_puzzle_draft';
status: 'completed';
phaseLabel: string;
phaseDetail: string;
progress: number;
};
session: PuzzleAgentSessionSnapshot;
}) => void;
vi.mocked(createPuzzleAgentSession).mockResolvedValueOnce({
session: runningSession,
});
vi.mocked(executePuzzleAgentAction).mockReturnValueOnce(
new Promise((resolve) => {
resolveCompile = resolve;
}),
);
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
session: runningSession,
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await expectDraftHubGeneratingBadgeCountAtLeast(1);
await user.click(
screen.getByRole('button', { name: /继续创作《拼图草稿》/u }),
);
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
expect(screen.queryByText('拼图结果页')).toBeNull();
await act(async () => {
resolveCompile({
operation: {
operationId: 'compile-puzzle-running',
type: 'compile_puzzle_draft',
status: 'completed',
phaseLabel: '已完成',
phaseDetail: '草稿已生成',
progress: 1,
},
session: buildMockPuzzleAgentSession({
sessionId: 'puzzle-running-session',
}),
});
});
});
test('puzzle form checks mud points before creating a draft', async () => {
const user = userEvent.setup();
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 1,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-05-14T10:00:00.000Z',
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
within(noticeDialog).getByText('本次需要 2 泥点,当前 1 泥点。'),
).toBeTruthy();
expect(screen.getByText('拼图工作区missing-session')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
expect(executePuzzleAgentAction).not.toHaveBeenCalled();
});
test('match3d form checks mud points before creating a draft', async () => {
const user = userEvent.setup();
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 9,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-05-14T10:00:00.000Z',
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('抓大鹅'));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
within(noticeDialog).getByText('本次需要 10 泥点,当前 9 泥点。'),
).toBeTruthy();
expect(screen.getByText('抓大鹅工作区missing-session')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
expect(match3dCreationClient.executeAction).not.toHaveBeenCalled();
});
test('match3d result trial passes generated models into first runtime mount', async () => {
const user = userEvent.setup();
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
modelSrc: null,
modelObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
modelFileName: 'strawberry.glb',
taskUuid: 'task-strawberry',
subscriptionKey: 'sub-strawberry',
status: 'model_ready',
error: null,
backgroundAsset: match3DGeneratedUiAsset,
},
];
const match3dDraftWork: Match3DWorkSummary = {
workId: 'match3d-work-draft-1',
profileId: 'match3d-profile-draft-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-draft-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-01T10:30:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets,
};
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [match3dDraftWork],
});
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-session-draft-1',
draft: {
profileId: 'match3d-profile-draft-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets,
},
}),
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: match3dDraftWork,
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(match3dDraftWork.profileId),
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
await user.click(
await screen.findByRole('button', { name: /继续创作《水果抓大鹅》/u }),
);
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-draft-1',
{},
);
});
expect(
await screen.findByTestId('match3d-runtime-generated-model-count'),
).toHaveProperty('textContent', '1');
expect(
screen.getByTestId('match3d-runtime-generated-asset-count'),
).toHaveProperty('textContent', '1');
});
test('match3d result trial passes generated 2D image views into first runtime mount', async () => {
const user = userEvent.setup();
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({
viewId: `view-${String(viewIndex).padStart(2, '0')}`,
viewIndex,
imageSrc:
`/generated-match3d-assets/session/profile/items/match3d-item-1-item/views/view-${String(viewIndex).padStart(2, '0')}.png`,
imageObjectKey: null,
})),
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
];
const match3dDraftWork: Match3DWorkSummary = {
workId: 'match3d-work-draft-2d-1',
profileId: 'match3d-profile-draft-2d-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-draft-2d-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-13T10:30:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets,
};
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [match3dDraftWork],
});
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-session-draft-2d-1',
draft: {
profileId: 'match3d-profile-draft-2d-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets,
},
}),
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: match3dDraftWork,
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(match3dDraftWork.profileId),
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
await user.click(
await screen.findByRole('button', { name: /继续创作《水果抓大鹅》/u }),
);
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-draft-2d-1',
{},
);
});
expect(
await screen.findByTestId('match3d-runtime-generated-model-count'),
).toHaveProperty('textContent', '0');
await waitFor(() => {
expect(
screen.getByTestId('match3d-runtime-generated-item-image-count'),
).toHaveProperty('textContent', '1');
expect(
screen.getByTestId('match3d-runtime-generated-asset-count'),
).toHaveProperty('textContent', '1');
});
});
test('match3d result back returns to platform creation page', async () => {
const user = userEvent.setup();
const match3dDraftWork: Match3DWorkSummary = {
workId: 'match3d-work-back-1',
profileId: 'match3d-profile-back-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-back-1',
gameName: '自动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '休闲', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-12T12:10:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets: [],
};
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [match3dDraftWork],
});
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-session-back-1',
draft: {
profileId: 'match3d-profile-back-1',
gameName: '自动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '休闲', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets: [],
},
}),
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
await user.click(
await screen.findByRole('button', { name: /继续创作《自动试玩抓大鹅》/u }),
);
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
expect(
await screen.findByRole('tablist', { name: '玩法模板分类' }),
).toBeTruthy();
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
});
test('match3d draft generation auto starts trial and runtime back opens draft result', async () => {
const user = userEvent.setup();
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
modelSrc: null,
modelObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
modelFileName: 'strawberry.glb',
taskUuid: 'task-strawberry',
subscriptionKey: 'sub-strawberry',
status: 'model_ready',
error: null,
backgroundAsset: match3DGeneratedUiAsset,
},
];
const generatedSession = buildMockMatch3DAgentSession({
stage: 'draft_ready',
draft: {
profileId: 'match3d-profile-auto-1',
gameName: '自动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅', '试玩'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets,
},
});
const generatedProfile: Match3DWorkSummary = {
workId: 'match3d-work-auto-1',
profileId: 'match3d-profile-auto-1',
ownerUserId: 'user-1',
sourceSessionId: generatedSession.sessionId,
gameName: '自动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅', '试玩'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-12T10:00:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets,
};
vi.mocked(match3dCreationClient.executeAction).mockResolvedValueOnce({
session: generatedSession,
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: generatedProfile,
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(generatedProfile.profileId),
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText(/抓大鹅运行态/u)).toBeTruthy();
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-auto-1',
);
expect(
await screen.findByTestId('match3d-runtime-generated-model-count'),
).toHaveProperty('textContent', '1');
await waitFor(() => {
expect(
screen.getByTestId('match3d-runtime-top-level-background-count'),
).toHaveProperty('textContent', '1');
});
expect(
screen.getByTestId('match3d-runtime-top-level-container-ui-count'),
).toHaveProperty('textContent', '1');
expect(
match3dGeneratedModelCache.preloadMatch3DGeneratedRuntimeAssets,
).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/container.png',
}),
{ expireSeconds: 300 },
);
await user.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
expect(screen.getByText('自动试玩抓大鹅')).toBeTruthy();
});
test('match3d result trial loads generated background and container assets', async () => {
const user = userEvent.setup();
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
{
itemId: 'match3d-trial-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-trial-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-trial-item-1-item/image.png',
imageViews: [],
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
backgroundAsset: match3DGeneratedUiAsset,
},
];
const match3dDraftWork: Match3DWorkSummary = {
workId: 'match3d-work-trial-ui',
profileId: 'match3d-profile-trial-ui',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-trial-ui',
gameName: '手动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-14T11:00:00.000Z',
publishedAt: null,
publishReady: false,
generatedBackgroundAsset: null,
generatedItemAssets,
};
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [match3dDraftWork],
});
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-session-trial-ui',
stage: 'draft_ready',
draft: {
profileId: 'match3d-profile-trial-ui',
gameName: '手动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets,
},
}),
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: match3dDraftWork,
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(match3dDraftWork.profileId),
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
await user.click(
await screen.findByRole('button', { name: /继续创作《手动试玩抓大鹅》/u }),
);
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' }));
expect(await screen.findByText(/抓大鹅运行态/u)).toBeTruthy();
await waitFor(() => {
expect(
screen.getByTestId('match3d-runtime-top-level-background-count'),
).toHaveProperty('textContent', '1');
});
expect(
screen.getByTestId('match3d-runtime-top-level-container-ui-count'),
).toHaveProperty('textContent', '1');
expect(
match3dGeneratedModelCache.preloadMatch3DGeneratedRuntimeAssets,
).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/container.png',
}),
{ expireSeconds: 300 },
);
});
test('completed match3d draft notice first opens trial then reopens result', async () => {
const user = userEvent.setup();
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
{
itemId: 'match3d-notice-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-notice-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-notice-item-1-item/image.png',
imageViews: [],
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: 'task-notice-strawberry',
subscriptionKey: 'sub-notice-strawberry',
status: 'image_ready',
error: null,
backgroundAsset: match3DGeneratedUiAsset,
},
];
const runningSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-notice-session-1',
draft: null,
stage: 'collecting_config',
});
const generatedSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-notice-session-1',
stage: 'draft_ready',
draft: {
profileId: 'match3d-notice-profile-1',
gameName: '红点自动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets,
},
});
const generatedProfile: Match3DWorkSummary = {
workId: 'match3d-notice-work-1',
profileId: 'match3d-notice-profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-notice-session-1',
gameName: '红点自动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-14T10:00:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets,
};
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: runningSession,
});
let resolveCompile!: (value: {
session: Match3DAgentSessionSnapshot;
}) => void;
vi.mocked(match3dCreationClient.executeAction).mockReturnValue(
new Promise((resolve) => {
resolveCompile = resolve;
}),
);
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: generatedSession,
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: generatedProfile,
});
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [generatedProfile],
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(generatedProfile.profileId),
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await expectDraftHubGeneratingBadgeCountAtLeast(1);
await act(async () => {
resolveCompile({ session: generatedSession });
});
expect(await screen.findByLabelText('新生成完成')).toBeTruthy();
await user.click(
await screen.findByRole('button', {
name: /继续创作《红点自动试玩抓大鹅》/u,
}),
);
expect(await screen.findByText(/抓大鹅运行态/u)).toBeTruthy();
expect(screen.queryByText('抓大鹅草稿生成进度')).toBeNull();
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(
screen.getByTestId('match3d-runtime-top-level-background-count'),
).toHaveProperty('textContent', '1');
});
expect(
screen.getByTestId('match3d-runtime-top-level-container-ui-count'),
).toHaveProperty('textContent', '1');
await user.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
await user.click(await screen.findByRole('button', { name: '返回' }));
await openDraftHub(user);
expect(screen.queryByLabelText('新生成完成')).toBeNull();
await user.click(
await screen.findByRole('button', {
name: /继续创作《红点自动试玩抓大鹅》/u,
}),
);
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
expect(screen.queryByText(/抓大鹅运行态/u)).toBeNull();
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledTimes(1);
});
test('completed baby object match draft viewed immediately does not keep unread marker', async () => {
const user = userEvent.setup();
const generatedDraft = buildMockBabyObjectMatchDraft();
vi.mocked(createBabyObjectMatchDraft).mockImplementation(
async (payload: CreateBabyObjectMatchDraftRequest) => ({
draft: buildMockBabyObjectMatchDraft({
itemNames: [payload.itemAName, payload.itemBName],
}),
}),
);
vi.mocked(listLocalBabyObjectMatchDrafts).mockResolvedValue([generatedDraft]);
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '宝贝识物' }));
await waitFor(() => {
expect(
screen.getByRole('tab', { name: '宝贝识物' }).getAttribute(
'aria-selected',
),
).toBe('true');
});
await user.type(await screen.findByLabelText('物品 A'), '苹果');
await user.type(await screen.findByLabelText('物品 B'), '香蕉');
await user.click(screen.getByRole('button', { name: '生成宝贝识物草稿' }));
expect(await screen.findByText('宝贝识物结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
await waitFor(() => {
expect(screen.queryByText('宝贝识物结果页')).toBeNull();
});
expect(await screen.findByLabelText('物品 A')).toBeTruthy();
await user.click(await screen.findByRole('button', { name: '返回' }));
await openDraftHub(user);
expect(
await screen.findByRole('button', {
name: /继续创作《宝贝识物红点草稿》/u,
}),
).toBeTruthy();
expect(screen.queryByLabelText('新生成完成')).toBeNull();
await user.click(
screen.getByRole('button', {
name: /继续创作《宝贝识物红点草稿》/u,
}),
);
expect(await screen.findByText('宝贝识物结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
await waitFor(() => {
expect(screen.queryByText('宝贝识物结果页')).toBeNull();
});
await user.click(await screen.findByRole('button', { name: '返回' }));
await openDraftHub(user);
expect(screen.queryByLabelText('新生成完成')).toBeNull();
});
test('completed baby object match draft shows unread marker after leaving generation page', async () => {
const user = userEvent.setup();
const generatedDraft = buildMockBabyObjectMatchDraft();
let resolveCreateDraft!: (value: { draft: BabyObjectMatchDraft }) => void;
vi.mocked(createBabyObjectMatchDraft).mockReturnValue(
new Promise((resolve) => {
resolveCreateDraft = resolve;
}),
);
vi.mocked(listLocalBabyObjectMatchDrafts).mockResolvedValue([generatedDraft]);
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '宝贝识物' }));
await user.type(await screen.findByLabelText('物品 A'), '苹果');
await user.type(await screen.findByLabelText('物品 B'), '香蕉');
await user.click(screen.getByRole('button', { name: '生成宝贝识物草稿' }));
expect(await screen.findByText('宝贝识物草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await act(async () => {
resolveCreateDraft({ draft: generatedDraft });
});
expect(await screen.findByLabelText('新生成完成')).toBeTruthy();
expect(
await screen.findByRole('button', { name: '草稿,有新草稿' }),
).toBeTruthy();
await user.click(
await screen.findByRole('button', {
name: /继续创作《宝贝识物红点草稿》/u,
}),
);
expect(await screen.findByText('宝贝识物结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
await waitFor(() => {
expect(screen.queryByText('宝贝识物结果页')).toBeNull();
});
await user.click(await screen.findByRole('button', { name: '返回' }));
await openDraftHub(user);
expect(screen.queryByLabelText('新生成完成')).toBeNull();
});
test('puzzle draft generation auto starts trial and runtime back opens draft result', async () => {
const user = userEvent.setup();
const generatedDraft = buildReadyPuzzleDraft({
workTitle: '自动试玩拼图',
workDescription: '生成完成后直接试玩。',
coverImageSrc: '/puzzle/auto-candidate.png',
levels: [
{
...buildReadyPuzzleDraft().levels![0]!,
coverImageSrc: '/puzzle/auto-candidate.png',
uiBackgroundImageSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
uiBackgroundImageObjectKey:
'generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
backgroundMusic: {
taskId: 'music-task-auto-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-auto-1',
assetKind: 'puzzle_background_music',
audioSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/audio/background.mp3',
prompt: '',
title: '水果乐园',
updatedAt: '2026-05-14T10:00:00.000Z',
},
},
],
});
const generatedSession: PuzzleAgentSessionSnapshot = {
sessionId: 'puzzle-session-auto-1',
seedText: '屋檐下的猫与暖灯街角。',
currentTurn: 1,
progressPercent: 100,
stage: 'ready_to_publish',
anchorPack: buildPuzzleAnchorPack(),
draft: generatedDraft,
messages: [],
lastAssistantReply: '拼图草稿已经生成。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: {
draft: generatedDraft,
publishReady: true,
blockers: [],
qualityFindings: [],
},
updatedAt: '2026-05-12T10:00:00.000Z',
};
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
operation: {
operationId: 'compile-puzzle-auto-1',
type: 'compile_puzzle_draft',
status: 'completed',
phaseLabel: '已完成',
phaseDetail: '草稿已生成',
progress: 1,
},
session: generatedSession,
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('button', { name: '生成草稿' }));
await waitFor(() => {
expect(updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-auto-1',
expect.objectContaining({
levelName: '雨夜猫街',
coverImageSrc: '/puzzle/auto-candidate.png',
levels: [
expect.objectContaining({
uiBackgroundImageSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
backgroundMusic: expect.objectContaining({
audioSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/audio/background.mp3',
}),
}),
],
}),
);
});
await waitFor(() => {
expect(startLocalPuzzleRun).toHaveBeenCalledTimes(1);
});
const runtimeWork = vi.mocked(startLocalPuzzleRun).mock.calls[0]?.[0];
expect(runtimeWork?.levels?.[0]).toEqual(
expect.objectContaining({
uiBackgroundImageSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
uiBackgroundImageObjectKey:
'generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
}),
);
const runtimeSnapshot = vi.mocked(startLocalPuzzleRun).mock.results[0]?.value;
expect(runtimeSnapshot?.currentLevel?.uiBackgroundImageSrc).toBe(
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
);
expect(screen.queryByText('拼图结果页')).toBeNull();
await user.click(
await screen.findByRole('button', { name: '返回上一页' }),
);
expect(await screen.findByText('拼图结果页')).toBeTruthy();
expect(screen.getByDisplayValue('雨夜猫街')).toBeTruthy();
});
test('embedded puzzle form recovers when compile request times out after backend completion', async () => {
const user = userEvent.setup();
const generatedDraft = buildReadyPuzzleDraft();
const generatedSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-session-recovered',
stage: 'ready_to_publish',
progressPercent: 100,
draft: generatedDraft,
lastAssistantReply: '拼图草稿已经生成。',
resultPreview: {
draft: generatedDraft,
publishReady: true,
blockers: [],
qualityFindings: [],
},
updatedAt: '2026-05-12T10:00:00.000Z',
});
vi.mocked(createPuzzleAgentSession).mockResolvedValueOnce({
session: buildMockPuzzleAgentSession({
sessionId: 'puzzle-session-recovered',
}),
});
vi.mocked(executePuzzleAgentAction).mockRejectedValueOnce(
Object.assign(new Error('请求超时90000ms'), {
name: 'TimeoutError',
}),
);
vi.mocked(getPuzzleAgentSession).mockResolvedValueOnce({
session: generatedSession,
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('button', { name: '生成草稿' }));
await waitFor(() => {
expect(getPuzzleAgentSession).toHaveBeenCalledWith(
'puzzle-session-recovered',
);
});
await waitFor(() => {
expect(updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-recovered',
expect.objectContaining({
levelName: '雨夜猫街',
coverImageSrc: '/puzzle/recovered-candidate.png',
}),
);
});
expect(screen.queryByText('执行拼图操作失败。')).toBeNull();
expect(screen.queryByText('请求超时90000ms')).toBeNull();
expect(screen.queryByText('拼图草稿生成进度')).toBeNull();
expect(startLocalPuzzleRun).toHaveBeenCalledTimes(1);
});
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();
const generatingPuzzleDraft: PuzzleResultDraft = {
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '雨夜猫街',
summary: '屋檐下的猫与暖灯街角。',
themeTags: ['猫咪', '雨夜', '暖灯'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack: {
themePromise: {
key: 'theme_promise',
label: '主题承诺',
value: '雨夜猫街',
status: 'confirmed',
},
visualSubject: {
key: 'visual_subject',
label: '视觉主体',
value: '屋檐下的猫',
status: 'confirmed',
},
visualMood: {
key: 'visual_mood',
label: '视觉气质',
value: '温暖',
status: 'confirmed',
},
compositionHooks: {
key: 'composition_hooks',
label: '构图钩子',
value: '雨滴与灯牌',
status: 'confirmed',
},
tagsAndForbidden: {
key: 'tags_and_forbidden',
label: '标签与禁区',
value: '猫咪、雨夜',
status: 'confirmed',
},
},
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle/candidate-1.png',
assetId: 'asset-1',
prompt: '雨夜猫咪',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle/candidate-1.png',
coverAssetId: 'asset-1',
generationStatus: 'generating',
levels: [
{
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
pictureDescription: '屋檐下的猫与暖灯街角。',
pictureReference: null,
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle/candidate-1.png',
assetId: 'asset-1',
prompt: '雨夜猫咪',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle/candidate-1.png',
coverAssetId: 'asset-1',
generationStatus: 'generating',
},
],
metadata: null,
};
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
operation: {
operationId: 'puzzle-image-generation-1',
type: 'generate_puzzle_images',
status: 'running',
phaseLabel: '生成中',
phaseDetail: '正在生成拼图画面',
progress: 0.3,
},
session: {
sessionId: 'puzzle-session-1',
currentTurn: 3,
progressPercent: 88,
stage: 'ready_to_publish',
anchorPack: {
themePromise: {
key: 'theme_promise',
label: '主题承诺',
value: '雨夜猫街',
status: 'confirmed',
},
visualSubject: {
key: 'visual_subject',
label: '视觉主体',
value: '屋檐下的猫',
status: 'confirmed',
},
visualMood: {
key: 'visual_mood',
label: '视觉气质',
value: '温暖',
status: 'confirmed',
},
compositionHooks: {
key: 'composition_hooks',
label: '构图钩子',
value: '雨滴与灯牌',
status: 'confirmed',
},
tagsAndForbidden: {
key: 'tags_and_forbidden',
label: '标签与禁区',
value: '猫咪、雨夜',
status: 'confirmed',
},
},
draft: generatingPuzzleDraft,
messages: [],
lastAssistantReply: '大鱼结果页草稿已经生成,可以补正式资产。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: {
draft: generatingPuzzleDraft,
publishReady: false,
blockers: [],
qualityFindings: [],
},
updatedAt: '2026-04-26T10:10:00.000Z',
},
});
await user.click(screen.getByRole('button', { name: '重新生成画面' }));
expect(executePuzzleAgentAction).toHaveBeenCalledWith(
'puzzle-session-1',
expect.objectContaining({
action: 'generate_puzzle_images',
}),
);
expect(screen.getByRole('button', { name: '新增关卡' })).toHaveProperty(
'disabled',
false,
);
});
test('logged out public detail gates big fish start before local runtime', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
const bigFishWork: BigFishWorkSummary = {
workId: 'big-fish-work-public-1',
sourceSessionId: 'big-fish-session-public-1',
ownerUserId: 'user-2',
authorDisplayName: '大鱼作者',
title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化',
summary: '从微光孢子一路吞并成长到深海巨鲲。',
coverImageSrc: null,
status: 'published',
updatedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
levelCount: 8,
levelMainImageReadyCount: 8,
levelMotionReadyCount: 16,
backgroundReady: true,
};
vi.mocked(listBigFishGallery).mockResolvedValue({
items: [bigFishWork],
});
render(
<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('creation draft hub skips visual novel shelves when entry is not open', async () => {
const user = userEvent.setup();
vi.mocked(fetchCreationEntryConfig).mockResolvedValue({
...testCreationEntryConfig,
creationTypes: testCreationEntryConfig.creationTypes.map((entry) =>
entry.id === 'visual-novel' ? { ...entry, open: false } : entry,
),
});
vi.mocked(listVisualNovelGallery).mockRejectedValue(
new Error('该玩法入口暂不可用'),
);
vi.mocked(listVisualNovelWorks).mockRejectedValue(
new Error('该玩法入口暂不可用'),
);
render(<TestWrapper withAuth />);
await openDraftHub(user);
expect(listVisualNovelGallery).not.toHaveBeenCalled();
expect(listVisualNovelWorks).not.toHaveBeenCalled();
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 Match3D runtime keeps profile generated models when card summary is stale', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-1',
profileId: 'match3d-profile-card-1',
ownerUserId: 'user-2',
sourceSessionId: 'match3d-session-card-1',
gameName: '果园抓大鹅',
themeText: '果园',
summary: '消除果园模型。',
tags: ['果园', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 3,
difficulty: 5,
publicationStatus: 'published',
playCount: 3,
updatedAt: '2026-04-25T10:30:00.000Z',
publishedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
generatedItemAssets: [],
};
const match3dDetail: Match3DWorkSummary = {
...match3dCard,
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
modelSrc: null,
modelObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
modelFileName: 'strawberry.glb',
taskUuid: 'task-strawberry',
subscriptionKey: 'sub-strawberry',
status: 'model_ready',
error: null,
},
],
};
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [match3dCard],
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: match3dDetail,
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(match3dCard.profileId),
});
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-card-1',
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
await waitFor(() => {
expect(
screen.getByTestId('match3d-runtime-generated-model-count'),
).toHaveProperty('textContent', '1');
});
});
test('home recommendation Match3D runtime keeps image, music and UI assets without requiring models', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-image-only',
profileId: 'match3d-profile-card-image-only',
ownerUserId: 'user-2',
sourceSessionId: 'match3d-session-card-image-only',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '消除水果模型。',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 3,
difficulty: 5,
publicationStatus: 'published',
playCount: 3,
updatedAt: '2026-04-25T10:30:00.000Z',
publishedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
backgroundMusic: {
taskId: 'music-task-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-1',
assetKind: 'match3d_background_music',
audioSrc:
'/generated-match3d-assets/session/profile/audio/background.mp3',
prompt: '',
title: '果园轻舞',
updatedAt: '2026-05-12T10:00:00.000Z',
},
backgroundAsset: {
prompt: '果园竖屏纯背景',
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/background.png',
containerPrompt: '果园浅盘容器',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/container.png',
status: 'image_ready',
error: null,
},
},
],
};
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [match3dCard],
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(match3dCard.profileId),
});
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(
screen.getByTestId('match3d-runtime-generated-asset-count'),
).toHaveProperty('textContent', '1');
});
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
'match3d-profile-card-image-only',
);
expect(
screen.getByTestId('match3d-runtime-background-music-count'),
).toHaveProperty('textContent', '1');
expect(screen.getByTestId('match3d-runtime-container-ui-count')).toHaveProperty(
'textContent',
'1',
);
expect(
screen.getByTestId('match3d-runtime-top-level-background-count'),
).toHaveProperty('textContent', '1');
expect(
screen.getByTestId('match3d-runtime-top-level-container-ui-count'),
).toHaveProperty('textContent', '1');
});
test('home recommendation Match3D runtime passes top-level UI background assets', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-top-level-ui',
profileId: 'match3d-profile-card-top-level-ui',
ownerUserId: 'user-2',
sourceSessionId: 'match3d-session-card-top-level-ui',
gameName: '果园抓大鹅',
themeText: '果园',
summary: '消除果园素材。',
tags: ['果园', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 3,
difficulty: 5,
publicationStatus: 'published',
playCount: 3,
updatedAt: '2026-04-25T10:30:00.000Z',
publishedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
backgroundImageObjectKey:
'generated-match3d-assets/session/profile/background/background.png',
generatedBackgroundAsset: {
prompt: '果园竖屏纯背景',
imageSrc: null,
imageObjectKey:
'generated-match3d-assets/session/profile/background/background.png',
containerPrompt: '果园浅盘容器',
containerImageSrc: null,
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/container.png',
status: 'image_ready',
error: null,
},
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
],
};
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [match3dCard],
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(match3dCard.profileId),
});
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(
screen.getByTestId('match3d-runtime-top-level-background-count'),
).toHaveProperty('textContent', '1');
});
expect(
screen.getByTestId('match3d-runtime-top-level-container-ui-count'),
).toHaveProperty('textContent', '1');
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
'match3d-profile-card-top-level-ui',
);
});
test('home recommendation Match3D runtime reloads detail when card only has UI assets', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-ui-only',
profileId: 'match3d-profile-card-ui-only',
ownerUserId: 'user-2',
sourceSessionId: 'match3d-session-card-ui-only',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '消除水果素材。',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 3,
difficulty: 5,
publicationStatus: 'published',
playCount: 3,
updatedAt: '2026-04-25T10:30:00.000Z',
publishedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [],
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
backgroundAsset: {
prompt: '果园竖屏纯背景',
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/background.png',
containerPrompt: '果园浅盘容器',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/container.png',
status: 'image_ready',
error: null,
},
},
],
};
const match3dDetail: Match3DWorkSummary = {
...match3dCard,
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [
{
viewId: 'view-01',
viewIndex: 1,
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/views/view-01.png',
imageObjectKey: null,
},
],
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
],
};
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [match3dCard],
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: match3dDetail,
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(match3dCard.profileId),
});
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(getMatch3DWorkDetail).toHaveBeenCalledWith(
'match3d-profile-card-ui-only',
);
});
expect(
await screen.findByTestId('match3d-runtime-generated-item-image-count'),
).toHaveProperty('textContent', '1');
expect(
screen.getByTestId('match3d-runtime-top-level-background-count'),
).toHaveProperty('textContent', '1');
expect(
screen.getByTestId('match3d-runtime-top-level-container-ui-count'),
).toHaveProperty('textContent', '1');
});
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('match3d creation tab stays usable even when public galleries fail', async () => {
const user = userEvent.setup();
vi.mocked(listRpgEntryWorldGallery).mockRejectedValueOnce(
new Error('读取作品广场失败'),
);
vi.mocked(listMatch3DGallery).mockRejectedValueOnce(
new Error('读取抓大鹅广场失败'),
);
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
expect(screen.queryByText('读取作品广场失败')).toBeNull();
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
expect(screen.getByRole('tab', { name: '抓大鹅' })).toBeTruthy();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});
test('puzzle draft result back button returns to creation hub', async () => {
const user = userEvent.setup();
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [
{
workId: 'puzzle-work-session-1',
profileId: 'puzzle-profile-session-1',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-1',
authorDisplayName: '测试玩家',
levelName: '雨夜猫塔',
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
themeTags: ['雨夜', '猫咪', '遗迹'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: '2026-04-22T12:10:00.000Z',
publishedAt: null,
playCount: 0,
likeCount: 0,
publishReady: false,
},
],
});
render(<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(await screen.findByText('拼图工作区missing-session')).toBeTruthy();
expect(
screen.queryByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
).toBeNull();
expect(screen.queryByText('拼图结果页')).toBeNull();
});
test('persisted generating puzzle draft opens generation progress after refresh', async () => {
const user = userEvent.setup();
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [
{
workId: 'puzzle-work-session-generating',
profileId: 'puzzle-profile-session-generating',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-generating',
authorDisplayName: '测试玩家',
workTitle: '生成中拼图',
workDescription: '刷新后仍应回到生成面板。',
levelName: '生成中拼图',
summary: '刷新后仍应回到生成面板。',
themeTags: ['雨夜'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: '2026-05-18T12:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
generationStatus: 'generating',
},
],
});
const persistedGeneratingPuzzleSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-session-generating',
stage: 'collecting_anchors',
progressPercent: 88,
lastAssistantReply: '正在生成拼图草稿。',
updatedAt: '2026-05-18T12:00:00.000Z',
});
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
session: persistedGeneratingPuzzleSession,
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
await user.click(await screen.findByRole('button', { name: /继续创作/u }));
await waitFor(() => {
expect(getPuzzleAgentSession).toHaveBeenCalledWith(
'puzzle-session-generating',
);
});
expect(
await screen.findByRole('progressbar', {
name: '拼图草稿生成进度',
}),
).toBeTruthy();
expect(
Number(
screen
.getByRole('progressbar', { name: '拼图草稿生成进度' })
.getAttribute('aria-valuenow'),
),
).toBe(0);
expect(screen.getByText('0%')).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 hides puzzle onboarding by default', async () => {
window.localStorage.removeItem(
'genarrative.puzzle-onboarding.first-visit.v1',
);
render(
<TestWrapper
authValue={createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth: () => {},
})}
/>,
);
await waitFor(() => {
expect(screen.queryByText('待定待定待定')).toBeNull();
});
expect(screen.queryByPlaceholderText('把你的梦讲给我听吧')).toBeNull();
expect(
window.localStorage.getItem('genarrative.puzzle-onboarding.first-visit.v1'),
).toBeNull();
expect(generatePuzzleOnboardingWork).not.toHaveBeenCalled();
});
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('recommend puzzle remix return restarts recommendation instead of stale loading run', 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 waitFor(() => {
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: '改造 0' }));
expect(await screen.findByText('拼图结果页')).toBeTruthy();
expect(screen.getByDisplayValue('改造后的雨夜猫塔')).toBeTruthy();
vi.mocked(startPuzzleRun).mockClear();
await user.click(screen.getByRole('button', { name: '返回' }));
await clickFirstButtonByName(user, '推荐');
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: 'puzzle-profile-public-1',
levelId: null,
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
expect(screen.queryByText('正在进入拼图关卡')).toBeNull();
});
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('direct missing public work detail alert returns to platform home', async () => {
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
window.history.replaceState(
null,
'',
'/works/detail?work=PZ-7A7B18D9',
);
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [],
});
render(<TestWrapper withAuth />);
expect(await screen.findByText('正在读取作品详情...')).toBeTruthy();
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith('作品不存在或已下架,将返回首页。');
});
await waitFor(() => {
expect(window.location.pathname).toBe('/');
});
expect(window.location.search).toBe('');
await waitFor(() => {
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,
generatedItemAssets: [],
};
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [match3dWork],
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: match3dWork,
});
match3dServerRuntimeAdapterMock.startRun.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(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-public-1',
{},
);
});
expect(
await screen.findByText(
'抓大鹅运行态match3d-run-match3d-profile-public-1',
),
).toBeTruthy();
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
});
test('published Match3D runtime receives persisted generated models', async () => {
const user = userEvent.setup();
const match3dWork: Match3DWorkSummary = {
workId: 'match3d-work-model-1',
profileId: 'match3d-profile-model-1',
ownerUserId: 'user-2',
sourceSessionId: 'match3d-session-model-1',
gameName: '果园抓大鹅',
themeText: '果园',
summary: '消除果园里的水果模型。',
tags: ['果园', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 3,
difficulty: 5,
publicationStatus: 'published',
playCount: 3,
updatedAt: '2026-04-25T10:30:00.000Z',
publishedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
modelSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
modelObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
modelFileName: 'strawberry.glb',
taskUuid: 'task-strawberry',
subscriptionKey: 'sub-strawberry',
status: 'model_ready',
error: null,
},
],
};
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [match3dWork],
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(match3dWork.profileId),
});
render(<TestWrapper withAuth />);
await openDiscoverHub(user);
const searchInput =
await screen.findByPlaceholderText('搜索作品号、名称、作者、描述');
await user.type(searchInput, 'M3-LEMODEL1');
await user.click(screen.getByRole('button', { name: '搜索' }));
await user.click(await screen.findByRole('button', { name: '启动' }));
await waitFor(() => {
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-model-1',
{},
);
});
expect(
await screen.findByTestId('match3d-runtime-generated-model-count'),
).toHaveProperty('textContent', '1');
});
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('running custom world draft generation can return to creation center with shelf badge', async () => {
const user = userEvent.setup();
vi.mocked(listRpgCreationWorks).mockResolvedValue([
buildExistingRpgDraftWork({
stage: 'clarifying',
stageLabel: '补齐关键锚点',
playableNpcCount: 0,
landmarkCount: 0,
}),
]);
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: '开始生成草稿' }));
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
expect(
await screen.findByRole('tablist', { name: '玩法模板分类' }),
).toBeTruthy();
await openDraftHub(user);
expect(await screen.findByText('潮雾列岛')).toBeTruthy();
await expectDraftHubGeneratingBadgeCountAtLeast(1);
});
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 draft result test button enters the opened draft profile instead of a previous session', async () => {
const user = userEvent.setup();
const handleCustomWorldSelect = vi.fn();
const previousDraftSession = {
...compiledAgentDraftSession,
sessionId: 'custom-world-agent-session-1',
resultPreview: {
...compiledAgentDraftSession.resultPreview!,
publishReady: false,
canEnterWorld: true,
preview: {
...compiledAgentResultPreview,
id: 'agent-draft-custom-world-agent-session-1',
name: '潮雾列岛',
summary: '上一份草稿内容,不能被本次启动复用。',
playableNpcs: [
{
...compiledAgentResultPreview.playableNpcs[0]!,
id: 'playable-previous-1',
name: '沈砺',
},
],
sessionId: 'custom-world-agent-session-1',
},
},
} satisfies CustomWorldAgentSessionSnapshot;
const openedDraftSession = {
...compiledAgentDraftSession,
sessionId: 'custom-world-agent-session-2',
resultPreview: {
...compiledAgentDraftSession.resultPreview!,
publishReady: false,
canEnterWorld: true,
preview: {
...compiledAgentResultPreview,
id: 'agent-draft-custom-world-agent-session-2',
name: '星砂废都',
subtitle: '坠星沙海与废都钟楼',
summary: '本次从草稿架打开的目标草稿内容。',
playerGoal: '找到废都钟楼下被星砂掩埋的旧约。',
playableNpcs: [
{
...compiledAgentResultPreview.playableNpcs[0]!,
id: 'playable-opened-1',
name: '砂眠',
title: '废都引路人',
},
],
storyNpcs: [],
landmarks: [
{
...compiledAgentResultPreview.landmarks[0]!,
id: 'landmark-opened-1',
name: '坠星钟楼',
},
],
sessionId: 'custom-world-agent-session-2',
},
},
} satisfies CustomWorldAgentSessionSnapshot;
const sessionsById = new Map([
[previousDraftSession.sessionId, previousDraftSession],
[openedDraftSession.sessionId, openedDraftSession],
]);
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-draft-foundation-1',
type: 'draft_foundation',
status: 'completed',
phaseLabel: '世界底稿已生成',
phaseDetail: '第一版世界底稿和草稿卡已经整理完成。',
progress: 100,
error: null,
});
vi.mocked(getRpgCreationSession).mockImplementation(async (sessionId) => {
const session = sessionsById.get(sessionId);
if (!session) {
throw new Error(`Missing test session: ${sessionId}`);
}
return session;
});
vi.mocked(getRpgCreationResultView).mockImplementation(async (sessionId) => {
const session = sessionsById.get(sessionId);
if (!session) {
throw new Error(`Missing test result view: ${sessionId}`);
}
return buildResultViewForSession(session);
});
vi.mocked(listRpgCreationWorks).mockResolvedValue([
buildExistingRpgDraftWork({
workId: 'draft:custom-world-agent-session-1',
title: '潮雾列岛',
summary: '上一份草稿内容,不能被本次启动复用。',
sessionId: 'custom-world-agent-session-1',
playableNpcCount: 1,
landmarkCount: 1,
}),
buildExistingRpgDraftWork({
workId: 'draft:custom-world-agent-session-2',
title: '星砂废都',
subtitle: '待完善草稿',
summary: '本次从草稿架打开的目标草稿内容。',
sessionId: 'custom-world-agent-session-2',
playableNpcCount: 1,
landmarkCount: 1,
}),
]);
render(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
await openDraftHub(user);
const draftPanel = getPlatformTabPanel('saves');
await user.click(
await within(draftPanel).findByRole('button', {
name: /继续完善《星砂废都》/u,
}),
);
expect(await screen.findByText('世界档案', {}, { timeout: 5000 })).toBeTruthy();
expect(screen.getByText('星砂废都')).toBeTruthy();
await user.click(
await screen.findByRole('button', { name: '作品测试' }, { timeout: 5000 }),
);
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({
id: 'agent-draft-custom-world-agent-session-2',
name: '星砂废都',
summary: '本次从草稿架打开的目标草稿内容。',
playableNpcs: [
expect.objectContaining({
id: 'playable-opened-1',
name: '砂眠',
}),
],
}),
expect.objectContaining({
mode: 'play',
disablePersistence: true,
returnStage: 'custom-world-result',
}),
);
});
expect(
vi
.mocked(executeRpgCreationAction)
.mock.calls.some(([, payload]) => payload?.action === 'publish_world'),
).toBe(false);
}, 10_000);
test('agent draft result start button enters the opened published draft profile instead of a previous session', async () => {
const user = userEvent.setup();
const handleCustomWorldSelect = vi.fn();
const previousDraftSession = {
...compiledAgentDraftSession,
sessionId: 'custom-world-agent-session-1',
stage: 'published',
resultPreview: {
...compiledAgentDraftSession.resultPreview!,
publishReady: true,
canEnterWorld: true,
preview: {
...compiledAgentResultPreview,
id: 'agent-draft-custom-world-agent-session-1',
name: '潮雾列岛',
summary: '上一份已发布草稿内容,不能被本次启动复用。',
sessionId: 'custom-world-agent-session-1',
},
},
} satisfies CustomWorldAgentSessionSnapshot;
const openedPublishedDraftSession = {
...compiledAgentDraftSession,
sessionId: 'custom-world-agent-session-2',
stage: 'published',
resultPreview: {
...compiledAgentDraftSession.resultPreview!,
publishReady: true,
canEnterWorld: true,
preview: {
...compiledAgentResultPreview,
id: 'agent-draft-custom-world-agent-session-2',
name: '星砂废都',
subtitle: '坠星沙海与废都钟楼',
summary: '本次从草稿架打开且已发布的目标草稿内容。',
playableNpcs: [
{
...compiledAgentResultPreview.playableNpcs[0]!,
id: 'playable-opened-1',
name: '砂眠',
title: '废都引路人',
},
],
sessionId: 'custom-world-agent-session-2',
},
},
} satisfies CustomWorldAgentSessionSnapshot;
const sessionsById = new Map([
[previousDraftSession.sessionId, previousDraftSession],
[openedPublishedDraftSession.sessionId, openedPublishedDraftSession],
]);
vi.mocked(getRpgCreationSession).mockImplementation(async (sessionId) => {
const session = sessionsById.get(sessionId);
if (!session) {
throw new Error(`Missing test session: ${sessionId}`);
}
return session;
});
vi.mocked(getRpgCreationResultView).mockImplementation(async (sessionId) => {
const session = sessionsById.get(sessionId);
if (!session) {
throw new Error(`Missing test result view: ${sessionId}`);
}
return buildResultViewForSession(session);
});
vi.mocked(listRpgCreationWorks).mockResolvedValue([
buildExistingRpgDraftWork({
workId: 'draft:custom-world-agent-session-1',
title: '潮雾列岛',
summary: '上一份已发布草稿内容,不能被本次启动复用。',
stage: 'published',
stageLabel: '已发布',
sessionId: 'custom-world-agent-session-1',
playableNpcCount: 1,
landmarkCount: 1,
canEnterWorld: true,
}),
buildExistingRpgDraftWork({
workId: 'draft:custom-world-agent-session-2',
title: '星砂废都',
subtitle: '已发布草稿',
summary: '本次从草稿架打开且已发布的目标草稿内容。',
stage: 'published',
stageLabel: '已发布',
sessionId: 'custom-world-agent-session-2',
playableNpcCount: 1,
landmarkCount: 1,
canEnterWorld: true,
}),
]);
render(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
await openDraftHub(user);
const draftPanel = getPlatformTabPanel('saves');
await user.click(
await within(draftPanel).findByRole('button', {
name: /继续完善《星砂废都》/u,
}),
);
expect(await screen.findByText('世界档案', {}, { timeout: 5000 })).toBeTruthy();
expect(screen.getByText('星砂废都')).toBeTruthy();
await user.click(
await screen.findByRole('button', { name: '进入世界' }, { timeout: 5000 }),
);
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({
id: 'agent-draft-custom-world-agent-session-2',
name: '星砂废都',
summary: '本次从草稿架打开且已发布的目标草稿内容。',
playableNpcs: [
expect.objectContaining({
id: 'playable-opened-1',
name: '砂眠',
}),
],
}),
);
});
expect(
vi
.mocked(executeRpgCreationAction)
.mock.calls.some(([, payload]) => 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: {
...compiledAgentResultPreview,
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('profile page exposes save archive picker as a direct entry', 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 clickFirstButtonByName(user, '我的');
const shortcutRegion = await screen.findByRole('region', { name: '常用功能' });
await user.click(within(shortcutRegion).getByRole('button', { name: /存档/u }));
const closeButton = await screen.findByLabelText('关闭存档');
const modal = closeButton.closest('.fixed') as HTMLElement;
expect(modal).toBeTruthy();
expect(within(modal).getByText('SAVES')).toBeTruthy();
await user.click(within(modal).getByRole('button', { name: /潮雾列岛/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 start uses loaded detail profile instead of library summary', async () => {
const user = userEvent.setup();
const handleCustomWorldSelect = vi.fn();
const workProfileId = 'world-detail-launch-1';
const summaryEntry = buildMockRpgGalleryDetail({
ownerUserId: mockAuthUser.id,
profileId: workProfileId,
publicWorkCode: 'work-detail-launch-1',
authorPublicUserCode: mockAuthUser.publicUserCode,
visibility: 'published',
publishedAt: '2026-04-20T10:00:00.000Z',
updatedAt: '2026-04-20T10:00:00.000Z',
authorDisplayName: mockAuthUser.displayName,
worldName: '星砂废都',
subtitle: '坠星沙海与废都钟楼',
summaryText: '列表摘要只提供卡片信息,不能作为运行态 profile。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 1,
landmarkCount: 1,
likeCount: 0,
});
summaryEntry.profile = {
...summaryEntry.profile,
name: '默认档案',
summary: '列表摘要不含运行态角色。',
playableNpcs: [],
storyNpcs: [],
landmarks: [],
};
const detailEntry = buildMockRpgGalleryDetail({
...summaryEntry,
summaryText: '详情接口返回完整草稿内容。',
});
detailEntry.profile = {
...detailEntry.profile,
name: '星砂废都',
subtitle: '坠星沙海与废都钟楼',
summary: '详情接口返回完整草稿内容。',
playableNpcs: [
{
...compiledAgentResultPreview.playableNpcs[0]!,
id: 'playable-stardust-1',
name: '砂眠',
title: '废都引路人',
},
],
landmarks: [
{
...compiledAgentResultPreview.landmarks[0]!,
id: 'landmark-stardust-1',
name: '坠星钟楼',
},
],
};
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: `published:${workProfileId}`,
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: 1,
landmarkCount: 1,
roleVisualReadyCount: 1,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: null,
profileId: workProfileId,
canResume: false,
canEnterWorld: true,
},
]);
vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([summaryEntry]);
vi.mocked(
rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail,
).mockResolvedValue(detailEntry);
render(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
await openDraftHub(user);
await user.click(await screen.findByRole('button', { name: /查看详情/u }));
await waitFor(() => {
expect(
rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail,
).toHaveBeenCalledWith(workProfileId);
});
await user.click(await screen.findByRole('button', { name: '启动' }));
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({
id: workProfileId,
name: '星砂废都',
summary: '详情接口返回完整草稿内容。',
playableNpcs: [
expect.objectContaining({
id: 'playable-stardust-1',
name: '砂眠',
}),
],
landmarks: [
expect.objectContaining({
id: 'landmark-stardust-1',
name: '坠星钟楼',
}),
],
}),
);
});
expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1);
});
test('creation hub published work edit keeps loaded detail profile assets instead of library summary', async () => {
const user = userEvent.setup();
const workProfileId = 'world-detail-edit-assets-1';
const summaryEntry = buildMockRpgGalleryDetail({
ownerUserId: mockAuthUser.id,
profileId: workProfileId,
publicWorkCode: 'work-detail-edit-assets-1',
authorPublicUserCode: mockAuthUser.publicUserCode,
visibility: 'published',
publishedAt: '2026-04-20T10:00:00.000Z',
updatedAt: '2026-04-20T10:00:00.000Z',
authorDisplayName: mockAuthUser.displayName,
worldName: '星砂废都',
subtitle: '坠星沙海与废都钟楼',
summaryText: '列表摘要字段齐全但不含详情资产。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 1,
landmarkCount: 1,
likeCount: 0,
});
summaryEntry.profile = {
...summaryEntry.profile,
name: '星砂废都',
summary: '列表摘要字段齐全但不含详情资产。',
playableNpcs: [
{
...compiledAgentResultPreview.playableNpcs[0]!,
id: 'playable-stardust-1',
name: '砂眠',
imageSrc: undefined,
},
],
storyNpcs: [
{
...compiledAgentResultPreview.storyNpcs[0]!,
id: 'story-clock-keeper-1',
name: '钟守',
imageSrc: undefined,
},
],
landmarks: [
{
...compiledAgentResultPreview.landmarks[0]!,
id: 'landmark-stardust-1',
name: '坠星钟楼',
imageSrc: undefined,
},
],
sceneChapterBlueprints: [
{
id: 'scene-chapter-stardust-1',
sceneId: 'landmark-stardust-1',
title: '坠星钟楼',
summary: '星砂覆盖钟楼入口,钟守等待第一位访客。',
sceneTaskDescription: '调查钟楼旧铃自鸣的原因。',
linkedThreadIds: [],
linkedLandmarkIds: ['landmark-stardust-1'],
acts: [
{
id: 'act-stardust-opening-1',
sceneId: 'landmark-stardust-1',
title: '第一幕',
summary: '砂眠带玩家进入坠星钟楼。',
stageCoverage: ['opening'],
backgroundImageSrc: undefined,
encounterNpcIds: ['playable-stardust-1'],
primaryNpcId: 'playable-stardust-1',
oppositeNpcId: 'story-clock-keeper-1',
eventDescription: '钟楼旧铃忽然自鸣。',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '进入钟楼。',
transitionHook: '星砂开始倒流。',
},
],
},
],
cover: null,
openingCg: null,
};
const detailEntry = buildMockRpgGalleryDetail({
...summaryEntry,
summaryText: '详情接口返回完整草稿内容。',
});
detailEntry.profile = {
...summaryEntry.profile,
summary: '详情接口返回完整草稿内容。',
cover: {
sourceType: 'generated',
imageSrc: '/assets/custom-world/star-waste-cover.png',
characterRoleIds: ['playable-stardust-1'],
},
openingCg: {
id: 'opening-cg-stardust-1',
status: 'ready',
storyboardImageSrc: '/assets/custom-world/opening-storyboard.png',
videoSrc: '/assets/custom-world/opening.mp4',
imageModel: 'gpt-image-2',
videoModel: 'doubao-seedance-2-0-fast-260128',
aspectRatio: '16:9',
imageSize: '2k',
videoResolution: '480p',
durationSeconds: 15,
pointCost: 80,
estimatedWaitMinutes: 10,
updatedAt: '2026-05-21T00:00:00.000Z',
},
camp: {
id: 'camp-stardust-1',
name: '废都营地',
description: '钟楼阴影下的临时营地。',
imageSrc: '/assets/custom-world/star-waste-camp.png',
sceneNpcIds: ['playable-stardust-1'],
connections: [],
},
playableNpcs: [
{
...summaryEntry.profile.playableNpcs[0]!,
imageSrc: '/assets/custom-world/playable-stardust-1.png',
},
],
storyNpcs: [
{
...summaryEntry.profile.storyNpcs[0]!,
imageSrc: '/assets/custom-world/story-clock-keeper-1.png',
},
],
landmarks: [
{
...summaryEntry.profile.landmarks[0]!,
imageSrc: '/assets/custom-world/landmark-stardust-1.png',
},
],
sceneChapterBlueprints: [
{
...summaryEntry.profile.sceneChapterBlueprints![0]!,
acts: [
{
...summaryEntry.profile.sceneChapterBlueprints![0]!.acts[0]!,
backgroundImageSrc:
'/assets/custom-world/act-stardust-opening-1.png',
},
],
},
],
};
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: `published:${workProfileId}`,
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: 1,
landmarkCount: 1,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: null,
profileId: workProfileId,
canResume: false,
canEnterWorld: true,
},
]);
vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([summaryEntry]);
vi.mocked(
rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail,
).mockResolvedValue(detailEntry);
render(<TestWrapper withAuth />);
await openDraftHub(user);
await user.click(await screen.findByRole('button', { name: /查看详情/u }));
await waitFor(() => {
expect(
rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail,
).toHaveBeenCalledWith(workProfileId);
});
await user.click(await screen.findByRole('button', { name: '作品编辑' }));
expect(await screen.findByText('世界档案', {}, { timeout: 5000 })).toBeTruthy();
expect(
document.querySelector('video[src="/assets/custom-world/opening.mp4"]'),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: /场景\s+2/u }));
expect((await screen.findByAltText('废都营地')).getAttribute('src')).toBe(
'/assets/custom-world/star-waste-camp.png',
);
expect(screen.getByAltText('坠星钟楼-第一幕').getAttribute('src')).toBe(
'/assets/custom-world/act-stardust-opening-1.png',
);
await user.click(screen.getByRole('button', { name: /可扮演角色\s+1/u }));
expect((await screen.findByAltText('砂眠')).getAttribute('src')).toBe(
'/assets/custom-world/playable-stardust-1.png',
);
await user.click(screen.getByRole('button', { name: /场景角色\s+1/u }));
expect((await screen.findByAltText('钟守')).getAttribute('src')).toBe(
'/assets/custom-world/story-clock-keeper-1.png',
);
});
test('creation hub published work card reveals delete action after card action reveal', 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);
const publishedCard = await screen.findByRole('button', {
name: /查看详情《潮雾列岛》/u,
});
publishedCard.focus();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('button', { name: '删除' })).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();
});