Files
Genarrative/src/components/CustomWorldEntityEditorModal.test.tsx
高物 75944b1f1f
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-20 21:06:48 +08:00

937 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @vitest-environment jsdom */
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { expect, test, vi } from 'vitest';
import type {
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
} from '../types';
import { CustomWorldEntityCatalog } from './CustomWorldEntityCatalog';
import {
type CustomWorldEditorTarget,
CustomWorldEntityEditorModal,
} from './CustomWorldEntityEditorModal';
import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService';
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/aiService', () => ({
generateCustomWorldSceneImage: vi.fn(),
generateCustomWorldSceneNpc: vi.fn(),
generateInitialStory: vi.fn(),
generateNextStep: vi.fn(),
}));
vi.mock('./CustomWorldNpcVisualEditor', () => ({
CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
<div>{npc.name}</div>
),
CustomWorldNpcVisualEditor: () => <div></div>,
}));
vi.mock('./game-shell/GameShellRuntime', () => ({
GameShellRuntime: ({
session,
}: {
session: { gameState: { currentScenePreset?: { name?: string } | null } };
}) => (
<div>
<div></div>
<div>{session.gameState.currentScenePreset?.name ?? '未进入场景'}</div>
</div>
),
}));
vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({
fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }),
saveCharacterWorkflowCache: vi.fn().mockResolvedValue(undefined),
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: [],
templateCharacterId: 'knight-female-1',
};
}
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: [],
},
playableNpcs: [createPlayableRole('playable-1', '沈砺')],
storyNpcs: [createStoryRole('story-1', '顾潮音')],
items: [],
camp: {
name: '潮灯居',
description: '玩家最初落脚的旧灯塔内院。',
dangerLevel: 'medium',
},
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', '谢孤灯'),
],
landmarks: [
{
id: 'landmark-1',
name: '沉钟栈桥',
description: '旧钟与潮声常年相撞的码头栈桥。',
dangerLevel: 'medium',
imageSrc: '/generated-custom-world-scenes/original-scene.png',
sceneNpcIds: ['story-1', 'story-2', 'story-3'],
connections: [],
},
],
} as unknown as CustomWorldProfile;
}
function LandmarkEditorFlowHarness() {
const [profile, setProfile] = useState(createProfileWithLandmark());
const [target, setTarget] = useState<CustomWorldEditorTarget | 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={() => {}}
/>
<CustomWorldEntityEditorModal
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: '玩家最初落脚的旧灯塔内院。',
dangerLevel: 'medium',
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<CustomWorldEditorTarget | 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={() => {}}
/>
<CustomWorldEntityEditorModal
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<CustomWorldEditorTarget | null>({
kind: 'cover',
});
return (
<>
<pre data-testid="cover-profile-json" className="hidden">
{JSON.stringify(profile)}
</pre>
<CustomWorldEntityEditorModal
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(
<CustomWorldEntityEditorModal
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(
<CustomWorldEntityEditorModal
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(
<CustomWorldEntityEditorModal
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(
<CustomWorldEntityEditorModal
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(
<CustomWorldEntityEditorModal
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(
<CustomWorldEntityEditorModal
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="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 () => {
const aiService = await import('../services/aiService');
vi.mocked(aiService.generateCustomWorldSceneImage).mockClear();
vi.mocked(aiService.generateCustomWorldSceneImage).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(aiService.generateCustomWorldSceneImage).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',
);
});
test('开局场景图片保存后会同步更新编辑页和场景列表', async () => {
const aiService = await import('../services/aiService');
vi.mocked(aiService.generateCustomWorldSceneImage).mockClear();
vi.mocked(aiService.generateCustomWorldSceneImage).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(aiService.generateCustomWorldSceneImage).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',
);
});
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).toHaveLength(3);
expect(savedProfile.camp?.sceneNpcIds).toEqual(
expect.arrayContaining(['story-1', 'story-2', 'story-3']),
);
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?.linkedLandmarkIds).toContain('custom-scene-camp');
});
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('场景幕预览会打开当前幕运行时面板', 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);
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',
);
});