Files
Genarrative/src/components/CustomWorldEntityEditorModal.test.tsx
2026-04-30 17:49:07 +08:00

1454 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @vitest-environment jsdom */
import {
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<typeof import('../data/characterPresets')>(
'../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: () => <div></div>,
}));
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 } }) => (
<div>{npc.name}</div>
),
CustomWorldNpcVisualEditor: () => <div></div>,
}));
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 };
}) => (
<div>
<div></div>
{chrome?.hidePlayerLevelBadge ? <div></div> : null}
<div>{session.gameState.currentScenePreset?.name ?? '未进入场景'}</div>
<div>{session.gameState.currentScenePreset?.id ?? '未进入场景ID'}</div>
<div>
{session.gameState.playerCharacter ? '已选择预览角色' : '未选择角色'}
</div>
<div>{session.gameState.runtimeSessionId ?? '未设置预览会话'}</div>
<div>{session.gameState.runtimeMode ?? '未设置运行模式'}</div>
<div>
{session.gameState.runtimePersistenceDisabled
? '预览禁用持久化'
: '预览允许持久化'}
</div>
<div>{session.currentStory?.text ?? '未生成当前故事'}</div>
</div>
),
}));
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<RpgCreationEditorTarget | null>({
kind: 'landmark',
mode: 'edit',
id: 'landmark-1',
});
return (
<>
<pre data-testid="landmark-profile-json" className="hidden">
{JSON.stringify(profile)}
</pre>
<CustomWorldEntityCatalog
profile={profile}
previewCharacters={[]}
activeTab="landmarks"
onActiveTabChange={() => {}}
onEditTarget={setTarget}
onProfileChange={setProfile}
onDeleteStoryNpcs={() => {}}
onDeleteLandmarks={() => {}}
/>
<RpgCreationEntityEditorModal
profile={profile}
target={target}
onClose={() => setTarget(null)}
onProfileChange={setProfile}
/>
</>
);
}
function TwoLandmarkEditorFlowHarness() {
const [profile, setProfile] = useState(createProfileWithTwoLandmarks());
const [target, setTarget] = useState<RpgCreationEditorTarget | null>({
kind: 'landmark',
mode: 'edit',
id: 'landmark-1',
});
return (
<RpgCreationEntityEditorModal
profile={profile}
target={target}
onClose={() => 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<CustomWorldProfile>({
...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<RpgCreationEditorTarget | null>({
kind: 'camp',
});
return (
<>
<pre data-testid="camp-profile-json" className="hidden">
{JSON.stringify(profile)}
</pre>
<CustomWorldEntityCatalog
profile={profile}
previewCharacters={[]}
activeTab="landmarks"
onActiveTabChange={() => {}}
onEditTarget={setTarget}
onProfileChange={setProfile}
onDeleteStoryNpcs={() => {}}
onDeleteLandmarks={() => {}}
/>
<RpgCreationEntityEditorModal
profile={profile}
target={target}
onClose={() => setTarget(null)}
onProfileChange={setProfile}
/>
</>
);
}
function CoverEditorFlowHarness() {
const [profile, setProfile] = useState<CustomWorldProfile>({
...createProfileWithLandmark(),
cover: {
sourceType: 'default',
imageSrc: null,
characterRoleIds: ['playable-1'],
},
});
const [target, setTarget] = useState<RpgCreationEditorTarget | null>({
kind: 'cover',
});
return (
<>
<pre data-testid="cover-profile-json" className="hidden">
{JSON.stringify(profile)}
</pre>
<RpgCreationEntityEditorModal
profile={profile}
target={target}
onClose={() => 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(
<RpgCreationEntityEditorModal
profile={createProfile()}
target={{ kind: 'playable', mode: 'edit', id: 'playable-1' }}
onClose={handleClose}
onProfileChange={vi.fn()}
/>,
);
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(
<RpgCreationEntityEditorModal
profile={createProfile()}
target={{ kind: 'story', mode: 'edit', id: 'story-1' }}
onClose={handleClose}
onProfileChange={vi.fn()}
/>,
);
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(
<RpgCreationEntityEditorModal
profile={createProfile()}
target={{ kind: 'playable', mode: 'edit', id: 'playable-1' }}
onClose={handleClose}
onProfileChange={vi.fn()}
/>,
);
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(
<RpgCreationEntityEditorModal
profile={createProfile()}
target={{ kind: 'playable', mode: 'edit', id: 'playable-1' }}
onClose={handleClose}
onProfileChange={vi.fn()}
/>,
);
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(
<RpgCreationEntityEditorModal
profile={createProfile()}
target={{ kind: 'story', mode: 'edit', id: 'story-1' }}
onClose={handleClose}
onProfileChange={vi.fn()}
/>,
);
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(
<RpgCreationEntityEditorModal
profile={createProfile()}
target={{ kind: 'story', mode: 'edit', id: 'story-1' }}
onClose={handleClose}
onProfileChange={vi.fn()}
/>,
);
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(
<CustomWorldEntityCatalog
profile={createProfile()}
previewCharacters={[]}
activeTab="world"
onActiveTabChange={() => {}}
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(
<CustomWorldEntityCatalog
profile={profile}
previewCharacters={[]}
activeTab="world"
onActiveTabChange={() => {}}
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(
<RpgCreationEntityEditorModal
profile={createProfile()}
target={{ kind: 'foundation' }}
onClose={vi.fn()}
onProfileChange={vi.fn()}
/>,
);
expect(screen.getByText('编辑基本设定')).toBeTruthy();
expect(screen.queryByText('编辑世界信息')).toBeNull();
});
test('基本设定面板只编辑六个角色维度名称', async () => {
const user = userEvent.setup();
const savedProfileRef: { current: CustomWorldProfile | null } = {
current: null,
};
render(
<RpgCreationEntityEditorModal
profile={createProfile()}
target={{ kind: 'foundation' }}
onClose={() => {}}
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(
<CustomWorldEntityCatalog
profile={createProfile()}
previewCharacters={[]}
activeTab="playable"
onActiveTabChange={() => {}}
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(
<CustomWorldEntityCatalog
profile={{
...createProfile(),
playableNpcs: [
createPlayableRole('', '沈砺'),
createPlayableRole('', '闻潮'),
],
}}
previewCharacters={[]}
activeTab="playable"
onActiveTabChange={() => {}}
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(<LandmarkEditorFlowHarness />);
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(<CampEditorFlowHarness />);
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(<CampEditorFlowHarness />);
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(
<>
<CustomWorldEntityCatalog
profile={profile}
previewCharacters={[]}
activeTab="landmarks"
onActiveTabChange={() => {}}
onEditTarget={() => {}}
onProfileChange={() => {}}
onDeleteStoryNpcs={() => {}}
onDeleteLandmarks={() => {}}
/>
<RpgCreationEntityEditorModal
profile={profile}
target={{ kind: 'camp' }}
onClose={() => {}}
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(
<RpgCreationEntityEditorModal
profile={profile}
target={{ kind: 'camp' }}
onClose={() => {}}
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(<LandmarkEditorFlowHarness />);
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(<TwoLandmarkEditorFlowHarness />);
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(<LandmarkEditorFlowHarness />);
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(<LandmarkEditorFlowHarness />);
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(<LandmarkEditorFlowHarness />);
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(<LandmarkEditorFlowHarness />);
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(<CoverEditorFlowHarness />);
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();
});
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',
);
});