@@ -15,6 +15,7 @@ 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')>(
|
||||
@@ -65,10 +66,6 @@ vi.mock('./game-shell/GameShellRuntime', () => ({
|
||||
|
||||
vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({
|
||||
fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }),
|
||||
generateCharacterPromptBundle: vi.fn().mockResolvedValue({
|
||||
visualPromptText: '自动生成的形象提示词',
|
||||
animationPromptText: '自动生成的动作提示词',
|
||||
}),
|
||||
saveCharacterWorkflowCache: vi.fn().mockResolvedValue(undefined),
|
||||
generateCharacterVisualCandidates: vi.fn(),
|
||||
publishCharacterVisualAsset: vi.fn(),
|
||||
@@ -76,6 +73,11 @@ vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({
|
||||
publishCharacterAnimationAssets: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/customWorldCoverAssetService', () => ({
|
||||
generateCustomWorldCoverImage: vi.fn(),
|
||||
uploadCustomWorldCoverImage: vi.fn(),
|
||||
}));
|
||||
|
||||
function createBackstoryReveal() {
|
||||
return {
|
||||
publicSummary: '公开背景',
|
||||
@@ -261,10 +263,19 @@ 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>({
|
||||
@@ -273,6 +284,9 @@ function CampEditorFlowHarness() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<pre data-testid="camp-profile-json" className="hidden">
|
||||
{JSON.stringify(profile)}
|
||||
</pre>
|
||||
<CustomWorldEntityCatalog
|
||||
profile={profile}
|
||||
previewCharacters={[]}
|
||||
@@ -293,6 +307,44 @@ function CampEditorFlowHarness() {
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -506,6 +558,14 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () =>
|
||||
'/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();
|
||||
@@ -523,22 +583,29 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () =>
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: '场景图片' }).getAttribute('src')).toBe(
|
||||
expect(screen.getByRole('img', { name: '第1幕背景预览' }).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/updated-scene.png',
|
||||
);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /保存修改/u }));
|
||||
await user.click(screen.getByRole('button', { name: '保存背景' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull();
|
||||
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 () => {
|
||||
@@ -562,6 +629,14 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async
|
||||
'/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();
|
||||
@@ -579,22 +654,80 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: '场景图片' }).getAttribute('src')).toBe(
|
||||
expect(screen.getByRole('img', { name: '第1幕背景预览' }).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/updated-camp.png',
|
||||
);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /保存修改/u }));
|
||||
await user.click(screen.getByRole('button', { name: '保存背景' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull();
|
||||
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 () => {
|
||||
@@ -636,7 +769,7 @@ test('场景编辑器会在场景内展示槽位化多幕配置并保存', async
|
||||
expect(screen.getByText('配置角色:第1幕 · 主角色槽位')).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /闻雪汀[\s\S]*选择/u }));
|
||||
await user.click(screen.getByRole('button', { name: /闻雪汀/u }));
|
||||
await user.click(screen.getByRole('button', { name: '保存角色' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -679,7 +812,7 @@ test('场景多幕支持新增删除和调序', async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('配置角色:第2幕 · 主角色槽位')).toBeTruthy();
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /谢孤灯[\s\S]*选择/u }));
|
||||
await user.click(screen.getByRole('button', { name: /谢孤灯/u }));
|
||||
await user.click(screen.getByRole('button', { name: '保存角色' }));
|
||||
await user.click(within(secondActCard).getByRole('button', { name: '下移' }));
|
||||
|
||||
@@ -721,3 +854,83 @@ test('场景幕预览会打开当前幕运行时面板', async () => {
|
||||
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',
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user