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