Files
Genarrative/src/components/CustomWorldEntityEditorModal.test.tsx
kdletters 1ad25e30f8 收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
2026-06-10 10:24:18 +08:00

2146 lines
67 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 LandmarkEditorNoNpcFlowHarness() {
const [profile, setProfile] = useState<CustomWorldProfile>(() => ({
...createProfileWithLandmark(),
storyNpcs: [],
landmarks: [
{
id: 'landmark-1',
name: '空港栈桥',
description: '暂时没有角色驻留的场景。',
imageSrc: '/generated-custom-world-scenes/empty-scene.png',
sceneNpcIds: [],
connections: [],
},
],
sceneChapterBlueprints: [
{
...createSceneChapter(
'landmark-1',
'空港栈桥',
'/generated-custom-world-scenes/empty',
),
acts: [
{
...createSceneAct(
'landmark-1',
0,
'/generated-custom-world-scenes/empty-act-1.png',
),
encounterNpcIds: [],
primaryNpcId: '',
oppositeNpcId: '',
},
{
...createSceneAct(
'landmark-1',
1,
'/generated-custom-world-scenes/empty-act-2.png',
),
encounterNpcIds: [],
primaryNpcId: '',
oppositeNpcId: '',
},
],
},
],
}));
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 findNearestClassName(element: HTMLElement, className: string) {
let current: HTMLElement | null = element;
while (current) {
if (current.className.includes(className)) {
return current.className;
}
current = current.parentElement;
}
return '';
}
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('可扮演角色技能动作状态复用暗色平台胶囊标签', () => {
const profile = createProfile();
profile.playableNpcs = [
{
...profile.playableNpcs[0]!,
imageSrc: '/generated-custom-world-roles/playable-1.png',
generatedVisualAssetId: 'visual-playable-1',
generatedAnimationSetId: 'animation-playable-1',
initialItems: [
{
id: 'starter-token',
name: '起始信物',
category: '道具',
quantity: 1,
rarity: 'rare',
description: '角色开局携带的测试道具。',
tags: ['开局', '信物'],
},
],
skills: [
{
id: 'tide-slash',
name: '潮刃突进',
summary: '向前突进并斩击。',
style: 'burst',
},
],
},
];
render(
<RpgCreationEntityEditorModal
profile={profile}
target={{ kind: 'playable', mode: 'edit', id: 'playable-1' }}
onClose={vi.fn()}
onProfileChange={vi.fn()}
/>,
);
const actionStatusBadge = screen.getByText('待生成动作');
const visualStatusBadge = screen.getByText('已应用主图');
const animationStatusBadge = screen.getByText('已应用动作');
const initialItemTagBadge = screen.getByText('开局');
const rolePreviewFrame = screen
.getByRole('img', { name: '沈砺' })
.closest('.platform-media-frame');
const skillPreviewFrame = screen
.getByRole('img', { name: '潮刃突进' })
.closest('.platform-media-frame');
const sectionPanels = ['背景故事', '与其他角色的关系', '技能', '物品'].map(
(title) => screen.getByText(title).closest('section'),
);
expect(actionStatusBadge.className).toContain('rounded-full');
expect(actionStatusBadge.className).toContain('font-black');
expect(actionStatusBadge.className).toContain('bg-black/20');
expect(actionStatusBadge.className).toContain('text-zinc-400');
expect(visualStatusBadge.className).toContain('rounded-full');
expect(visualStatusBadge.className).toContain('font-black');
expect(visualStatusBadge.className).toContain('bg-emerald-500/10');
expect(animationStatusBadge.className).toContain('rounded-full');
expect(animationStatusBadge.className).toContain('font-black');
expect(animationStatusBadge.className).toContain('bg-amber-500/10');
expect(initialItemTagBadge.className).toContain('rounded-full');
expect(initialItemTagBadge.className).toContain('font-black');
expect(initialItemTagBadge.className).toContain('bg-black/20');
expect(rolePreviewFrame?.className).toContain('platform-media-frame');
expect(rolePreviewFrame?.className).toContain('aspect-square');
expect(skillPreviewFrame?.className).toContain('platform-media-frame');
expect(skillPreviewFrame?.className).toContain('rounded-none');
for (const sectionPanel of sectionPanels) {
expect(sectionPanel?.className).toContain('border-white/10');
expect(sectionPanel?.className).toContain('bg-black/25');
expect(sectionPanel?.className).toContain('rounded-[1.25rem]');
}
});
test('可扮演角色空态复用暗色平台空态', () => {
render(
<RpgCreationEntityEditorModal
profile={createProfile()}
target={{ kind: 'playable', mode: 'edit', id: 'playable-1' }}
onClose={vi.fn()}
onProfileChange={vi.fn()}
/>,
);
for (const label of [
'还没有配置与其他角色的关系。',
'还没有配置角色技能。',
'还没有配置角色物品。',
]) {
const emptyState = screen.getByText(label);
expect(emptyState.className).toContain('platform-empty-state');
expect(emptyState.className).toContain('border-dashed');
expect(emptyState.className).toContain('bg-black/20');
}
});
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();
const dialog = screen.getByRole('dialog', { name: '确认关闭' });
expect(
within(dialog).getByText('当前修改尚未保存,确认关闭吗?'),
).toBeTruthy();
await user.click(within(dialog).getByRole('button', { name: '继续编辑' }));
expect(handleClose).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog', { name: '确认关闭' })).toBeNull();
await user.click(screen.getByRole('button', { name: '关闭' }));
await user.click(screen.getByRole('button', { name: '确认关闭' }));
expect(handleClose).toHaveBeenCalledTimes(1);
});
test('可扮演角色至少保留一个背景章节时使用统一提示弹窗', async () => {
const user = userEvent.setup();
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
render(
<RpgCreationEntityEditorModal
profile={createProfile()}
target={{ kind: 'playable', mode: 'edit', id: 'playable-1' }}
onClose={vi.fn()}
onProfileChange={vi.fn()}
/>,
);
const deleteChapterButtons = () =>
screen.getAllByRole('button', { name: '删除章节' });
await user.click(deleteChapterButtons()[0]!);
await user.click(deleteChapterButtons()[0]!);
await user.click(deleteChapterButtons()[0]!);
expect(deleteChapterButtons()).toHaveLength(1);
await user.click(deleteChapterButtons()[0]!);
const dialog = await screen.findByRole('dialog', { name: '提示' });
expect(within(dialog).getByText('至少保留一个背景章节。')).toBeTruthy();
expect(alertSpy).not.toHaveBeenCalled();
expect(screen.getByText('编辑角色:沈砺')).toBeTruthy();
await user.click(within(dialog).getByRole('button', { name: '知道了' }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '提示' })).toBeNull();
});
alertSpy.mockRestore();
});
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();
const dialog = screen.getByRole('dialog', { name: '确认关闭' });
expect(
within(dialog).getByText('当前修改尚未保存,确认关闭吗?'),
).toBeTruthy();
await user.click(within(dialog).getByRole('button', { name: '继续编辑' }));
expect(handleClose).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog', { name: '确认关闭' })).toBeNull();
await user.click(screen.getByRole('button', { name: '关闭' }));
await user.click(screen.getByRole('button', { name: '确认关闭' }));
expect(handleClose).toHaveBeenCalledTimes(1);
});
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();
const playableCard = screen.getByRole('button', { name: //u });
const playableMediaFrame = Array.from(
playableCard.querySelectorAll<HTMLElement>('.platform-subpanel'),
).find((element) => element.className.includes('h-[4.75rem]'));
expect(playableCard.tagName).toBe('BUTTON');
expect(playableCard.className).toContain('platform-subpanel');
expect(playableCard.className).toContain('rounded-[1.3rem]');
expect(playableMediaFrame).toBeTruthy();
expect(playableMediaFrame?.className).toContain('platform-subpanel');
expect(playableMediaFrame?.className).toContain('rounded-[1rem]');
expect(playableMediaFrame?.className).toContain('p-0');
await user.click(playableCard);
expect(handleEditTarget).toHaveBeenCalledWith({
kind: 'playable',
mode: 'edit',
id: 'playable-1',
});
});
test('实体目录批量选择卡片和选择徽标复用平台公共组件 chrome', async () => {
const user = userEvent.setup();
render(
<CustomWorldEntityCatalog
profile={createProfileWithLandmark()}
previewCharacters={[]}
activeTab="story"
onActiveTabChange={() => {}}
onEditTarget={() => {}}
onProfileChange={() => {}}
onDeleteStoryNpcs={() => {}}
onDeleteLandmarks={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: '批量删除' }));
const storyCard = screen.getByRole('button', { name: //u });
const idleBadge = within(storyCard).getByText('选择');
expect(storyCard.tagName).toBe('BUTTON');
expect(storyCard.className).toContain('platform-subpanel');
expect(storyCard.className).toContain('rounded-[1.3rem]');
expect(idleBadge.className).toContain('bg-[var(--platform-subpanel-fill)]');
expect(idleBadge.className).toContain('text-[var(--platform-text-soft)]');
await user.click(storyCard);
const selectedStoryCard = screen.getByRole('button', { name: //u });
const selectedBadge = within(selectedStoryCard).getByText('已选');
expect(selectedStoryCard.className).not.toContain('platform-subpanel');
expect(selectedStoryCard.className).toContain(
'border-[var(--platform-button-danger-border)]',
);
expect(selectedStoryCard.className).toContain(
'bg-[var(--platform-button-danger-fill)]',
);
expect(selectedBadge.className).toContain(
'border-[var(--platform-button-danger-border)]',
);
});
test('实体目录场景图片框复用平台媒体框 chrome', () => {
render(
<CustomWorldEntityCatalog
profile={createProfileWithLandmark()}
previewCharacters={[]}
activeTab="landmarks"
onActiveTabChange={() => {}}
onEditTarget={() => {}}
onProfileChange={() => {}}
onDeleteStoryNpcs={() => {}}
onDeleteLandmarks={() => {}}
/>,
);
const sceneImage = screen.getByRole('img', { name: '沉钟栈桥' });
const mediaFrame = sceneImage.closest('div.relative');
expect(mediaFrame?.className).toContain('aspect-[16/9]');
expect(mediaFrame?.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(mediaFrame?.className).toContain('radial-gradient');
expect(sceneImage.className).toContain('object-cover');
});
test('实体目录搜索框和空态复用平台公共组件 chrome', async () => {
const user = userEvent.setup();
render(
<CustomWorldEntityCatalog
profile={createProfile()}
previewCharacters={[]}
activeTab="playable"
onActiveTabChange={() => {}}
onEditTarget={() => {}}
onProfileChange={() => {}}
onDeleteStoryNpcs={() => {}}
onDeleteLandmarks={() => {}}
/>,
);
const searchInput = screen.getByPlaceholderText('搜索角色名称、称号、标签');
expect(searchInput.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(searchInput.className).toContain('focus:ring-2');
await user.type(searchInput, '没有这个角色');
const emptyState = screen
.getByText('当前没有符合搜索条件的可扮演角色。')
.closest('div.rounded-2xl');
expect(emptyState?.className).toContain('border-dashed');
expect(emptyState?.className).toContain('bg-white/52');
});
test('世界页统计和基本设定复用平台公共组件 chrome', () => {
render(
<CustomWorldEntityCatalog
profile={createProfile()}
previewCharacters={[]}
activeTab="world"
onActiveTabChange={() => {}}
onEditTarget={() => {}}
onProfileChange={() => {}}
onDeleteStoryNpcs={() => {}}
onDeleteLandmarks={() => {}}
/>,
);
const archiveScaleSection = screen
.getByText('档案规模')
.closest('.platform-surface');
const playableStatCard = within(archiveScaleSection as HTMLElement).getByText(
'可扮演角色',
).parentElement;
const worldTonePanel = screen
.getByText(//u)
.closest('div.rounded-2xl');
const roleDimensionPanel = screen
.getByText('角色维度')
.closest('div.rounded-2xl');
const foundationPanel = screen
.getByText('玩家幻想')
.closest('div.rounded-2xl');
expect(playableStatCard?.className).toContain('platform-subpanel');
expect(playableStatCard?.className).toContain('bg-white/68');
expect(playableStatCard?.className).toContain('rounded-xl');
expect(worldTonePanel?.className).toContain('platform-subpanel');
expect(roleDimensionPanel?.className).toContain('platform-subpanel');
expect(foundationPanel?.className).toContain('platform-subpanel');
expect(screen.getByText('角色维度').className).toContain('tracking-[0.18em]');
});
test('可扮演角色删除改用统一确认弹窗', async () => {
const user = userEvent.setup();
const handleProfileChange = vi.fn();
render(
<CustomWorldEntityCatalog
profile={{
...createProfile(),
playableNpcs: [
createPlayableRole('playable-1', '沈砺'),
createPlayableRole('playable-2', '闻潮'),
],
}}
previewCharacters={[]}
activeTab="playable"
onActiveTabChange={() => {}}
onEditTarget={() => {}}
onProfileChange={handleProfileChange}
onDeleteStoryNpcs={() => {}}
onDeleteLandmarks={() => {}}
/>,
);
await user.click(screen.getAllByRole('button', { name: '删除' })[0]!);
const dialog = screen.getByRole('dialog', { name: '删除角色' });
expect(
within(dialog).getByText('确认删除可扮演角色「沈砺」吗?'),
).toBeTruthy();
await user.click(within(dialog).getByRole('button', { name: '取消' }));
expect(handleProfileChange).not.toHaveBeenCalled();
await user.click(screen.getAllByRole('button', { name: '删除' })[0]!);
await user.click(
screen.getByRole('button', {
name: '确认删除',
}),
);
expect(handleProfileChange).toHaveBeenCalledTimes(1);
expect(handleProfileChange.mock.calls[0]?.[0].playableNpcs).toHaveLength(1);
expect(handleProfileChange.mock.calls[0]?.[0].playableNpcs[0]?.name).toBe(
'闻潮',
);
});
test('最后一个可扮演角色不可删除时使用统一提示弹窗', async () => {
const user = userEvent.setup();
const handleProfileChange = vi.fn();
render(
<CustomWorldEntityCatalog
profile={createProfile()}
previewCharacters={[]}
activeTab="playable"
onActiveTabChange={() => {}}
onEditTarget={() => {}}
onProfileChange={handleProfileChange}
onDeleteStoryNpcs={() => {}}
onDeleteLandmarks={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: '删除' }));
const dialog = screen.getByRole('dialog', { name: '无法删除' });
expect(
within(dialog).getByText(
'至少保留一个可扮演角色,才能正常进入自定义世界。',
),
).toBeTruthy();
await user.click(within(dialog).getByRole('button', { name: '知道了' }));
expect(handleProfileChange).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog', { name: '无法删除' })).toBeNull();
});
test('批量删除场景角色使用统一确认弹窗', async () => {
const user = userEvent.setup();
const handleDeleteStoryNpcs = vi.fn();
render(
<CustomWorldEntityCatalog
profile={createProfileWithLandmark()}
previewCharacters={[]}
activeTab="story"
onActiveTabChange={() => {}}
onEditTarget={() => {}}
onProfileChange={() => {}}
onDeleteStoryNpcs={handleDeleteStoryNpcs}
onDeleteLandmarks={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: '批量删除' }));
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '删除选中' }));
const dialog = screen.getByRole('dialog', { name: '批量删除' });
expect(
within(dialog).getByText('确认批量删除 2 个场景角色吗?'),
).toBeTruthy();
expect(handleDeleteStoryNpcs).not.toHaveBeenCalled();
await user.click(within(dialog).getByRole('button', { name: '确认删除' }));
expect(handleDeleteStoryNpcs).toHaveBeenCalledWith(['story-1', 'story-2']);
expect(screen.queryByRole('button', { name: '删除选中' })).toBeNull();
});
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 waitFor(() => {
const generatedStatus = screen.getByText('已生成完毕,请保存后再退出页面');
expect(generatedStatus.className).toContain('platform-status-message');
expect(generatedStatus.className).toContain('text-emerald-50');
});
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/unsaved-scene.png',
assetId: 'asset-unsaved-1',
model: 'wan2.2-t2i-flash',
size: '1280*720',
taskId: 'task-unsaved-1',
prompt: '未保存的场景图',
});
const user = userEvent.setup();
render(<LandmarkEditorFlowHarness />);
await user.click(
within(getSceneActCard(0)).getByRole('button', { name: '配置背景' }),
);
await waitFor(() => {
expect(screen.getByText('配置幕背景第1幕')).toBeTruthy();
});
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);
});
const closeButtons = screen.getAllByRole('button', { name: '关闭' });
await user.click(closeButtons[closeButtons.length - 1]!);
const exitDialog = screen.getByRole('dialog', { name: '确认退出' });
expect(
within(exitDialog).getByText(
'当前生成画面还未保存,退出后将丢失这次生成结果,仍然退出吗?',
),
).toBeTruthy();
await user.click(
within(exitDialog).getByRole('button', { name: '继续编辑' }),
);
expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy();
const reopenedCloseButtons = screen.getAllByRole('button', { name: '关闭' });
await user.click(reopenedCloseButtons[reopenedCloseButtons.length - 1]!);
await user.click(screen.getByRole('button', { name: '仍然退出' }));
await waitFor(() => {
expect(screen.queryByText('智能生成:沉钟栈桥')).toBeNull();
});
});
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');
const campSecondActPreview = screen
.getByLabelText('潮灯居-第2幕预览')
.closest('div');
expect(campSecondActPreview?.className).toContain('platform-subpanel');
expect(campSecondActPreview?.className).toContain('rounded-xl');
expect(campSecondActPreview?.className).toContain('p-0');
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);
const currentBadge = screen.getByText('当前');
expect(currentBadge.className).toContain('rounded-full');
expect(currentBadge.className).toContain(
'bg-[var(--platform-subpanel-fill)]',
);
});
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();
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
render(<LandmarkEditorFlowHarness />);
await user.click(screen.getByText('北'));
const dialog = await screen.findByRole('dialog', { name: '提示' });
expect(
within(dialog).getByText('请先保留至少一个其他场景,才能配置连接关系。'),
).toBeTruthy();
expect(alertSpy).not.toHaveBeenCalled();
await user.click(within(dialog).getByRole('button', { name: '知道了' }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '提示' })).toBeNull();
});
alertSpy.mockRestore();
});
test('场景编辑器会在场景内展示槽位化多幕配置并保存', async () => {
const user = userEvent.setup();
render(<LandmarkEditorFlowHarness />);
expect(screen.getByText('多幕配置')).toBeTruthy();
const connectionPanel = screen.getByText('场景连接关系').closest('section');
expect(connectionPanel).toBeTruthy();
expect(connectionPanel?.className).toContain('bg-black/25');
expect(connectionPanel?.className).toContain('rounded-[1.25rem]');
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 presetFrame = presetImage.closest('.platform-media-frame');
const presetPanel = screen.getByText('预设背景').closest('section');
const presetButton = presetImage.closest('button');
expect(presetPanel?.className).toContain('bg-black/25');
expect(presetPanel?.className).toContain('rounded-[1.25rem]');
expect(presetFrame?.className).toContain('aspect-[16/9]');
expect(presetFrame?.className).toContain('rounded-none');
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();
});
const npcOption = screen.getByRole('button', { name: //u });
const selectBadge = within(npcOption).getByText('选择');
expect(npcOption).toBeTruthy();
expect(selectBadge.className).toContain('rounded-full');
expect(selectBadge.className).toContain('font-black');
expect(selectBadge.className).toContain('bg-black/20');
expect(selectBadge.className).toContain('text-zinc-400');
await user.click(npcOption);
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('作品封面来源状态复用暗色平台胶囊标签', () => {
render(<CoverEditorFlowHarness />);
const sourceBadge = screen.getByText('当前为默认封面');
expect(sourceBadge.className).toContain('rounded-full');
expect(sourceBadge.className).toContain('font-black');
expect(sourceBadge.className).toContain('bg-black/20');
});
test('场景保存缺少主角色时使用统一提示弹窗', async () => {
const user = userEvent.setup();
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
render(<LandmarkEditorNoNpcFlowHarness />);
await user.click(screen.getByRole('button', { name: //u }));
const dialog = await screen.findByRole('dialog', { name: '提示' });
expect(within(dialog).getByText('请至少为一幕配置主角色。')).toBeTruthy();
expect(alertSpy).not.toHaveBeenCalled();
expect(screen.getByText('编辑场景:空港栈桥')).toBeTruthy();
alertSpy.mockRestore();
});
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 uploadedCover = {
imageSrc: '/generated-custom-world-covers/world-1/uploaded/cover.webp',
assetId: 'custom-cover-upload-1',
sourceType: 'uploaded' as const,
};
let resolveUpload!: (value: typeof uploadedCover) => void;
const uploadPromise = new Promise<typeof uploadedCover>((resolve) => {
resolveUpload = resolve;
});
const uploadMock = vi
.mocked(customWorldCoverAssetService.uploadCustomWorldCoverImage)
.mockReturnValue(uploadPromise);
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();
});
const cropPreviewFrame = screen
.getByRole('img', { name: '上传封面裁剪预览' })
.closest('.platform-media-frame');
const cropResultFrame = screen
.getByRole('img', { name: '上传封面裁剪结果预览' })
.closest('.platform-media-frame');
expect(cropPreviewFrame?.className).toContain('bg-black/40');
expect(cropResultFrame?.className).toContain('aspect-[16/9]');
expect(
screen.getByRole('button', { name: '拖拽右下角裁剪边界' }),
).toBeTruthy();
expect(screen.queryByText('左右位置')).toBeNull();
expect(screen.queryByText('上下位置')).toBeNull();
await user.click(screen.getByRole('button', { name: '确认裁剪并上传' }));
const uploadSavingPanelClassName = findNearestClassName(
await screen.findByText('正在保存封面资源,请稍候。'),
'bg-sky-500/8',
);
expect(uploadSavingPanelClassName).toContain('border-sky-400/18');
expect(uploadSavingPanelClassName).toContain('rounded-[1rem]');
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);
resolveUpload(uploadedCover);
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',
);
});