Persist custom world asset configs in runtime snapshots
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
@@ -9,12 +10,32 @@ import type {
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
} from '../types';
|
||||
import { CustomWorldEntityEditorModal } from './CustomWorldEntityEditorModal';
|
||||
import { CustomWorldEntityCatalog } from './CustomWorldEntityCatalog';
|
||||
import {
|
||||
CustomWorldEntityEditorModal,
|
||||
type CustomWorldEditorTarget,
|
||||
} from './CustomWorldEntityEditorModal';
|
||||
|
||||
vi.mock('../data/characterPresets', async () => {
|
||||
const actual = await vi.importActual<typeof import('../data/characterPresets')>(
|
||||
'../data/characterPresets',
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
buildCustomWorldPlayableCharacters: vi.fn(() => []),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./CharacterAnimator', () => ({
|
||||
CharacterAnimator: () => <div>角色预览</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../services/aiService', () => ({
|
||||
generateCustomWorldSceneImage: vi.fn(),
|
||||
generateCustomWorldSceneNpc: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./CustomWorldNpcVisualEditor', () => ({
|
||||
CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
|
||||
<div>{npc.name}</div>
|
||||
@@ -136,6 +157,94 @@ function createProfile(): CustomWorldProfile {
|
||||
} 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 (
|
||||
<>
|
||||
<CustomWorldEntityCatalog
|
||||
profile={profile}
|
||||
previewCharacters={[]}
|
||||
activeTab="landmarks"
|
||||
onActiveTabChange={() => {}}
|
||||
onEditTarget={setTarget}
|
||||
onProfileChange={setProfile}
|
||||
onDeleteStoryNpcs={() => {}}
|
||||
onDeleteLandmarks={() => {}}
|
||||
/>
|
||||
<CustomWorldEntityEditorModal
|
||||
profile={profile}
|
||||
target={target}
|
||||
onClose={() => setTarget(null)}
|
||||
onProfileChange={setProfile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CampEditorFlowHarness() {
|
||||
const [profile, setProfile] = useState({
|
||||
...createProfileWithLandmark(),
|
||||
camp: {
|
||||
name: '潮灯居',
|
||||
description: '玩家最初落脚的旧灯塔内院。',
|
||||
dangerLevel: 'medium',
|
||||
imageSrc: '/generated-custom-world-scenes/original-camp.png',
|
||||
},
|
||||
});
|
||||
const [target, setTarget] = useState<CustomWorldEditorTarget | null>({
|
||||
kind: 'camp',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomWorldEntityCatalog
|
||||
profile={profile}
|
||||
previewCharacters={[]}
|
||||
activeTab="landmarks"
|
||||
onActiveTabChange={() => {}}
|
||||
onEditTarget={setTarget}
|
||||
onProfileChange={setProfile}
|
||||
onDeleteStoryNpcs={() => {}}
|
||||
onDeleteLandmarks={() => {}}
|
||||
/>
|
||||
<CustomWorldEntityEditorModal
|
||||
profile={profile}
|
||||
target={target}
|
||||
onClose={() => setTarget(null)}
|
||||
onProfileChange={setProfile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
test('playable角色打开AI工坊后不会自动关闭', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClose = vi.fn();
|
||||
@@ -179,3 +288,263 @@ test('场景角色打开AI工坊后不会自动关闭', async () => {
|
||||
|
||||
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',
|
||||
);
|
||||
|
||||
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: '场景图片' }).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/updated-scene.png',
|
||||
);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /保存修改/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: '沉钟栈桥' }).getAttribute('src')).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',
|
||||
);
|
||||
|
||||
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: '场景图片' }).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/updated-camp.png',
|
||||
);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /保存修改/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: '潮灯居' }).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/updated-camp.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user