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