1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 15:45:14 +08:00
parent 8a7bd90458
commit 1c72066bab
73 changed files with 7814 additions and 1018 deletions

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
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';
@@ -12,8 +12,8 @@ import type {
} from '../types';
import { CustomWorldEntityCatalog } from './CustomWorldEntityCatalog';
import {
CustomWorldEntityEditorModal,
type CustomWorldEditorTarget,
CustomWorldEntityEditorModal,
} from './CustomWorldEntityEditorModal';
vi.mock('../data/characterPresets', async () => {
@@ -24,6 +24,11 @@ vi.mock('../data/characterPresets', async () => {
return {
...actual,
buildCustomWorldPlayableCharacters: vi.fn(() => []),
buildCustomWorldRuntimeCharacters: vi.fn(() => []),
createCharacterSkillCooldowns: vi.fn(() => ({})),
getCharacterMaxHp: vi.fn(() => 180),
getCharacterMaxMana: vi.fn(() => 60),
setRuntimeCharacterOverrides: vi.fn(),
};
});
@@ -34,6 +39,8 @@ vi.mock('./CharacterAnimator', () => ({
vi.mock('../services/aiService', () => ({
generateCustomWorldSceneImage: vi.fn(),
generateCustomWorldSceneNpc: vi.fn(),
generateInitialStory: vi.fn(),
generateNextStep: vi.fn(),
}));
vi.mock('./CustomWorldNpcVisualEditor', () => ({
@@ -43,6 +50,19 @@ vi.mock('./CustomWorldNpcVisualEditor', () => ({
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 }),
generateCharacterPromptBundle: vi.fn().mockResolvedValue({
@@ -138,7 +158,19 @@ function createProfile(): CustomWorldProfile {
templateWorldType: 'WUXIA',
majorFactions: ['守潮盟', '沉钟会'],
coreConflicts: ['旧航道归属', '沉钟遗产争夺'],
attributeSchema: {},
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: [],
@@ -189,6 +221,9 @@ function LandmarkEditorFlowHarness() {
return (
<>
<pre data-testid="landmark-profile-json" className="hidden">
{JSON.stringify(profile)}
</pre>
<CustomWorldEntityCatalog
profile={profile}
previewCharacters={[]}
@@ -209,6 +244,19 @@ function LandmarkEditorFlowHarness() {
);
}
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(),
@@ -548,3 +596,128 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async
);
});
});
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: /[\s\S]*/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: /[\s\S]*/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();
});
});