/* @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, name: string | RegExp, ) { const buttons = await screen.findAllByRole('button', { name }); await user.click(buttons[0]!); } async function clickFirstAsyncButtonByName( user: ReturnType, name: string | RegExp, ) { const buttons = await screen.findAllByRole('button', { name }); await user.click(buttons[0]!); } async function openCreateTemplateHub(user: ReturnType) { 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) { 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) { 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, ) { await clickFirstButtonByName(user, '我的'); await user.click(await screen.findByRole('button', { name: /玩过/u })); expect(await screen.findByText('可继续')).toBeTruthy(); } async function openExistingRpgDraft( user: ReturnType, 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) => 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; }) => (
拼图工作区:{session?.sessionId ?? 'missing-session'}
{isBusy ? 'busy' : 'idle'}
{session?.messages.map((message) => (
{message.text}
))} {error ?
{error}
: null}
), })); 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; }) => (
拼图结果页
), })); vi.mock('../puzzle-gallery/PuzzleGalleryDetailView', () => ({ PuzzleGalleryDetailView: ({ item, onBack, onStartGame, }: { item: { levelName: string }; onBack: () => void; onStartGame: () => void; }) => (
{item.levelName}
), })); vi.mock('../big-fish-creation/BigFishAgentWorkspace', () => ({ BigFishAgentWorkspace: ({ session, }: { session: { sessionId: string; messages: Array<{ text: string }> } | null; }) => (
大鱼吃小鱼工作区:{session?.sessionId ?? 'missing-session'}
{session?.messages.map((message) => (
{message.text}
))}
), })); vi.mock('../big-fish-result/BigFishResultView', () => ({ BigFishResultView: ({ session, onBack, onExecuteAction, }: { session: { draft?: { title: string } | null }; onBack: () => void; onExecuteAction: (payload: { action: string }) => void; }) => (
大鱼吃小鱼结果页
{session.draft?.title ?? '缺少草稿标题'}
), })); vi.mock('../match3d-result/Match3DResultView', () => ({ Match3DResultView: ({ draft, onBack, onStartTestRun, profile, }: { draft?: { gameName?: string | null } | null; onBack: () => void; onStartTestRun: (profile: Match3DWorkSummary) => void; profile: Match3DWorkSummary; }) => (
抓大鹅结果页
{draft?.gameName ?? profile.gameName}
), })); 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; }) => (
抓大鹅工作区:{session?.sessionId ?? 'missing-session'}
{session?.messages.map((message) => (
{message.text}
))}
{isBusy ? 'busy' : 'idle'}
{error ?
{error}
: null}
), })); vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({ Match3DRuntimeShell: ({ run, generatedItemAssets = [], generatedBackgroundAsset = null, onBack, }: { run: Match3DRunSnapshot | null; generatedItemAssets?: Match3DWorkSummary['generatedItemAssets']; generatedBackgroundAsset?: Match3DWorkSummary['generatedBackgroundAsset']; onBack: () => void; }) => (
抓大鹅运行态:{run?.runId ?? 'missing-run'}
{ generatedItemAssets.filter( (asset) => asset.modelSrc?.trim() || asset.modelObjectKey?.trim(), ).length }
{ 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 }
{ generatedItemAssets.filter( (asset) => asset.imageSrc?.trim() || asset.imageObjectKey?.trim() || asset.imageViews?.some( (view) => view.imageSrc?.trim() || view.imageObjectKey?.trim(), ), ).length }
{ generatedItemAssets.filter((asset) => asset.backgroundMusic?.audioSrc?.trim(), ).length }
{ generatedItemAssets.filter( (asset) => asset.backgroundAsset?.containerImageSrc?.trim() || asset.backgroundAsset?.containerImageObjectKey?.trim(), ).length }
{ generatedBackgroundAsset?.imageSrc?.trim() || generatedBackgroundAsset?.imageObjectKey?.trim() ? 1 : 0 }
{ generatedBackgroundAsset?.containerImageSrc?.trim() || generatedBackgroundAsset?.containerImageObjectKey?.trim() ? 1 : 0 }
), })); 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; }) => (
汪汪声浪配置表单
{showBackButton ? 'back-visible' : 'back-hidden'}
{title === null ? 'title-hidden' : title}
{isBusy ? 'busy' : 'idle'}
{error ?
{error}
: null}
), })); 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; }) => (
汪汪声浪结果页:{draft.title}
草稿ID:{draft.draftId}
作品ID:{draft.workId ?? 'missing-work'}
), })); vi.mock('../edutainment-result/BabyObjectMatchResultView', () => ({ BabyObjectMatchResultView: ({ draft, onBack, onStartTestRun, }: { draft: BabyObjectMatchDraft; onBack: () => void; onStartTestRun?: (draft: BabyObjectMatchDraft) => void; }) => (
宝贝识物结果页
{draft.workTitle}
), })); vi.mock('../edutainment-runtime/BabyObjectMatchRuntimeShell', () => ({ BabyObjectMatchRuntimeShell: ({ draft, onBack, }: { draft: BabyObjectMatchDraft; onBack?: () => void; }) => (
宝贝识物运行态:{draft.profileId}
), })); 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; }) => (
汪汪声浪运行态:{title ?? '未命名'} / {workId ?? 'missing-work'}
{runtimeMode ?? 'missing-mode'}
{publishedConfig?.workId ?? 'missing-config-work'}
{publishedConfig?.playerCharacterImageSrc ?? 'missing-player-src'}
), })); vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({ CustomWorldAgentWorkspace: ({ session, onExecuteAction, }: { session: CustomWorldAgentSessionSnapshot | null; onExecuteAction: (payload: { action: string }) => void; }) => (
Agent工作区:{session?.sessionId ?? 'missing-session'}
), })); 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 { 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 { 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 { 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[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 { 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 { 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; function buildMockMatch3DAgentSession( overrides: Partial = {}, ): 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 { 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 { 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 = {}, ) { 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; 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 { 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(() => resolveSelectionStageFromPath(window.location.pathname), ); const [initialPublicWorkCode] = useState(() => readPublicWorkCodeFromLocationSearch(window.location.search), ); const content = ( {})} handleStartNewGame={() => {}} handleCustomWorldSelect={onSelectWorld ?? (() => {})} /> ); if (!withAuth && !authValue) { return content; } return ( {content} ); } 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(); 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(); 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(); 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(); 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(); 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('作品ID:bark-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(); 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(); 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>>( () => undefined, ), ); render(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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( {}, 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(); 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(); 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(); 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(); 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(); 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( {}, 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( {}, 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(); 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( {}, 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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( , ); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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( {}, 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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('platform'); return ( {}} handleStartNewGame={() => {}} handleCustomWorldSelect={handleCustomWorldSelect} /> ); } render(); 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('platform'); return ( {}} handleStartNewGame={() => {}} handleCustomWorldSelect={handleCustomWorldSelect} /> ); } render(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); });