/* @vitest-environment jsdom */ import { cleanup, render, screen, waitFor, within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { afterEach, expect, test, vi } from 'vitest'; import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService'; import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient'; import type { CustomWorldNpc, CustomWorldPlayableNpc, CustomWorldProfile, SceneActBlueprint, SceneChapterBlueprint, } from '../types'; import { CustomWorldEntityCatalog } from './CustomWorldEntityCatalog'; import { type RpgCreationEditorTarget, RpgCreationEntityEditorModal, } from './rpg-creation-editor/RpgCreationEntityEditorModal'; afterEach(() => { cleanup(); }); vi.mock('../data/characterPresets', async () => { const actual = await vi.importActual( '../data/characterPresets', ); return { ...actual, buildCustomWorldPlayableCharacters: vi.fn(() => []), buildCustomWorldRuntimeCharacters: vi.fn(() => []), createCharacterSkillCooldowns: vi.fn(() => ({})), getCharacterMaxHp: vi.fn(() => 180), getCharacterMaxMana: vi.fn(() => 60), setRuntimeCharacterOverrides: vi.fn(), }; }); vi.mock('./CharacterAnimator', () => ({ CharacterAnimator: () =>
角色预览
, })); vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => { const generateSceneImage = vi.fn(); const generateSceneNpc = vi.fn(); return { rpgCreationAssetClient: { generateSceneImage, generateSceneNpc, }, generateCustomWorldSceneImage: generateSceneImage, generateCustomWorldSceneNpc: generateSceneNpc, }; }); const mockedRpgCreationAssetClient = vi.mocked( rpgCreationAssetClient.rpgCreationAssetClient, ); vi.mock('./CustomWorldNpcVisualEditor', () => ({ CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
{npc.name}
), CustomWorldNpcVisualEditor: () =>
预设形象编辑器
, })); vi.mock('../hooks/useResolvedAssetReadUrl', () => ({ useResolvedAssetReadUrl: (source: string | null | undefined) => ({ resolvedUrl: source?.trim() ?? '', isResolving: false, shouldResolve: false, }), })); vi.mock('./rpg-runtime-shell', () => ({ RpgRuntimeShell: ({ session, chrome, }: { session: { gameState: { currentScenePreset?: { id?: string; name?: string } | null; playerCharacter?: { name?: string } | null; runtimeSessionId?: string | null; runtimeMode?: string; runtimePersistenceDisabled?: boolean; }; currentStory?: { text?: string } | null; }; chrome?: { hidePlayerLevelBadge?: boolean }; }) => (
幕预览运行时
{chrome?.hidePlayerLevelBadge ?
隐藏等级徽标
: null}
{session.gameState.currentScenePreset?.name ?? '未进入场景'}
{session.gameState.currentScenePreset?.id ?? '未进入场景ID'}
{session.gameState.playerCharacter ? '已选择预览角色' : '未选择角色'}
{session.gameState.runtimeSessionId ?? '未设置预览会话'}
{session.gameState.runtimeMode ?? '未设置运行模式'}
{session.gameState.runtimePersistenceDisabled ? '预览禁用持久化' : '预览允许持久化'}
{session.currentStory?.text ?? '未生成当前故事'}
), })); vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({ fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }), saveCharacterWorkflowCache: vi.fn().mockResolvedValue(undefined), resolveCharacterRoleAssetWorkflow: vi.fn(({ role }) => Promise.resolve({ ok: true, cache: null, workflow: { role, defaultPromptBundle: { visualPromptText: '', animationPromptText: '', scenePromptText: '', }, visualPromptText: '', animationPromptText: '', animationPromptTextByKey: {}, visualDrafts: [], selectedVisualDraftId: '', selectedAnimation: 'idle', }, }), ), putCharacterRoleAssetWorkflow: vi.fn().mockResolvedValue({ ok: true, cache: null, }), generateCharacterVisualCandidates: vi.fn(), publishCharacterVisualAsset: vi.fn(), generateCharacterAnimationDraft: vi.fn(), publishCharacterAnimationAssets: vi.fn(), })); vi.mock('../services/customWorldCoverAssetService', () => ({ generateCustomWorldCoverImage: vi.fn(), uploadCustomWorldCoverImage: vi.fn(), })); function createBackstoryReveal() { return { publicSummary: '公开背景', chapters: [ { id: 'surface', title: '表层来意', affinityRequired: 6, teaser: '表层来意', content: '表层来意内容', contextSnippet: '表层来意摘要', }, { id: 'scar', title: '旧事裂痕', affinityRequired: 12, teaser: '旧事裂痕', content: '旧事裂痕内容', contextSnippet: '旧事裂痕摘要', }, { id: 'hidden', title: '隐藏执念', affinityRequired: 18, teaser: '隐藏执念', content: '隐藏执念内容', contextSnippet: '隐藏执念摘要', }, { id: 'final', title: '最终底牌', affinityRequired: 24, teaser: '最终底牌', content: '最终底牌内容', contextSnippet: '最终底牌摘要', }, ], }; } function createPlayableRole(id: string, name: string): CustomWorldPlayableNpc { return { id, name, title: '同行者', role: '协作战力', description: `${name}的定位描述`, backstory: `${name}的背景`, personality: `${name}的性格`, motivation: `${name}的动机`, combatStyle: `${name}的战斗风格`, initialAffinity: 18, relationshipHooks: ['关系钩子'], relations: [], tags: ['测试'], backstoryReveal: createBackstoryReveal(), skills: [], initialItems: [], }; } function createStoryRole(id: string, name: string): CustomWorldNpc { return { ...createPlayableRole(id, name), initialAffinity: 6, visual: undefined, }; } function createProfile(): CustomWorldProfile { return { id: 'world-1', settingText: '潮雾群岛上的禁制与旧航道正在一起失衡。', name: '潮雾群岛', subtitle: '旧航道与沉钟回响', summary: '一座正在被旧誓与新利益共同撕扯的群岛世界。', tone: '压抑、潮湿、带着未解旧伤。', playerGoal: '找到能让群岛重新稳定的关键节点。', templateWorldType: 'WUXIA', majorFactions: ['守潮盟', '沉钟会'], coreConflicts: ['旧航道归属', '沉钟遗产争夺'], attributeSchema: { id: 'schema-1', worldId: 'world-1', schemaVersion: 1, generatedFrom: { worldType: 'WUXIA', worldName: '潮雾群岛', settingSummary: '潮雾群岛上的禁制与旧航道正在一起失衡。', tone: '压抑、潮湿、带着未解旧伤。', conflictCore: '旧航道归属', }, slots: [ { slotId: 'axis_a', name: '骨势', }, { slotId: 'axis_b', name: '身法', }, { slotId: 'axis_c', name: '眼脉', }, { slotId: 'axis_d', name: '心焰', }, { slotId: 'axis_e', name: '尘缘', }, { slotId: 'axis_f', name: '玄息', }, ], }, playableNpcs: [createPlayableRole('playable-1', '沈砺')], storyNpcs: [createStoryRole('story-1', '顾潮音')], items: [], camp: { name: '潮灯居', description: '玩家最初落脚的旧灯塔内院。', }, landmarks: [], creatorIntent: null, anchorPack: null, lockState: null, ownedSettingLayers: null, generationMode: 'full', generationStatus: 'complete', } as unknown as CustomWorldProfile; } function createProfileWithLandmark(): CustomWorldProfile { return { ...createProfile(), storyNpcs: [ createStoryRole('story-1', '顾潮音'), createStoryRole('story-2', '闻雪汀'), createStoryRole('story-3', '谢孤灯'), createStoryRole('story-4', '陆听潮'), ], landmarks: [ { id: 'landmark-1', name: '沉钟栈桥', description: '旧钟与潮声常年相撞的码头栈桥。', imageSrc: '/generated-custom-world-scenes/original-scene.png', sceneNpcIds: ['story-1', 'story-2', 'story-3'], connections: [], }, ], } as unknown as CustomWorldProfile; } function createProfileWithTwoLandmarks(): CustomWorldProfile { return { ...createProfileWithLandmark(), landmarks: [ { id: 'landmark-1', name: '沉钟栈桥', description: '旧钟与潮声常年相撞的码头栈桥。', imageSrc: '/generated-custom-world-scenes/original-scene.png', sceneNpcIds: ['story-1', 'story-2', 'story-3'], connections: [], }, { id: 'landmark-2', name: '雾灯塔', description: '雾中仍在闪烁的旧灯塔。', sceneNpcIds: ['story-1', 'story-2', 'story-3'], connections: [], }, ], } as unknown as CustomWorldProfile; } function createSceneAct( sceneId: string, index: number, imageSrc: string, ): SceneActBlueprint { return { id: `${sceneId}-act-${index + 1}`, sceneId, title: `第${index + 1}幕`, summary: `第${index + 1}幕摘要`, stageCoverage: index === 0 ? ['opening'] : ['expansion'], backgroundPromptText: '', backgroundImageSrc: imageSrc, encounterNpcIds: ['story-1'], primaryNpcId: 'story-1', oppositeNpcId: 'story-1', eventDescription: `第${index + 1}幕事件`, linkedThreadIds: [], advanceRule: index === 0 ? 'after_primary_contact' : index >= 2 ? 'after_chapter_resolution' : 'after_active_step_complete', actGoal: `第${index + 1}幕目标`, transitionHook: '', }; } function createSceneChapter( sceneId: string, sceneName: string, imagePrefix: string, ): SceneChapterBlueprint { return { id: `${sceneId}-chapter`, sceneId, title: sceneName, summary: `${sceneName}章节`, sceneTaskDescription: `${sceneName}任务`, linkedThreadIds: [], linkedLandmarkIds: [sceneId], acts: [0, 1, 2].map((index) => createSceneAct(sceneId, index, `${imagePrefix}-act-${index + 1}.png`), ), }; } function createProfileWithSceneChapters(): CustomWorldProfile { return { ...createProfileWithLandmark(), camp: { id: 'custom-scene-camp', name: '潮灯居', description: '玩家最初落脚的旧灯塔内院。', imageSrc: '/generated-custom-world-scenes/camp-main.png', sceneNpcIds: ['story-1'], connections: [], }, sceneChapterBlueprints: [ createSceneChapter( 'custom-scene-camp', '潮灯居', '/generated-custom-world-scenes/camp', ), createSceneChapter( 'landmark-1', '沉钟栈桥', '/generated-custom-world-scenes/landmark', ), ], } as unknown as CustomWorldProfile; } function LandmarkEditorFlowHarness() { const [profile, setProfile] = useState(createProfileWithLandmark()); const [target, setTarget] = useState({ kind: 'landmark', mode: 'edit', id: 'landmark-1', }); return ( <>
        {JSON.stringify(profile)}
      
{}} onEditTarget={setTarget} onProfileChange={setProfile} onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} /> setTarget(null)} onProfileChange={setProfile} /> ); } function TwoLandmarkEditorFlowHarness() { const [profile, setProfile] = useState(createProfileWithTwoLandmarks()); const [target, setTarget] = useState({ kind: 'landmark', mode: 'edit', id: 'landmark-1', }); return ( setTarget(null)} onProfileChange={setProfile} /> ); } function readLandmarkHarnessProfile() { const content = screen.getByTestId('landmark-profile-json').textContent; return JSON.parse(content || '{}') as CustomWorldProfile; } function getSceneActCard(index: number) { const card = screen.getAllByTestId('scene-act-card')[index]; if (!card) { throw new Error(`未找到第 ${index + 1} 个幕卡片`); } return card; } function CampEditorFlowHarness() { const [profile, setProfile] = useState({ ...createProfileWithLandmark(), camp: { id: 'custom-scene-camp', name: '潮灯居', description: '玩家最初落脚的旧灯塔内院。', imageSrc: '/generated-custom-world-scenes/original-camp.png', sceneNpcIds: ['story-1', 'story-2', 'story-3'], connections: [ { targetLandmarkId: 'landmark-1', relativePosition: 'north', summary: '北侧通往沉钟栈桥。', }, ], }, }); const [target, setTarget] = useState({ kind: 'camp', }); return ( <>
        {JSON.stringify(profile)}
      
{}} onEditTarget={setTarget} onProfileChange={setProfile} onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} /> setTarget(null)} onProfileChange={setProfile} /> ); } function CoverEditorFlowHarness() { const [profile, setProfile] = useState({ ...createProfileWithLandmark(), cover: { sourceType: 'default', imageSrc: null, characterRoleIds: ['playable-1'], }, }); const [target, setTarget] = useState({ kind: 'cover', }); return ( <>
        {JSON.stringify(profile)}
      
setTarget(null)} onProfileChange={setProfile} /> ); } function readCoverHarnessProfile() { const content = screen.getByTestId('cover-profile-json').textContent; return JSON.parse(content || '{}') as CustomWorldProfile; } function readCampHarnessProfile() { const content = screen.getByTestId('camp-profile-json').textContent; return JSON.parse(content || '{}') as CustomWorldProfile; } test('playable角色打开AI工坊后不会自动关闭', async () => { const user = userEvent.setup(); const handleClose = vi.fn(); render( , ); await user.click(screen.getByRole('button', { name: 'AI生成' })); await waitFor(() => { expect(screen.getByText('AI角色形象生成')).toBeTruthy(); }); expect(handleClose).not.toHaveBeenCalled(); }); test('场景角色打开AI工坊后不会自动关闭', async () => { const user = userEvent.setup(); const handleClose = vi.fn(); render( , ); await user.click(screen.getByRole('button', { name: 'AI生成' })); await waitFor(() => { expect(screen.getByText('AI角色形象生成')).toBeTruthy(); }); expect(handleClose).not.toHaveBeenCalled(); }); test('可扮演角色未修改时右上角关闭不会弹确认', async () => { const user = userEvent.setup(); const handleClose = vi.fn(); render( , ); await user.click(screen.getByRole('button', { name: '关闭' })); expect(handleClose).toHaveBeenCalledTimes(1); expect(screen.queryByText('确认关闭')).toBeNull(); }); test('可扮演角色修改后右上角关闭才弹确认', async () => { const user = userEvent.setup(); const handleClose = vi.fn(); render( , ); const nameInput = screen.getByDisplayValue('沈砺'); await user.clear(nameInput); await user.type(nameInput, '沈砺·改'); await user.click(screen.getByRole('button', { name: '关闭' })); expect(handleClose).not.toHaveBeenCalled(); expect(screen.getAllByText('确认关闭').length).toBeGreaterThan(0); }); test('场景角色未修改时右上角关闭不会弹确认', async () => { const user = userEvent.setup(); const handleClose = vi.fn(); render( , ); await user.click(screen.getByRole('button', { name: '关闭' })); expect(handleClose).toHaveBeenCalledTimes(1); expect(screen.queryByText('确认关闭')).toBeNull(); }); test('场景角色修改后右上角关闭才弹确认', async () => { const user = userEvent.setup(); const handleClose = vi.fn(); render( , ); const nameInput = screen.getByDisplayValue('顾潮音'); await user.clear(nameInput); await user.type(nameInput, '顾潮音·改'); await user.click(screen.getByRole('button', { name: '关闭' })); expect(handleClose).not.toHaveBeenCalled(); expect(screen.getAllByText('确认关闭').length).toBeGreaterThan(0); }); test('世界页基本设定编辑按钮打开基本设定编辑目标', async () => { const user = userEvent.setup(); const handleEditTarget = vi.fn(); render( {}} onEditTarget={handleEditTarget} onProfileChange={() => {}} />, ); const editButtons = screen.getAllByRole('button', { name: '编辑' }); const foundationEditButton = editButtons[1]; expect(foundationEditButton).toBeDefined(); await user.click(foundationEditButton as HTMLElement); expect(handleEditTarget).toHaveBeenCalledWith({ kind: 'foundation' }); }); test('基本设定用分号拆分成标签展示', () => { const profile = { ...createProfile(), anchorContent: { worldPromise: '机械微生物吞并进化;角色被迫寄生改造;在失控系统里求生', playerFantasy: null, themeBoundary: null, playerEntryPoint: null, coreConflict: null, keyRelationships: null, hiddenLines: null, iconicElements: null, }, } as CustomWorldProfile; render( {}} onEditTarget={() => {}} onProfileChange={() => {}} />, ); const foundationSection = screen.getByText('世界承诺').closest('div'); expect(foundationSection).not.toBeNull(); expect(screen.getByText('机械微生物吞并进化')).toBeTruthy(); expect(screen.getByText('角色被迫寄生改造')).toBeTruthy(); expect(screen.getByText('在失控系统里求生')).toBeTruthy(); }); test('基本设定目标打开独立编辑面板', () => { render( , ); expect(screen.getByText('编辑基本设定')).toBeTruthy(); expect(screen.queryByText('编辑世界信息')).toBeNull(); }); test('基本设定面板只编辑六个角色维度名称', async () => { const user = userEvent.setup(); const savedProfileRef: { current: CustomWorldProfile | null } = { current: null, }; render( {}} onProfileChange={(profile) => { savedProfileRef.current = profile; }} />, ); expect(screen.getByText('角色维度')).toBeTruthy(); const nameInputs = screen.getAllByLabelText('维度名称'); await user.clear(nameInputs[0]!); await user.type(nameInputs[0]!, '潮骨'); expect(screen.queryByLabelText('定义')).toBeNull(); expect(screen.queryByLabelText('正向信号')).toBeNull(); expect(screen.queryByLabelText('战斗体现')).toBeNull(); await user.click(screen.getByRole('button', { name: /保存修改/u })); expect(savedProfileRef.current?.attributeSchema.slots[0]?.name).toBe( '潮骨', ); }); test('可扮演角色列表使用缩略卡片并点击进入编辑', async () => { const user = userEvent.setup(); const handleEditTarget = vi.fn(); render( {}} onEditTarget={handleEditTarget} onProfileChange={vi.fn()} onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} />, ); expect(screen.queryByText(/公开背景/u)).toBeNull(); await user.click(screen.getByRole('button', { name: /沈砺/u })); expect(handleEditTarget).toHaveBeenCalledWith({ kind: 'playable', mode: 'edit', id: 'playable-1', }); }); test('实体目录在空 id 列表项下不会触发重复 key 警告', () => { const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => undefined); render( {}} onEditTarget={() => {}} onProfileChange={vi.fn()} onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} />, ); expect(screen.getByRole('button', { name: /沈砺/u })).toBeTruthy(); expect(screen.getByRole('button', { name: /闻潮/u })).toBeTruthy(); const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) => call.some( (arg) => typeof arg === 'string' && arg.includes('Encountered two children with the same key'), ), ); expect(duplicateKeyCalls).toHaveLength(0); }); test('场景图片保存后会同步更新编辑页和场景列表', async () => { mockedRpgCreationAssetClient.generateSceneImage.mockClear(); mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({ imageSrc: '/generated-custom-world-scenes/updated-scene.png', assetId: 'asset-1', model: 'wan2.2-t2i-flash', size: '1280*720', taskId: 'task-1', prompt: '更新后的场景图', }); const user = userEvent.setup(); render(); const initialListImage = screen.getByRole('img', { name: '沉钟栈桥' }); expect(initialListImage.getAttribute('src')).toBe( '/generated-custom-world-scenes/original-scene.png', ); const firstActCard = getSceneActCard(0); await user.click(within(firstActCard).getByRole('button', { name: '配置背景' })); await waitFor(() => { expect(screen.getByText('配置幕背景:第1幕')).toBeTruthy(); }); expect(screen.queryByText('场景图片')).toBeNull(); expect(screen.queryByText('场景内 NPC')).toBeNull(); await user.click(screen.getByRole('button', { name: 'AI生成' })); await waitFor(() => { expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy(); }); await user.click(screen.getByRole('button', { name: '开始生成' })); await waitFor(() => { expect(mockedRpgCreationAssetClient.generateSceneImage).toHaveBeenCalledTimes(1); }); await user.click(screen.getByRole('button', { name: '保存' })); await waitFor(() => { expect(screen.queryByText('智能生成:沉钟栈桥')).toBeNull(); }); await waitFor(() => { expect(screen.getByRole('img', { name: '第1幕背景预览' }).getAttribute('src')).toBe( '/generated-custom-world-scenes/updated-scene.png', ); }); await user.click(screen.getByRole('button', { name: '保存背景' })); await waitFor(() => { expect(screen.queryByText('配置幕背景:第1幕')).toBeNull(); }); await user.click(screen.getByRole('button', { name: /保存修改/u })); await waitFor(() => { expect(screen.getByRole('img', { name: '沉钟栈桥' }).getAttribute('src')).toBe( '/generated-custom-world-scenes/updated-scene.png', ); }); const savedProfile = readLandmarkHarnessProfile(); expect(savedProfile.landmarks[0]?.imageSrc).toBe( '/generated-custom-world-scenes/updated-scene.png', ); const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find( (entry) => entry.sceneId === 'landmark-1', ); expect(savedSceneChapter?.acts[0]?.backgroundImageSrc).toBe( '/generated-custom-world-scenes/updated-scene.png', ); expect(savedSceneChapter?.acts[1]?.backgroundImageSrc).not.toBe( '/generated-custom-world-scenes/updated-scene.png', ); expect(savedSceneChapter?.acts[2]?.backgroundImageSrc).not.toBe( '/generated-custom-world-scenes/updated-scene.png', ); }); test('开局场景图片保存后会同步更新编辑页和场景列表', async () => { mockedRpgCreationAssetClient.generateSceneImage.mockClear(); mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({ imageSrc: '/generated-custom-world-scenes/updated-camp.png', assetId: 'asset-camp-1', model: 'wan2.2-t2i-flash', size: '1280*720', taskId: 'task-camp-1', prompt: '更新后的开局场景图', }); const user = userEvent.setup(); render(); const initialListImage = screen.getByRole('img', { name: '潮灯居' }); expect(initialListImage.getAttribute('src')).toBe( '/generated-custom-world-scenes/original-camp.png', ); const firstActCard = getSceneActCard(0); await user.click(within(firstActCard).getByRole('button', { name: '配置背景' })); await waitFor(() => { expect(screen.getByText('配置幕背景:第1幕')).toBeTruthy(); }); expect(screen.queryByText('场景图片')).toBeNull(); expect(screen.queryByText('场景内 NPC')).toBeNull(); await user.click(screen.getByRole('button', { name: 'AI生成' })); await waitFor(() => { expect(screen.getByText('智能生成:潮灯居')).toBeTruthy(); }); await user.click(screen.getByRole('button', { name: '开始生成' })); await waitFor(() => { expect(mockedRpgCreationAssetClient.generateSceneImage).toHaveBeenCalledTimes(1); }); await user.click(screen.getByRole('button', { name: '保存' })); await waitFor(() => { expect(screen.queryByText('智能生成:潮灯居')).toBeNull(); }); await waitFor(() => { expect(screen.getByRole('img', { name: '第1幕背景预览' }).getAttribute('src')).toBe( '/generated-custom-world-scenes/updated-camp.png', ); }); await user.click(screen.getByRole('button', { name: '保存背景' })); await waitFor(() => { expect(screen.queryByText('配置幕背景:第1幕')).toBeNull(); }); await user.click(screen.getByRole('button', { name: /保存修改/u })); await waitFor(() => { expect(screen.getByRole('img', { name: '潮灯居' }).getAttribute('src')).toBe( '/generated-custom-world-scenes/updated-camp.png', ); }); const savedProfile = readCampHarnessProfile(); expect(savedProfile.camp?.imageSrc).toBe( '/generated-custom-world-scenes/updated-camp.png', ); const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find( (entry) => entry.sceneId === 'custom-scene-camp', ); expect(savedSceneChapter?.acts[0]?.backgroundImageSrc).toBe( '/generated-custom-world-scenes/updated-camp.png', ); expect(savedSceneChapter?.acts[1]?.backgroundImageSrc).not.toBe( '/generated-custom-world-scenes/updated-camp.png', ); expect(savedSceneChapter?.acts[2]?.backgroundImageSrc).not.toBe( '/generated-custom-world-scenes/updated-camp.png', ); }); test('开局场景在场景配置面板中与普通场景使用同级参数并可保存', async () => { const user = userEvent.setup(); render(); expect(screen.getByText('多幕配置')).toBeTruthy(); expect(screen.getByText('场景连接关系')).toBeTruthy(); expect(screen.queryByText('场景图片')).toBeNull(); expect(screen.queryByText('场景内 NPC')).toBeNull(); expect(screen.getAllByTestId('scene-act-card')).toHaveLength(3); const firstActCard = getSceneActCard(0); await user.click(within(firstActCard).getAllByTestId('scene-act-slot-button')[0]!); await waitFor(() => { expect(screen.getByText('配置角色:第1幕 · 主角色槽位')).toBeTruthy(); }); await user.click(screen.getByRole('button', { name: /闻雪汀/u })); await user.click(screen.getByRole('button', { name: '保存角色' })); await waitFor(() => { expect(screen.queryByText('配置角色:第1幕 · 主角色槽位')).toBeNull(); }); await user.click(screen.getByRole('button', { name: /保存修改/u })); await waitFor(() => { expect(screen.queryByText('编辑场景:潮灯居')).toBeNull(); }); const savedProfile = readCampHarnessProfile(); const openingSceneChapter = savedProfile.sceneChapterBlueprints?.find( (entry) => entry.sceneId === 'custom-scene-camp', ); expect(savedProfile.camp?.sceneNpcIds).toContain('story-2'); expect(savedProfile.camp?.connections).toEqual([ { targetLandmarkId: 'landmark-1', relativePosition: 'north', summary: '北侧通往沉钟栈桥。', }, ]); expect(openingSceneChapter).toBeTruthy(); expect(openingSceneChapter?.acts[0]?.encounterNpcIds[0]).toBe('story-2'); expect(openingSceneChapter?.acts[1]?.encounterNpcIds[0]).not.toBe('story-2'); expect(openingSceneChapter?.acts[1]?.primaryNpcId).not.toBe('story-2'); expect(openingSceneChapter?.acts[2]?.encounterNpcIds[0]).not.toBe('story-2'); expect(openingSceneChapter?.acts[2]?.primaryNpcId).not.toBe('story-2'); expect(openingSceneChapter?.linkedLandmarkIds).toContain('custom-scene-camp'); }); test('开局场景列表与详情幕预览复用同一套幕级图片', async () => { const profile = createProfileWithSceneChapters(); profile.sceneChapterBlueprints![0]!.acts[1]!.backgroundPromptText = '第二幕专属背景提示'; const user = userEvent.setup(); render( <> {}} onEditTarget={() => {}} onProfileChange={() => {}} onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} /> {}} onProfileChange={() => {}} /> , ); expect(screen.getByRole('img', { name: '潮灯居-第2幕' }).getAttribute('src')).toBe( '/generated-custom-world-scenes/camp-act-2.png', ); expect(screen.getByRole('img', { name: '沉钟栈桥-第2幕' }).getAttribute('src')).toBe( '/generated-custom-world-scenes/landmark-act-2.png', ); expect(screen.getByRole('img', { name: '第2幕幕背景' }).getAttribute('src')).toBe( '/generated-custom-world-scenes/camp-act-2.png', ); await user.click(within(getSceneActCard(1)).getByRole('button', { name: '配置背景' })); await waitFor(() => { expect(screen.getByText('配置幕背景:第2幕')).toBeTruthy(); }); expect(screen.getByRole('img', { name: '第2幕背景预览' }).getAttribute('src')).toBe( '/generated-custom-world-scenes/camp-act-2.png', ); }); test('开局场景幕背景智能生成复用当前幕图片和幕级提示词', async () => { mockedRpgCreationAssetClient.generateSceneImage.mockClear(); mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({ imageSrc: '/generated-custom-world-scenes/camp-act-2-ai.png', assetId: 'asset-camp-act-2', model: 'wan2.2-t2i-flash', size: '1280*720', taskId: 'task-camp-act-2', prompt: '第二幕专属背景提示', }); const profile = createProfileWithSceneChapters(); profile.sceneChapterBlueprints![0]!.acts[1]!.backgroundPromptText = '第二幕专属背景提示'; const user = userEvent.setup(); render( {}} onProfileChange={() => {}} />, ); await user.click(within(getSceneActCard(1)).getByRole('button', { name: '配置背景' })); await waitFor(() => { expect(screen.getByText('配置幕背景:第2幕')).toBeTruthy(); }); await user.click(screen.getByRole('button', { name: 'AI生成' })); await waitFor(() => { expect(screen.getByText('智能生成:潮灯居')).toBeTruthy(); }); expect(screen.getByRole('img', { name: '潮灯居' }).getAttribute('src')).toBe( '/generated-custom-world-scenes/camp-act-2.png', ); await user.click(screen.getByRole('button', { name: '开始生成' })); await waitFor(() => { expect(mockedRpgCreationAssetClient.generateSceneImage).toHaveBeenCalledTimes(1); }); const payload = mockedRpgCreationAssetClient.generateSceneImage.mock.calls[0]?.[0]; expect(payload?.userPrompt).toBe('第二幕专属背景提示'); }); test('普通场景世界地图会包含开局场景并高亮当前场景', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: '查看世界地图' })); await waitFor(() => { expect(screen.getByText('世界地图')).toBeTruthy(); }); expect(screen.getAllByText('潮灯居').length).toBeGreaterThan(0); expect(screen.getAllByText('沉钟栈桥').length).toBeGreaterThan(0); expect(screen.getByText('当前')).toBeTruthy(); }); test('世界地图会展示当前未保存的场景连接', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByText('北')); await user.click(screen.getByRole('button', { name: /雾灯塔/u })); await waitFor(() => { expect(screen.queryByText('北侧连接')).toBeNull(); }); await user.click(screen.getByRole('button', { name: '查看世界地图' })); await waitFor(() => { expect(screen.getByText('世界地图')).toBeTruthy(); }); expect(screen.getAllByText('雾灯塔').length).toBeGreaterThan(0); expect(screen.getAllByText('北').length).toBeGreaterThan(0); }); test('场景编辑器会在场景内展示槽位化多幕配置并保存', async () => { const user = userEvent.setup(); render(); expect(screen.getByText('多幕配置')).toBeTruthy(); expect(screen.getAllByTestId('scene-act-card')).toHaveLength(3); expect(screen.queryByText('幕标题')).toBeNull(); expect(screen.queryByText('幕摘要')).toBeNull(); expect(screen.queryByText('幕目标')).toBeNull(); expect(screen.queryByText('过渡铺垫')).toBeNull(); const firstActCard = getSceneActCard(0); expect(within(firstActCard).getAllByTestId('scene-act-slot-button')).toHaveLength(3); await user.click(within(firstActCard).getByRole('button', { name: '配置背景' })); await waitFor(() => { expect(screen.getByText('配置幕背景:第1幕')).toBeTruthy(); }); const presetImage = screen.getByRole('img', { name: '幕背景预设 1' }); const presetSrc = presetImage.getAttribute('src'); const presetButton = presetImage.closest('button'); expect(presetButton).toBeTruthy(); if (!presetButton) { throw new Error('未找到幕背景预设按钮'); } await user.click(presetButton); await user.click(screen.getByRole('button', { name: '保存背景' })); await waitFor(() => { expect(screen.queryByText('配置幕背景:第1幕')).toBeNull(); }); await user.click(within(getSceneActCard(0)).getAllByTestId('scene-act-slot-button')[0]!); await waitFor(() => { expect(screen.getByText('配置角色:第1幕 · 主角色槽位')).toBeTruthy(); }); await user.click(screen.getByRole('button', { name: /闻雪汀/u })); await user.click(screen.getByRole('button', { name: '保存角色' })); await waitFor(() => { expect(screen.queryByText('配置角色:第1幕 · 主角色槽位')).toBeNull(); }); expect( within(getSceneActCard(0)).getByRole('button', { name: '配置第1个角色:闻雪汀', }), ).toBeTruthy(); await user.click(screen.getByRole('button', { name: /保存修改/u })); await waitFor(() => { expect(screen.queryByText('编辑场景:沉钟栈桥')).toBeNull(); }); const savedProfile = readLandmarkHarnessProfile(); const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find( (entry) => entry.sceneId === 'landmark-1', ); expect(savedSceneChapter).toBeTruthy(); expect(savedSceneChapter?.acts[0]?.backgroundImageSrc).toBe(presetSrc); expect(savedSceneChapter?.acts[0]?.encounterNpcIds[0]).toBe('story-2'); expect(savedSceneChapter?.acts[0]?.primaryNpcId).toBe('story-2'); }); test('场景多幕支持新增删除和调序', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: '新增一幕' })); expect(screen.getAllByTestId('scene-act-card')).toHaveLength(4); const secondActCard = getSceneActCard(1); await user.click(within(secondActCard).getAllByTestId('scene-act-slot-button')[0]!); await waitFor(() => { expect(screen.getByText('配置角色:第2幕 · 主角色槽位')).toBeTruthy(); }); await user.click(screen.getByRole('button', { name: /谢孤灯/u })); await user.click(screen.getByRole('button', { name: '保存角色' })); await user.click(within(secondActCard).getByRole('button', { name: '下移' })); const fourthActCard = getSceneActCard(3); await user.click(within(fourthActCard).getByRole('button', { name: '删除' })); expect(screen.getAllByTestId('scene-act-card')).toHaveLength(3); await user.click(screen.getByRole('button', { name: /保存修改/u })); await waitFor(() => { expect(screen.queryByText('编辑场景:沉钟栈桥')).toBeNull(); }); const savedProfile = readLandmarkHarnessProfile(); const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find( (entry) => entry.sceneId === 'landmark-1', ); expect(savedSceneChapter?.acts).toHaveLength(3); expect(savedSceneChapter?.acts[2]?.primaryNpcId).toBe('story-3'); }); test('每幕角色槽位可以从当前世界所有 NPC 中选择', async () => { const user = userEvent.setup(); render(); await user.click(within(getSceneActCard(0)).getAllByTestId('scene-act-slot-button')[0]!); await waitFor(() => { expect(screen.getByText('配置角色:第1幕 · 主角色槽位')).toBeTruthy(); }); expect(screen.getByRole('button', { name: /陆听潮/u })).toBeTruthy(); await user.click(screen.getByRole('button', { name: /陆听潮/u })); await user.click(screen.getByRole('button', { name: '保存角色' })); await waitFor(() => { expect(screen.queryByText('配置角色:第1幕 · 主角色槽位')).toBeNull(); }); await user.click(screen.getByRole('button', { name: /保存修改/u })); await waitFor(() => { expect(screen.queryByText('编辑场景:沉钟栈桥')).toBeNull(); }); const savedProfile = readLandmarkHarnessProfile(); const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find( (entry) => entry.sceneId === 'landmark-1', ); expect(savedSceneChapter?.acts[0]?.encounterNpcIds[0]).toBe('story-4'); expect(savedProfile.landmarks[0]?.sceneNpcIds).toContain('story-4'); }); test('场景幕预览会打开当前幕运行时面板', async () => { const user = userEvent.setup(); render(); await user.click(within(getSceneActCard(0)).getByRole('button', { name: '幕预览' })); await waitFor(() => { expect(screen.getByText('幕预览运行时')).toBeTruthy(); }); expect(screen.getAllByText('沉钟栈桥').length).toBeGreaterThan(0); expect(screen.getByText('隐藏等级徽标')).toBeTruthy(); expect(screen.getByText('已选择预览角色')).toBeTruthy(); expect(screen.getByText('runtime-scene-act-preview')).toBeTruthy(); expect(screen.getByText('landmark-1')).toBeTruthy(); expect(screen.getByText('play')).toBeTruthy(); expect(screen.getByText('预览禁用持久化')).toBeTruthy(); expect(screen.getByText(/顾潮音已经在沉钟栈桥等你/u)).toBeTruthy(); expect(screen.queryByText('正在载入这一幕的游戏流程...')).toBeNull(); await user.click(screen.getByRole('button', { name: '结束预览' })); await waitFor(() => { expect(screen.queryByText('幕预览运行时')).toBeNull(); }); }); test('作品封面上传会先进入 16:9 裁剪面板再提交到后端', async () => { const uploadMock = vi .mocked(customWorldCoverAssetService.uploadCustomWorldCoverImage) .mockResolvedValue({ imageSrc: '/generated-custom-world-covers/world-1/uploaded/cover.webp', assetId: 'custom-cover-upload-1', sourceType: 'uploaded', }); class MockFileReader { result: string | null = null; error: Error | null = null; onload: null | (() => void) = null; onerror: null | (() => void) = null; readAsDataURL() { this.result = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII='; this.onload?.(); } } class MockImage { onload: null | (() => void) = null; onerror: null | (() => void) = null; naturalWidth = 1920; naturalHeight = 1080; set src(_value: string) { this.onload?.(); } } vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader); vi.stubGlobal('Image', MockImage as unknown as typeof Image); const user = userEvent.setup(); render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement | null; expect(input).toBeTruthy(); if (!input) { throw new Error('未找到封面上传输入框'); } const file = new File(['cover'], 'cover.png', { type: 'image/png' }); await user.upload(input, file); await waitFor(() => { expect(screen.getByText('裁剪上传封面')).toBeTruthy(); }); expect( screen.getByRole('button', { name: '拖拽右下角裁剪边界' }), ).toBeTruthy(); expect(screen.queryByText('左右位置')).toBeNull(); expect(screen.queryByText('上下位置')).toBeNull(); await user.click(screen.getByRole('button', { name: '确认裁剪并上传' })); await waitFor(() => { expect(uploadMock).toHaveBeenCalledTimes(1); }); const uploadPayload = uploadMock.mock.calls[0]?.[0]; expect(uploadPayload?.worldName).toBe('潮雾群岛'); expect(uploadPayload?.cropRect.width).toBeGreaterThan(0); expect(uploadPayload?.cropRect.height).toBeGreaterThan(0); await waitFor(() => { expect(screen.queryByText('裁剪上传封面')).toBeNull(); }); await user.click(screen.getByRole('button', { name: /保存/u })); await waitFor(() => { expect(screen.queryByText('编辑作品封面')).toBeNull(); }); const savedProfile = readCoverHarnessProfile(); expect(savedProfile.cover?.sourceType).toBe('uploaded'); expect(savedProfile.cover?.imageSrc).toBe( '/generated-custom-world-covers/world-1/uploaded/cover.webp', ); });