新增获取项目剧本数据的API接口,并在剧本服务Hook中实现相应的功能;优化剧本编辑界面,支持剧本片段的动态加载和用户提示词的更新;更新相关测试用例以覆盖新功能。

This commit is contained in:
海龙 2025-07-31 20:19:58 +08:00
parent e42f5269ca
commit 4cd9a371ff
7 changed files with 1472 additions and 332 deletions

View File

@ -593,3 +593,22 @@ export const applyScriptToShot = async (request: {
}): Promise<ApiResponse<any>> => {
return post<ApiResponse<any>>('/movie/apply_script_to_shot', request);
};
/**
*
* @param request
*/
export const getProjectScript = async (request: {
/** 项目ID */
projectId: string;
}): Promise<ApiResponse<{
/** 用户提示词 */
prompt: string;
/** 生成的剧本文本 */
scriptText: string;
}>> => {
return post<ApiResponse<{
prompt: string;
scriptText: string;
}>>('/movie/get_project_script', request);
};

View File

@ -1,6 +1,7 @@
import { useState, useCallback, useMemo } from "react";
import { ScriptSlice } from "../domain/valueObject";
import { ScriptEditUseCase } from "../usecase/ScriptEditUseCase";
import { getProjectScript } from "../../../api/video_flow";
/**
* Hook接口
@ -28,6 +29,8 @@ export interface UseScriptService {
// 操作方法
/** 获取剧本数据(用户提示词) */
fetchScriptData: (prompt: string) => Promise<void>;
/** 根据项目ID获取已存在的剧本数据 */
fetchProjectScript: (projectId: string) => Promise<void>;
/** 设置当前聚焦的剧本片段 */
setFocusedSlice: (sliceId: string) => void;
/** 清除聚焦状态 */
@ -123,6 +126,58 @@ export const useScriptService = (): UseScriptService => {
}
}, [initialScriptText]);
/**
* ID获取已存在的剧本数据
* @param projectId ID
*/
const fetchProjectScript = useCallback(async (projectId: string): Promise<void> => {
try {
setLoading(true);
setError(null);
// 清空当前状态
setScriptText("");
setScriptSlices([]);
setFocusedSliceId("");
setScriptSliceText("");
// 调用API获取项目剧本数据
const response = await getProjectScript({ projectId });
if (!response.successful) {
throw new Error(response.message || '获取项目剧本失败');
}
const { prompt, scriptText } = response.data;
// 更新用户提示词状态
setUserPrompt(prompt);
// 保存初始提示词(只在第一次获取时保存)
if (!initialScriptText) {
setInitialScriptText(prompt);
}
// 创建新的剧本编辑用例并初始化数据
const newScriptEditUseCase = new ScriptEditUseCase(scriptText);
setScriptEditUseCase(newScriptEditUseCase);
// 设置剧本文本
setScriptText(scriptText);
// 从UseCase获取解析后的剧本片段
const scriptSlices = newScriptEditUseCase.getScriptSlices();
setScriptSlices(scriptSlices);
} catch (error) {
console.error('获取项目剧本数据失败:', error);
setError(error instanceof Error ? error.message : '获取项目剧本数据失败');
throw error;
} finally {
setLoading(false);
}
}, [initialScriptText]);
/**
@ -278,6 +333,7 @@ export const useScriptService = (): UseScriptService => {
// 操作方法
fetchScriptData,
fetchProjectScript,
setFocusedSlice,
clearFocusedSlice,
updateScriptSliceText,

View File

@ -70,7 +70,7 @@ describe('RoleService 业务逻辑测试', () => {
id: 'shot1',
name: '分镜1',
sketchUrl: 'http://example.com/sketch1.jpg',
videoUrl: 'http://example.com/video1.mp4',
videoUrl: ['http://example.com/video1.mp4'],
roleList: [],
sceneList: [],
status: ShotStatus.sketchLoading,
@ -254,140 +254,120 @@ describe('RoleService 业务逻辑测试', () => {
});
});
describe('重新获取角色数据测试', () => {
it('应该成功重新获取角色数据并更新实体', async () => {
const mockTextEntity = {
id: 'text1',
content: '更新后的AI文本',
updatedAt: Date.now(),
loadingProgress: 100,
disableEdit: false,
};
describe('角色业务流程测试', () => {
it('应该完成完整的角色编辑流程:获取列表→选择角色→修改提示词→智能优化→修改标签→重新生成→角色库替换→应用角色', async () => {
// 1. 用户操作:获取角色列表
const mockRoles = [mockRoleEntity];
(getRoleList as jest.Mock).mockResolvedValue({
successful: true,
data: mockRoles,
message: 'success',
});
const mockTags = [
{
id: 'tag1',
name: '更新标签1',
content: '更新内容1',
updatedAt: Date.now(),
loadingProgress: 100,
disableEdit: false,
},
{
id: 'tag2',
name: '更新标签2',
content: '更新内容2',
updatedAt: Date.now(),
loadingProgress: 100,
disableEdit: false,
}
];
const roleListResult = await getRoleList({ projectId: 'project1' });
expect(roleListResult.successful).toBe(true);
expect(roleListResult.data).toEqual(mockRoles);
// 2. 用户操作:选择角色并获取角色数据
(getRoleData as jest.Mock).mockResolvedValue({
successful: true,
data: {
text: mockTextEntity,
tags: mockTags,
tags: [mockTagEntity1],
},
});
// 模拟RoleItem的setEntity方法
const mockSetEntity = jest.fn();
(RoleItem as jest.MockedClass<typeof RoleItem>).mockImplementation((entity) => ({
entity,
metadata: {},
disableEdit: entity.disableEdit,
type: 1,
setEntity: mockSetEntity,
} as any));
const roleDataResult = await getRoleData({ roleId: 'role1' });
expect(roleDataResult.successful).toBe(true);
expect(roleDataResult.data.text).toEqual(mockTextEntity);
expect(roleDataResult.data.tags).toEqual([mockTagEntity1]);
const roleItem = new RoleItem(mockRoleEntity);
const useCase = new RoleEditUseCase(roleItem);
const result = await useCase.refreshRoleData();
expect(getRoleData).toHaveBeenCalledWith({ roleId: 'role1' });
expect(result.text).toEqual(mockTextEntity);
expect(result.tags).toEqual(mockTags);
expect(mockSetEntity).toHaveBeenCalledWith(expect.objectContaining({
generateTextId: 'text1',
tagIds: ['tag1', 'tag2'],
updatedAt: expect.any(Number),
}));
});
it('角色ID不存在时应该抛出错误', async () => {
const emptyRoleEntity = { ...mockRoleEntity, id: '' };
const roleItem = new RoleItem(emptyRoleEntity);
const useCase = new RoleEditUseCase(roleItem);
await expect(useCase.refreshRoleData()).rejects.toThrow('角色ID不存在无法获取角色数据');
});
it('API调用失败时应该抛出错误', async () => {
(getRoleData as jest.Mock).mockResolvedValue({
successful: false,
message: '获取失败',
// 3. 用户操作:修改角色提示词
const updatedTextEntity = { ...mockTextEntity, content: '修改后的角色提示词' };
(updateText as jest.Mock).mockResolvedValue({
successful: true,
data: updatedTextEntity,
});
const roleItem = new RoleItem(mockRoleEntity);
const useCase = new RoleEditUseCase(roleItem);
const updateTextResult = await updateText({
textId: 'text1',
content: '修改后的角色提示词'
});
expect(updateTextResult.successful).toBe(true);
expect(updateTextResult.data.content).toBe('修改后的角色提示词');
await expect(useCase.refreshRoleData()).rejects.toThrow('获取角色数据失败: 获取失败');
});
});
// 4. 用户操作:智能优化文本
const optimizedContent = '智能优化后的角色文本';
mockTextEditUseCase.getOptimizedContent.mockResolvedValue(optimizedContent);
mockTextEditUseCase.updateText.mockResolvedValue({
entity: { ...mockTextEntity, content: optimizedContent },
metadata: {},
disableEdit: false,
type: 0,
} as any);
describe('角色形象库选取使用测试', () => {
it('应该成功获取用户角色库', async () => {
const mockLibraryRoles = [mockRoleEntity];
const optimizedContentResult = await mockTextEditUseCase.getOptimizedContent();
expect(optimizedContentResult).toBe(optimizedContent);
// 5. 用户操作:修改标签
const updatedTagEntity = { ...mockTagEntity1, content: '修改后的标签内容' };
(updateTag as jest.Mock).mockResolvedValue({
successful: true,
data: updatedTagEntity,
});
const updateTagResult = await updateTag({
tagId: 'tag1',
content: '修改后的标签内容'
});
expect(updateTagResult.successful).toBe(true);
expect(updateTagResult.data.content).toBe('修改后的标签内容');
// 6. 用户操作:使用新的提示词和标签重新生成角色
const newRoleEntity = { ...mockRoleEntity, id: 'role2', name: '重新生成的角色' };
(regenerateRole as jest.Mock).mockResolvedValue({
successful: true,
data: newRoleEntity,
});
mockRoleEditUseCase.AIgenerateRole.mockResolvedValue(newRoleEntity);
const regenerateResult = await regenerateRole({
prompt: '使用新的提示词重新生成角色',
tagTypes: ['tag1'],
roleId: 'role1'
});
expect(regenerateResult.successful).toBe(true);
expect(regenerateResult.data.name).toBe('重新生成的角色');
// 7. 用户操作:获取角色库
const mockLibraryRoles = [
{ ...mockRoleEntity, id: 'libraryRole1', name: '库角色1' },
{ ...mockRoleEntity, id: 'libraryRole2', name: '库角色2' }
];
(getUserRoleLibrary as jest.Mock).mockResolvedValue({
successful: true,
data: mockLibraryRoles,
});
const result = await getUserRoleLibrary();
const libraryResult = await getUserRoleLibrary();
expect(libraryResult.successful).toBe(true);
expect(libraryResult.data).toEqual(mockLibraryRoles);
expect(getUserRoleLibrary).toHaveBeenCalled();
expect(result.successful).toBe(true);
expect(result.data).toEqual(mockLibraryRoles);
});
it('应该成功替换角色', async () => {
// 8. 用户操作:从角色库中选择角色替换当前角色
const selectedLibraryRole = mockLibraryRoles[0];
(replaceRole as jest.Mock).mockResolvedValue({
successful: true,
data: { success: true },
});
const result = await replaceRole({
const replaceResult = await replaceRole({
currentRoleId: 'role1',
replaceRoleId: 'newRoleId'
replaceRoleId: 'libraryRole1'
});
expect(replaceResult.successful).toBe(true);
expect(replaceRole).toHaveBeenCalledWith({
currentRoleId: 'role1',
replaceRoleId: 'newRoleId'
});
expect(result.successful).toBe(true);
});
it('替换角色失败时应该返回错误信息', async () => {
(replaceRole as jest.Mock).mockResolvedValue({
successful: false,
message: '替换失败',
});
const result = await replaceRole({
currentRoleId: 'role1',
replaceRoleId: 'newRoleId'
});
expect(result.successful).toBe(false);
expect(result.message).toBe('替换失败');
});
});
describe('角色形象应用到多个分镜测试', () => {
it('应该成功获取角色分镜列表', async () => {
// 9. 用户操作:获取角色应用到的分镜列表
const mockShots = [mockShotEntity];
(getRoleShots as jest.Mock).mockResolvedValue({
successful: true,
@ -397,15 +377,11 @@ describe('RoleService 业务逻辑测试', () => {
},
});
const result = await getRoleShots({ roleId: 'role1' });
const shotsResult = await getRoleShots({ roleId: 'role1' });
expect(shotsResult.successful).toBe(true);
expect(shotsResult.data.shots).toEqual(mockShots);
expect(getRoleShots).toHaveBeenCalledWith({ roleId: 'role1' });
expect(result.successful).toBe(true);
expect(result.data.shots).toEqual(mockShots);
expect(result.data.appliedShotIds).toEqual([]);
});
it('应该成功应用角色到选中的分镜', async () => {
// 10. 用户操作:选择分镜并应用角色
(applyRoleToShots as jest.Mock).mockResolvedValue({
successful: true,
data: { success: true },
@ -413,31 +389,360 @@ describe('RoleService 业务逻辑测试', () => {
mockRoleEditUseCase.applyRole.mockResolvedValue({} as any);
const result = await applyRoleToShots({
const applyResult = await applyRoleToShots({
roleId: 'role1',
shotIds: ['shot1', 'shot2']
});
expect(applyRoleToShots).toHaveBeenCalledWith({
roleId: 'role1',
shotIds: ['shot1', 'shot2']
});
expect(result.successful).toBe(true);
expect(applyResult.successful).toBe(true);
});
it('应用角色失败时应该返回错误信息', async () => {
(applyRoleToShots as jest.Mock).mockResolvedValue({
successful: false,
message: '应用失败',
it('应该验证角色库替换时只保留ID其他数据都被替换', async () => {
// 当前角色
const currentRole = {
id: 'currentRoleId',
name: '当前角色',
generateTextId: 'currentTextId',
tagIds: ['currentTag1'],
imageUrl: 'http://example.com/current.jpg',
updatedAt: Date.now(),
loadingProgress: 100,
disableEdit: false,
isStored: false,
};
// 角色库中的角色
const libraryRole = {
id: 'libraryRoleId',
name: '库角色',
generateTextId: 'libraryTextId',
tagIds: ['libraryTag1', 'libraryTag2'],
imageUrl: 'http://example.com/library.jpg',
updatedAt: Date.now() + 1000,
loadingProgress: 100,
disableEdit: false,
isStored: true,
};
// 模拟替换操作
(replaceRole as jest.Mock).mockResolvedValue({
successful: true,
data: { success: true },
});
const result = await applyRoleToShots({
const replaceResult = await replaceRole({
currentRoleId: currentRole.id,
replaceRoleId: libraryRole.id
});
expect(replaceResult.successful).toBe(true);
// 验证替换后的角色数据ID保持不变其他数据来自库角色
const replacedRole = {
...libraryRole,
id: currentRole.id, // 只有ID保持不变
};
expect(replacedRole.id).toBe(currentRole.id); // ID不变
expect(replacedRole.name).toBe(libraryRole.name); // 其他数据来自库角色
expect(replacedRole.generateTextId).toBe(libraryRole.generateTextId);
expect(replacedRole.tagIds).toEqual(libraryRole.tagIds);
expect(replacedRole.imageUrl).toBe(libraryRole.imageUrl);
expect(replacedRole.isStored).toBe(libraryRole.isStored);
});
it('应该模拟用户选择角色并修改提示词的完整流程', async () => {
// 用户操作:获取项目中的角色列表
const mockRoles = [
{ ...mockRoleEntity, id: 'role1', name: '角色1' },
{ ...mockRoleEntity, id: 'role2', name: '角色2' }
];
(getRoleList as jest.Mock).mockResolvedValue({
successful: true,
data: mockRoles,
});
const roleList = await getRoleList({ projectId: 'project1' });
expect(roleList.data).toHaveLength(2);
// 用户操作:选择第一个角色
const selectedRoleId = 'role1';
(getRoleData as jest.Mock).mockResolvedValue({
successful: true,
data: {
text: mockTextEntity,
tags: [mockTagEntity1],
},
});
const selectedRoleData = await getRoleData({ roleId: selectedRoleId });
expect(selectedRoleData.data.text.id).toBe('text1');
// 用户操作:修改角色提示词
const newPrompt = '我想要一个更加勇敢的角色';
(updateText as jest.Mock).mockResolvedValue({
successful: true,
data: { ...mockTextEntity, content: newPrompt },
});
const updatedText = await updateText({
textId: 'text1',
content: newPrompt
});
expect(updatedText.data.content).toBe(newPrompt);
// 用户操作:使用新提示词重新生成角色
const regeneratedRole = { ...mockRoleEntity, name: '勇敢的角色' };
(regenerateRole as jest.Mock).mockResolvedValue({
successful: true,
data: regeneratedRole,
});
const regenerationResult = await regenerateRole({
prompt: newPrompt,
tagTypes: ['tag1'],
roleId: selectedRoleId
});
expect(regenerationResult.data.name).toBe('勇敢的角色');
});
it('应该处理角色编辑流程中的错误情况', async () => {
// 模拟获取角色列表失败
(getRoleList as jest.Mock).mockResolvedValue({
successful: false,
message: '获取角色列表失败',
});
const roleListResult = await getRoleList({ projectId: 'project1' });
expect(roleListResult.successful).toBe(false);
expect(roleListResult.message).toBe('获取角色列表失败');
// 模拟修改文本失败
(updateText as jest.Mock).mockResolvedValue({
successful: false,
message: '修改文本失败',
});
const updateTextResult = await updateText({
textId: 'text1',
content: '新的文本内容'
});
expect(updateTextResult.successful).toBe(false);
expect(updateTextResult.message).toBe('修改文本失败');
// 模拟重新生成角色失败
(regenerateRole as jest.Mock).mockResolvedValue({
successful: false,
message: '重新生成角色失败',
});
const regenerateResult = await regenerateRole({
prompt: '重新生成角色',
tagTypes: ['tag1'],
roleId: 'role1'
});
expect(regenerateResult.successful).toBe(false);
expect(regenerateResult.message).toBe('重新生成角色失败');
// 模拟替换角色失败
(replaceRole as jest.Mock).mockResolvedValue({
successful: false,
message: '替换角色失败',
});
const replaceResult = await replaceRole({
currentRoleId: 'role1',
replaceRoleId: 'newRoleId'
});
expect(replaceResult.successful).toBe(false);
expect(replaceResult.message).toBe('替换角色失败');
// 模拟应用角色失败
(applyRoleToShots as jest.Mock).mockResolvedValue({
successful: false,
message: '应用角色失败',
});
const applyResult = await applyRoleToShots({
roleId: 'role1',
shotIds: ['shot1']
});
expect(applyResult.successful).toBe(false);
expect(applyResult.message).toBe('应用角色失败');
});
});
expect(result.successful).toBe(false);
expect(result.message).toBe('应用失败');
describe('角色库业务流程测试', () => {
it('应该完成角色库选择和使用流程:获取角色库→选择角色→替换当前角色→验证数据替换', async () => {
// 1. 用户操作:获取用户角色库
const mockLibraryRoles = [
{ ...mockRoleEntity, id: 'libraryRole1', name: '库角色1', isStored: true },
{ ...mockRoleEntity, id: 'libraryRole2', name: '库角色2', isStored: true }
];
(getUserRoleLibrary as jest.Mock).mockResolvedValue({
successful: true,
data: mockLibraryRoles,
});
const libraryResult = await getUserRoleLibrary();
expect(libraryResult.successful).toBe(true);
expect(libraryResult.data).toEqual(mockLibraryRoles);
// 2. 用户操作:从角色库中选择角色
const selectedLibraryRole = mockLibraryRoles[0];
expect(selectedLibraryRole.isStored).toBe(true); // 验证是库中的角色
// 3. 用户操作:替换当前角色
(replaceRole as jest.Mock).mockResolvedValue({
successful: true,
data: { success: true },
});
const replaceResult = await replaceRole({
currentRoleId: 'role1',
replaceRoleId: selectedLibraryRole.id
});
expect(replaceResult.successful).toBe(true);
// 4. 验证替换后的角色数据ID保持不变其他数据来自库角色
const currentRoleId = 'role1';
const replacedRole = {
...selectedLibraryRole,
id: currentRoleId, // 只有ID保持不变
};
expect(replacedRole.id).toBe(currentRoleId); // ID不变
expect(replacedRole.name).toBe(selectedLibraryRole.name); // 其他数据来自库角色
expect(replacedRole.generateTextId).toBe(selectedLibraryRole.generateTextId);
expect(replacedRole.tagIds).toEqual(selectedLibraryRole.tagIds);
expect(replacedRole.imageUrl).toBe(selectedLibraryRole.imageUrl);
expect(replacedRole.isStored).toBe(selectedLibraryRole.isStored);
});
it('应该验证角色库替换的边界情况', async () => {
// 测试空角色库
(getUserRoleLibrary as jest.Mock).mockResolvedValue({
successful: true,
data: [],
});
const emptyLibraryResult = await getUserRoleLibrary();
expect(emptyLibraryResult.data).toHaveLength(0);
// 测试替换不存在的角色
(replaceRole as jest.Mock).mockResolvedValue({
successful: false,
message: '角色不存在',
});
const invalidReplaceResult = await replaceRole({
currentRoleId: 'nonexistentRole',
replaceRoleId: 'libraryRole1'
});
expect(invalidReplaceResult.successful).toBe(false);
expect(invalidReplaceResult.message).toBe('角色不存在');
});
});
describe('角色应用到分镜业务流程测试', () => {
it('应该完成角色应用到分镜的完整流程:获取分镜列表→选择分镜→应用角色→验证应用状态', async () => {
// 1. 用户操作:获取角色应用到的分镜列表
const mockShots = [
{ ...mockShotEntity, id: 'shot1', name: '分镜1' },
{ ...mockShotEntity, id: 'shot2', name: '分镜2' },
{ ...mockShotEntity, id: 'shot3', name: '分镜3' }
];
(getRoleShots as jest.Mock).mockResolvedValue({
successful: true,
data: {
shots: mockShots,
appliedShotIds: ['shot1'], // 分镜1已经应用了角色
},
});
const shotsResult = await getRoleShots({ roleId: 'role1' });
expect(shotsResult.successful).toBe(true);
expect(shotsResult.data.shots).toEqual(mockShots);
expect(shotsResult.data.appliedShotIds).toEqual(['shot1']);
// 2. 用户操作:选择未应用的分镜
const unappliedShots = mockShots.filter(shot => !shotsResult.data.appliedShotIds.includes(shot.id));
expect(unappliedShots).toHaveLength(2); // shot2, shot3 未应用
// 3. 用户操作:应用角色到选中的分镜
const selectedShotIds = ['shot2', 'shot3'];
(applyRoleToShots as jest.Mock).mockResolvedValue({
successful: true,
data: { success: true },
});
mockRoleEditUseCase.applyRole.mockResolvedValue({} as any);
const applyResult = await applyRoleToShots({
roleId: 'role1',
shotIds: selectedShotIds
});
expect(applyResult.successful).toBe(true);
// 4. 验证应用后的状态
const updatedAppliedShotIds = ['shot1', 'shot2', 'shot3']; // 所有分镜都已应用
expect(updatedAppliedShotIds).toContain('shot1'); // 原来已应用的
expect(updatedAppliedShotIds).toContain('shot2'); // 新应用的
expect(updatedAppliedShotIds).toContain('shot3'); // 新应用的
});
it('应该处理分镜应用的各种情况', async () => {
// 测试应用空分镜列表
(applyRoleToShots as jest.Mock).mockResolvedValue({
successful: true,
data: { success: true },
});
const emptyApplyResult = await applyRoleToShots({
roleId: 'role1',
shotIds: []
});
expect(emptyApplyResult.successful).toBe(true);
// 测试应用单个分镜
const singleApplyResult = await applyRoleToShots({
roleId: 'role1',
shotIds: ['shot1']
});
expect(singleApplyResult.successful).toBe(true);
// 测试应用多个分镜
const multipleApplyResult = await applyRoleToShots({
roleId: 'role1',
shotIds: ['shot1', 'shot2', 'shot3']
});
expect(multipleApplyResult.successful).toBe(true);
});
it('应该处理分镜应用失败的情况', async () => {
// 测试应用失败
(applyRoleToShots as jest.Mock).mockResolvedValue({
successful: false,
message: '应用角色失败',
});
const failedApplyResult = await applyRoleToShots({
roleId: 'role1',
shotIds: ['shot1']
});
expect(failedApplyResult.successful).toBe(false);
expect(failedApplyResult.message).toBe('应用角色失败');
// 测试部分分镜应用失败
(applyRoleToShots as jest.Mock).mockResolvedValue({
successful: false,
message: '部分分镜应用失败',
});
const partialFailedResult = await applyRoleToShots({
roleId: 'role1',
shotIds: ['shot1', 'shot2']
});
expect(partialFailedResult.successful).toBe(false);
expect(partialFailedResult.message).toBe('部分分镜应用失败');
});
});

View File

@ -75,7 +75,7 @@ describe('SceneService 业务逻辑测试', () => {
id: 'shot1',
name: '分镜1',
sketchUrl: 'http://example.com/sketch1.jpg',
videoUrl: 'http://example.com/video1.mp4',
videoUrl: ['http://example.com/video1.mp4'],
roleList: [],
sceneList: [],
content: [],
@ -300,86 +300,226 @@ describe('SceneService 业务逻辑测试', () => {
});
});
describe('重新获取场景数据测试', () => {
it('应该成功重新获取场景数据并更新实体', async () => {
const mockTextEntity = {
id: 'text1',
content: '更新后的场景AI文本',
updatedAt: Date.now(),
loadingProgress: 100,
disableEdit: false,
};
describe('场景业务流程测试', () => {
it('应该完成完整的场景编辑流程:获取列表→选择场景→修改提示词→智能优化→修改标签→重新生成→应用场景', async () => {
// 模拟用户操作:获取场景列表
const mockScenes = [mockSceneEntity];
(getSceneList as jest.Mock).mockResolvedValue({
successful: true,
data: mockScenes,
message: 'success',
});
const mockTags = [
{
id: 'tag1',
name: '更新场景标签1',
content: '更新场景内容1',
updatedAt: Date.now(),
loadingProgress: 100,
disableEdit: false,
},
{
id: 'tag2',
name: '更新场景标签2',
content: '更新场景内容2',
updatedAt: Date.now(),
loadingProgress: 100,
disableEdit: false,
}
];
const sceneListResult = await getSceneList({ projectId: 'project1' });
expect(sceneListResult.successful).toBe(true);
expect(sceneListResult.data).toEqual(mockScenes);
// 模拟用户操作:选择场景并获取场景数据
(getSceneData as jest.Mock).mockResolvedValue({
successful: true,
data: {
text: mockTextEntity,
tags: mockTags,
tags: [mockTagEntity1, mockTagEntity2],
},
});
// 模拟SceneItem的setEntity方法
const mockSetEntity = jest.fn();
(SceneItem as jest.MockedClass<typeof SceneItem>).mockImplementation((entity) => ({
entity,
metadata: {},
disableEdit: entity.disableEdit,
type: 3,
setEntity: mockSetEntity,
} as any));
const sceneDataResult = await getSceneData({ sceneId: 'scene1' });
expect(sceneDataResult.successful).toBe(true);
expect(sceneDataResult.data.text).toEqual(mockTextEntity);
expect(sceneDataResult.data.tags).toEqual([mockTagEntity1, mockTagEntity2]);
const sceneItem = new SceneItem(mockSceneEntity);
const useCase = new SceneEditUseCase(sceneItem);
const result = await useCase.refreshSceneData();
expect(getSceneData).toHaveBeenCalledWith({ sceneId: 'scene1' });
expect(result.text).toEqual(mockTextEntity);
expect(result.tags).toEqual(mockTags);
expect(mockSetEntity).toHaveBeenCalledWith(expect.objectContaining({
generateTextId: 'text1',
tagIds: ['tag1', 'tag2'],
updatedAt: expect.any(Number),
}));
});
it('场景ID不存在时应该抛出错误', async () => {
const emptySceneEntity = { ...mockSceneEntity, id: '' };
const sceneItem = new SceneItem(emptySceneEntity);
const useCase = new SceneEditUseCase(sceneItem);
await expect(useCase.refreshSceneData()).rejects.toThrow('场景ID不存在无法获取场景数据');
});
it('API调用失败时应该抛出错误', async () => {
(getSceneData as jest.Mock).mockResolvedValue({
successful: false,
message: '获取失败',
// 模拟用户操作:修改场景提示词
const updatedTextEntity = { ...mockTextEntity, content: '修改后的场景提示词' };
(updateText as jest.Mock).mockResolvedValue({
successful: true,
data: updatedTextEntity,
});
const sceneItem = new SceneItem(mockSceneEntity);
const useCase = new SceneEditUseCase(sceneItem);
const updateTextResult = await updateText({
textId: 'text1',
content: '修改后的场景提示词'
});
expect(updateTextResult.successful).toBe(true);
expect(updateTextResult.data.content).toBe('修改后的场景提示词');
await expect(useCase.refreshSceneData()).rejects.toThrow('获取场景数据失败: 获取失败');
// 模拟用户操作:智能优化文本
const optimizedContent = '智能优化后的场景文本';
mockTextEditUseCase.getOptimizedContent.mockResolvedValue(optimizedContent);
mockTextEditUseCase.updateText.mockResolvedValue({
entity: { ...mockTextEntity, content: optimizedContent },
metadata: {},
disableEdit: false,
type: 0,
} as any);
const optimizedContentResult = await mockTextEditUseCase.getOptimizedContent();
expect(optimizedContentResult).toBe(optimizedContent);
// 模拟用户操作:修改标签
const updatedTagEntity = { ...mockTagEntity1, content: '修改后的标签内容' };
(updateTag as jest.Mock).mockResolvedValue({
successful: true,
data: updatedTagEntity,
});
const updateTagResult = await updateTag({
tagId: 'tag1',
content: '修改后的标签内容'
});
expect(updateTagResult.successful).toBe(true);
expect(updateTagResult.data.content).toBe('修改后的标签内容');
// 模拟用户操作:使用新的提示词和标签重新生成场景
const newSceneEntity = { ...mockSceneEntity, id: 'scene2', name: '重新生成的场景' };
(regenerateScene as jest.Mock).mockResolvedValue({
successful: true,
data: newSceneEntity,
});
mockSceneEditUseCase.AIgenerateScene.mockResolvedValue(newSceneEntity);
const regenerateResult = await regenerateScene({
prompt: '使用新的提示词重新生成场景',
tagTypes: ['tag1', 'tag2'],
sceneId: 'scene1'
});
expect(regenerateResult.successful).toBe(true);
expect(regenerateResult.data.name).toBe('重新生成的场景');
// 模拟用户操作:获取场景应用到的分镜列表
const mockShots = [mockShotEntity];
(getSceneShots as jest.Mock).mockResolvedValue({
successful: true,
data: {
shots: mockShots,
appliedShotIds: [],
},
});
const shotsResult = await getSceneShots({ sceneId: 'scene1' });
expect(shotsResult.successful).toBe(true);
expect(shotsResult.data.shots).toEqual(mockShots);
// 模拟用户操作:选择分镜并应用新的场景
(applySceneToShots as jest.Mock).mockResolvedValue({
successful: true,
data: { success: true },
});
mockSceneEditUseCase.applyScene.mockResolvedValue({} as any);
const applyResult = await applySceneToShots({
sceneId: 'scene1',
shotIds: ['shot1', 'shot2']
});
expect(applyResult.successful).toBe(true);
});
it('应该模拟用户选择场景并修改提示词的完整流程', async () => {
// 用户操作:获取项目中的场景列表
const mockScenes = [
{ ...mockSceneEntity, id: 'scene1', name: '场景1' },
{ ...mockSceneEntity, id: 'scene2', name: '场景2' }
];
(getSceneList as jest.Mock).mockResolvedValue({
successful: true,
data: mockScenes,
});
const sceneList = await getSceneList({ projectId: 'project1' });
expect(sceneList.data).toHaveLength(2);
// 用户操作:选择第一个场景
const selectedSceneId = 'scene1';
(getSceneData as jest.Mock).mockResolvedValue({
successful: true,
data: {
text: mockTextEntity,
tags: [mockTagEntity1],
},
});
const selectedSceneData = await getSceneData({ sceneId: selectedSceneId });
expect(selectedSceneData.data.text.id).toBe('text1');
// 用户操作:修改场景提示词
const newPrompt = '我想要一个更加戏剧性的场景';
(updateText as jest.Mock).mockResolvedValue({
successful: true,
data: { ...mockTextEntity, content: newPrompt },
});
const updatedText = await updateText({
textId: 'text1',
content: newPrompt
});
expect(updatedText.data.content).toBe(newPrompt);
// 用户操作:使用新提示词重新生成场景
const regeneratedScene = { ...mockSceneEntity, name: '戏剧性场景' };
(regenerateScene as jest.Mock).mockResolvedValue({
successful: true,
data: regeneratedScene,
});
const regenerationResult = await regenerateScene({
prompt: newPrompt,
tagTypes: ['tag1'],
sceneId: selectedSceneId
});
expect(regenerationResult.data.name).toBe('戏剧性场景');
});
it('应该处理场景编辑流程中的错误情况', async () => {
// 模拟获取场景列表失败
(getSceneList as jest.Mock).mockResolvedValue({
successful: false,
message: '获取场景列表失败',
});
const sceneListResult = await getSceneList({ projectId: 'project1' });
expect(sceneListResult.successful).toBe(false);
expect(sceneListResult.message).toBe('获取场景列表失败');
// 模拟修改文本失败
(updateText as jest.Mock).mockResolvedValue({
successful: false,
message: '修改文本失败',
});
const updateTextResult = await updateText({
textId: 'text1',
content: '新的文本内容'
});
expect(updateTextResult.successful).toBe(false);
expect(updateTextResult.message).toBe('修改文本失败');
// 模拟重新生成场景失败
(regenerateScene as jest.Mock).mockResolvedValue({
successful: false,
message: '重新生成场景失败',
});
const regenerateResult = await regenerateScene({
prompt: '重新生成场景',
tagTypes: ['tag1'],
sceneId: 'scene1'
});
expect(regenerateResult.successful).toBe(false);
expect(regenerateResult.message).toBe('重新生成场景失败');
// 模拟应用场景失败
(applySceneToShots as jest.Mock).mockResolvedValue({
successful: false,
message: '应用场景失败',
});
const applyResult = await applySceneToShots({
sceneId: 'scene1',
shotIds: ['shot1']
});
expect(applyResult.successful).toBe(false);
expect(applyResult.message).toBe('应用场景失败');
});
});

View File

@ -0,0 +1,484 @@
import { generateScriptStream, applyScriptToShot, getProjectScript } from '../../../api/video_flow';
import { ScriptEditUseCase } from '../usecase/ScriptEditUseCase';
import { ScriptSlice, ScriptSliceType } from '../domain/valueObject';
// Mock API模块
jest.mock('@/api/video_flow', () => ({
generateScriptStream: jest.fn(),
applyScriptToShot: jest.fn(),
getProjectScript: jest.fn(),
}));
// Mock UseCase模块
jest.mock('../usecase/ScriptEditUseCase');
describe('ScriptService 业务逻辑测试', () => {
let mockScriptEditUseCase: jest.Mocked<ScriptEditUseCase>;
// 测试数据
const mockScriptSlices: ScriptSlice[] = [
{
id: 'slice1',
type: ScriptSliceType.role,
text: '你好,我是主角',
metaData: { speaker: '主角', emotion: '友好' }
},
{
id: 'slice2',
type: ScriptSliceType.scene,
text: '场景:阳光明媚的公园',
metaData: { location: '公园', weather: '晴天' }
},
{
id: 'slice3',
type: ScriptSliceType.text,
text: '这是一个温馨的故事',
metaData: { style: '温馨' }
}
];
const mockStreamData = [
{ id: 'slice1', type: 'role', text: '你好,我是主角', metaData: { speaker: '主角' } },
{ id: 'slice2', type: 'scene', text: '场景:阳光明媚的公园', metaData: { location: '公园' } },
{ id: 'slice3', type: 'text', text: '这是一个温馨的故事', metaData: { style: '温馨' } }
];
beforeEach(() => {
jest.clearAllMocks();
// 设置Mock UseCase实例
mockScriptEditUseCase = {
generateScript: jest.fn(),
applyScript: jest.fn(),
updateScriptSlice: jest.fn(),
getScriptSlices: jest.fn(),
toString: jest.fn(),
} as any;
// 设置Mock构造函数
(ScriptEditUseCase as jest.MockedClass<typeof ScriptEditUseCase>).mockImplementation(() => mockScriptEditUseCase);
// 设置默认的Mock返回值
mockScriptEditUseCase.getScriptSlices.mockReturnValue(mockScriptSlices);
mockScriptEditUseCase.toString.mockReturnValue('你好,我是主角\n场景阳光明媚的公园\n这是一个温馨的故事');
mockScriptEditUseCase.updateScriptSlice.mockReturnValue(true);
mockScriptEditUseCase.generateScript.mockResolvedValue();
mockScriptEditUseCase.applyScript.mockResolvedValue();
// Mock流式数据
(generateScriptStream as jest.Mock).mockResolvedValue(mockStreamData);
(applyScriptToShot as jest.Mock).mockResolvedValue({
successful: true,
data: { success: true },
});
(getProjectScript as jest.Mock).mockResolvedValue({
successful: true,
data: {
prompt: '创建一个关于友谊的温馨故事',
scriptText: '你好,我是主角\n场景阳光明媚的公园\n这是一个温馨的故事'
}
});
});
describe('剧本业务流程测试', () => {
it('应该完成完整的剧本编辑流程:根据提示词生成剧本→修改提示词重新生成→重置提示词重新生成→修改剧本→应用剧本', async () => {
// 1. 用户操作:根据提示词生成新剧本
const initialPrompt = '创建一个关于友谊的温馨故事';
// 模拟AI生成剧本的流式数据
const mockStream = (async function* () {
for (const data of mockStreamData) {
yield data;
}
})();
(generateScriptStream as jest.Mock).mockResolvedValue(mockStream);
mockScriptEditUseCase.generateScript.mockImplementation(async (prompt) => {
// 模拟流式处理
for await (const chunk of mockStream) {
// 这里可以模拟逐步添加剧本片段
}
});
// 创建剧本编辑用例并生成剧本
const scriptEditUseCase = new ScriptEditUseCase('');
await scriptEditUseCase.generateScript(initialPrompt);
expect(ScriptEditUseCase).toHaveBeenCalledWith('');
expect(mockScriptEditUseCase.generateScript).toHaveBeenCalledWith(initialPrompt);
// 验证生成的剧本内容
const generatedScript = scriptEditUseCase.toString();
expect(generatedScript).toContain('你好,我是主角');
expect(generatedScript).toContain('场景:阳光明媚的公园');
// 验证剧本片段
const scriptSlices = scriptEditUseCase.getScriptSlices();
expect(scriptSlices).toHaveLength(3);
expect(scriptSlices[0].type).toBe(ScriptSliceType.role);
expect(scriptSlices[1].type).toBe(ScriptSliceType.scene);
// 2. 用户操作:修改提示词重新生成剧本
const modifiedPrompt = '创建一个关于冒险的刺激故事';
// 模拟修改后的剧本数据
const modifiedScriptSlices: ScriptSlice[] = [
{
id: 'slice1',
type: ScriptSliceType.role,
text: '我们出发吧!',
metaData: { speaker: '冒险者', emotion: '兴奋' }
},
{
id: 'slice2',
type: ScriptSliceType.scene,
text: '场景:神秘的森林',
metaData: { location: '森林', atmosphere: '神秘' }
},
{
id: 'slice3',
type: ScriptSliceType.text,
text: '这是一个充满挑战的冒险',
metaData: { style: '冒险' }
}
];
mockScriptEditUseCase.getScriptSlices.mockReturnValue(modifiedScriptSlices);
mockScriptEditUseCase.toString.mockReturnValue('我们出发吧!\n场景神秘的森林\n这是一个充满挑战的冒险');
// 重新生成剧本
await scriptEditUseCase.generateScript(modifiedPrompt);
expect(mockScriptEditUseCase.generateScript).toHaveBeenCalledWith(modifiedPrompt);
// 验证修改后的剧本内容
const modifiedScript = scriptEditUseCase.toString();
expect(modifiedScript).toContain('我们出发吧!');
expect(modifiedScript).toContain('神秘的森林');
expect(modifiedScript).toContain('充满挑战的冒险');
// 3. 用户操作:重置提示词重新生成剧本
const resetPrompt = '创建一个关于友谊的温馨故事'; // 回到初始提示词
// 模拟重置后的剧本数据(回到初始状态)
mockScriptEditUseCase.getScriptSlices.mockReturnValue(mockScriptSlices);
mockScriptEditUseCase.toString.mockReturnValue('你好,我是主角\n场景阳光明媚的公园\n这是一个温馨的故事');
// 重置并重新生成剧本
await scriptEditUseCase.generateScript(resetPrompt);
expect(mockScriptEditUseCase.generateScript).toHaveBeenCalledWith(resetPrompt);
// 验证重置后的剧本内容
const resetScript = scriptEditUseCase.toString();
expect(resetScript).toContain('你好,我是主角');
expect(resetScript).toContain('阳光明媚的公园');
expect(resetScript).toContain('温馨的故事');
// 4. 用户操作:修改剧本片段
const updatedText = '你好,我是勇敢的主角';
const updatedMetaData = { speaker: '主角', emotion: '勇敢' };
// 修改第一个剧本片段
const updateSuccess = scriptEditUseCase.updateScriptSlice('slice1', updatedText, updatedMetaData);
expect(updateSuccess).toBe(true);
expect(mockScriptEditUseCase.updateScriptSlice).toHaveBeenCalledWith('slice1', updatedText, updatedMetaData);
// 验证修改后的片段
const updatedSlices = scriptEditUseCase.getScriptSlices();
// 注意mock返回的数据不会因为updateScriptSlice调用而改变
// 这里只是验证getScriptSlices被正确调用
expect(updatedSlices).toEqual(mockScriptSlices);
// 5. 用户操作:应用剧本到分镜
// 应用剧本
await scriptEditUseCase.applyScript();
expect(mockScriptEditUseCase.applyScript).toHaveBeenCalled();
// 验证API调用
expect(applyScriptToShot).toHaveBeenCalledWith({
scriptSlices: updatedSlices
});
});
it('应该验证剧本生成的不同提示词效果', async () => {
const scriptEditUseCase = new ScriptEditUseCase('');
// 测试不同类型的提示词
const testPrompts = [
'创建一个浪漫的爱情故事',
'创建一个悬疑的侦探故事',
'创建一个科幻的未来故事'
];
for (const prompt of testPrompts) {
await scriptEditUseCase.generateScript(prompt);
expect(mockScriptEditUseCase.generateScript).toHaveBeenCalledWith(prompt);
}
// 验证每个提示词都被正确处理
expect(mockScriptEditUseCase.generateScript).toHaveBeenCalledTimes(testPrompts.length);
});
it('应该验证剧本片段的修改和更新', async () => {
const scriptEditUseCase = new ScriptEditUseCase('');
await scriptEditUseCase.generateScript('测试剧本');
// 测试修改不同类型的剧本片段
const testUpdates = [
{
id: 'slice1',
text: '修改后的对话',
metaData: { speaker: '新角色', emotion: '愤怒' }
},
{
id: 'slice2',
text: '修改后的场景描述',
metaData: { location: '新地点', weather: '雨天' }
},
{
id: 'slice3',
text: '修改后的文本内容',
metaData: { style: '新风格', tone: '严肃' }
}
];
for (const update of testUpdates) {
const success = scriptEditUseCase.updateScriptSlice(update.id, update.text, update.metaData);
expect(success).toBe(true);
expect(mockScriptEditUseCase.updateScriptSlice).toHaveBeenCalledWith(
update.id,
update.text,
update.metaData
);
}
// 验证所有修改都被正确处理
expect(mockScriptEditUseCase.updateScriptSlice).toHaveBeenCalledTimes(testUpdates.length);
});
it('应该处理剧本编辑流程中的错误情况', async () => {
const scriptEditUseCase = new ScriptEditUseCase('');
// 模拟生成剧本失败
mockScriptEditUseCase.generateScript.mockRejectedValue(new Error('AI生成失败'));
await expect(scriptEditUseCase.generateScript('测试提示词')).rejects.toThrow('AI生成失败');
// 模拟更新剧本片段失败
mockScriptEditUseCase.updateScriptSlice.mockReturnValue(false);
const updateResult = scriptEditUseCase.updateScriptSlice('nonexistent', '新文本');
expect(updateResult).toBe(false);
// 模拟应用剧本失败
mockScriptEditUseCase.applyScript.mockRejectedValue(new Error('应用剧本失败'));
await expect(scriptEditUseCase.applyScript()).rejects.toThrow('应用剧本失败');
});
});
describe('剧本流式生成测试', () => {
it('应该正确处理流式剧本生成', async () => {
const scriptEditUseCase = new ScriptEditUseCase('');
// 模拟流式数据生成
const streamChunks = [
{ id: 'slice1', type: 'role', text: '第一段对话', metaData: { speaker: '角色1' } },
{ id: 'slice2', type: 'scene', text: '场景描述', metaData: { location: '地点1' } },
{ id: 'slice3', type: 'text', text: '叙述文本', metaData: { style: '风格1' } }
];
let chunkIndex = 0;
const mockStream = (async function* () {
for (const chunk of streamChunks) {
yield chunk;
chunkIndex++;
}
})();
(generateScriptStream as jest.Mock).mockResolvedValue(mockStream);
// 模拟流式处理
mockScriptEditUseCase.generateScript.mockImplementation(async (prompt) => {
for await (const chunk of mockStream) {
// 模拟逐步处理每个片段
// 注意这里不需要验证chunk因为mockStream已经包含了正确的数据
}
});
await scriptEditUseCase.generateScript('流式生成测试');
expect(mockScriptEditUseCase.generateScript).toHaveBeenCalledWith('流式生成测试');
expect(chunkIndex).toBe(streamChunks.length);
});
it('应该处理流式生成中的错误', async () => {
const scriptEditUseCase = new ScriptEditUseCase('');
// 模拟流式数据生成失败
const mockErrorStream = (async function* () {
throw new Error('流式生成错误');
})();
(generateScriptStream as jest.Mock).mockResolvedValue(mockErrorStream);
mockScriptEditUseCase.generateScript.mockImplementation(async () => {
for await (const chunk of mockErrorStream) {
// 这里不会执行,因为流会抛出错误
}
});
await expect(scriptEditUseCase.generateScript('错误测试')).rejects.toThrow('流式生成错误');
});
});
describe('剧本应用测试', () => {
it('应该成功应用剧本到分镜', async () => {
const scriptEditUseCase = new ScriptEditUseCase('');
// 先生成剧本
await scriptEditUseCase.generateScript('应用测试');
// 应用剧本
await scriptEditUseCase.applyScript();
expect(mockScriptEditUseCase.applyScript).toHaveBeenCalled();
});
it('应该处理应用剧本失败的情况', async () => {
const scriptEditUseCase = new ScriptEditUseCase('');
// 模拟应用失败
(applyScriptToShot as jest.Mock).mockResolvedValue({
successful: false,
message: '应用剧本失败',
});
mockScriptEditUseCase.applyScript.mockRejectedValue(new Error('应用失败'));
await expect(scriptEditUseCase.applyScript()).rejects.toThrow('应用失败');
});
});
describe('剧本状态管理测试', () => {
it('应该正确管理剧本的生成状态', async () => {
const scriptEditUseCase = new ScriptEditUseCase('');
// 设置初始状态为空数组
mockScriptEditUseCase.getScriptSlices.mockReturnValue([]);
// 初始状态应该是空的
const initialSlices = scriptEditUseCase.getScriptSlices();
expect(initialSlices).toEqual([]);
// 生成剧本后应该有内容
await scriptEditUseCase.generateScript('状态测试');
// 重新设置mock返回值为有内容的数组
mockScriptEditUseCase.getScriptSlices.mockReturnValue(mockScriptSlices);
const generatedSlices = scriptEditUseCase.getScriptSlices();
expect(generatedSlices).toEqual(mockScriptSlices);
// 修改后状态应该更新
scriptEditUseCase.updateScriptSlice('slice1', '新文本', undefined);
expect(mockScriptEditUseCase.updateScriptSlice).toHaveBeenCalledWith('slice1', '新文本', undefined);
});
it('应该验证剧本文本的完整性', async () => {
const scriptEditUseCase = new ScriptEditUseCase('');
await scriptEditUseCase.generateScript('完整性测试');
const scriptText = scriptEditUseCase.toString();
const scriptSlices = scriptEditUseCase.getScriptSlices();
// 验证文本包含所有片段的内容
for (const slice of scriptSlices) {
expect(scriptText).toContain(slice.text);
}
// 验证片段数量与文本行数匹配
const textLines = scriptText.split('\n').filter(line => line.trim());
expect(textLines.length).toBeGreaterThan(0);
});
it('应该根据项目ID获取已存在的剧本数据', async () => {
const projectId = 'test-project-123';
// 模拟API返回项目剧本数据只包含提示词和剧本文本
const mockProjectScriptData = {
prompt: '创建一个关于冒险的故事',
scriptText: '我们出发吧!\n场景神秘的森林\n这是一个充满挑战的冒险'
};
(getProjectScript as jest.Mock).mockResolvedValue({
successful: true,
data: mockProjectScriptData
});
// 创建剧本编辑用例并模拟解析逻辑
const scriptEditUseCase = new ScriptEditUseCase(mockProjectScriptData.scriptText);
// 模拟解析后的剧本片段这是UseCase内部逻辑的结果
const mockParsedSlices = [
{
id: 'slice1',
type: ScriptSliceType.role,
text: '我们出发吧!',
metaData: { speaker: '冒险者', emotion: '兴奋' }
},
{
id: 'slice2',
type: ScriptSliceType.scene,
text: '场景:神秘的森林',
metaData: { location: '森林', atmosphere: '神秘' }
},
{
id: 'slice3',
type: ScriptSliceType.text,
text: '这是一个充满挑战的冒险',
metaData: { style: '冒险' }
}
];
// 模拟从项目获取剧本数据
const response = await getProjectScript({ projectId });
expect(response.successful).toBe(true);
expect(response.data.prompt).toBe('创建一个关于冒险的故事');
expect(response.data.scriptText).toContain('我们出发吧!');
// 验证API只返回提示词和剧本文本不包含解析后的片段
expect(response.data).not.toHaveProperty('scriptSlices');
// 验证UseCase能够正确解析剧本文本
const parsedSlices = scriptEditUseCase.getScriptSlices();
// 注意由于ScriptValueObject.parseFromString目前是TODO这里只是验证调用不会出错
expect(Array.isArray(parsedSlices)).toBe(true);
// 验证API调用参数
expect(getProjectScript).toHaveBeenCalledWith({ projectId });
});
it('应该处理获取项目剧本失败的情况', async () => {
const projectId = 'invalid-project';
// 模拟API失败
(getProjectScript as jest.Mock).mockResolvedValue({
successful: false,
message: '项目不存在'
});
await expect(getProjectScript({ projectId })).resolves.toEqual({
successful: false,
message: '项目不存在'
});
expect(getProjectScript).toHaveBeenCalledWith({ projectId });
});
});
});

View File

@ -35,9 +35,9 @@ const tabs = [
{ id: 'settings', label: 'Settings', icon: Settings },
];
export function EditModal({
isOpen,
onClose,
export function EditModal({
isOpen,
onClose,
activeEditTab,
taskStatus,
taskSketch,
@ -203,7 +203,7 @@ export function EditModal({
);
})}
</div>
{/* 弹窗操作按钮 */}
<div className="pl-4 border-l border-white/10">
{/* 关闭按钮 */}
@ -235,7 +235,7 @@ export function EditModal({
</div>
{/* 底部操作栏 */}
<div className="p-4 border-t border-white/10 bg-black/20">
{/* <div className="p-4 border-t border-white/10 bg-black/20">
<div className="flex justify-end gap-3">
<motion.button
className="px-4 py-2 rounded-lg bg-white/10 text-white hover:bg-white/20 transition-colors"
@ -253,11 +253,11 @@ export function EditModal({
Save
</motion.button>
</div>
</div>
</div> */}
</motion.div>
</div>
</>
)}
</AnimatePresence>
);
}
}

View File

@ -1,145 +1,281 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Plus } from 'lucide-react';
import { DndContext, closestCenter, DragEndEvent } from '@dnd-kit/core';
import { SortableContext, rectSortingStrategy } from '@dnd-kit/sortable';
import { StoryboardCard as StoryboardCardType, mockStoryboards, mockSceneOptions } from '@/app/model/enums';
import FilterBar from './filter-bar';
import StoryboardCard from './storyboard-card';
import { X, Check, ChevronDown } from 'lucide-react';
import * as Popover from '@radix-ui/react-popover';
import { mockSceneOptions, mockCharacterOptions } from '@/app/model/enums';
import { Button } from './button';
import { Input } from './input';
import { useScriptService } from '@/app/service/Interaction/ScriptService';
const ScriptTabContent: React.FC = () => {
const [cards, setCards] = useState<StoryboardCardType[]>(mockStoryboards);
const [selectedScenes, setSelectedScenes] = useState<string[]>([]);
const [selectedCharacters, setSelectedCharacters] = useState<string[]>([]);
// 获取当前项目ID这里需要根据实际项目路由或上下文获取
const projectId = 'current-project-id'; // TODO: 从路由或上下文获取实际项目ID
// 筛选卡片
const filteredCards = cards.filter(card => {
const matchesScene = selectedScenes.length === 0 ||
selectedScenes.includes(card.scene?.sceneId || '');
const matchesCharacter = selectedCharacters.length === 0 ||
card.characters.some(char => selectedCharacters.includes(char.characterId));
return matchesScene && matchesCharacter;
});
const {
scriptSlices,
userPrompt,
loading,
error,
updateUserPrompt,
fetchScriptData,
setFocusedSlice,
updateScriptSliceText,
resetScript,
applyScript,
fetchProjectScript
} = useScriptService();
// 处理卡片拖拽
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id) {
const oldIndex = cards.findIndex(c => c.id === active.id);
const newIndex = cards.findIndex(c => c.id === over?.id);
const newCards = [...cards];
const [removed] = newCards.splice(oldIndex, 1);
newCards.splice(newIndex, 0, removed);
setCards(newCards);
}
};
// 处理卡片更新
const handleCardUpdate = useCallback((cardId: string, updates: Partial<StoryboardCardType>) => {
setCards(cards => cards.map(card =>
card.id === cardId ? { ...card, ...updates } : card
));
}, []);
// 处理卡片删除
const handleCardDelete = useCallback((cardId: string) => {
setCards(cards => cards.filter(card => card.id !== cardId));
}, []);
// 处理卡片复制
const handleCardDuplicate = useCallback((cardId: string) => {
const card = cards.find(c => c.id === cardId);
if (card) {
const newCard: StoryboardCardType = {
...card,
id: `card-${Date.now()}`,
shotId: `SC-${cards.length + 1}`,
dialogues: card.dialogues.map(d => ({ ...d, id: `d${Date.now()}-${d.id}` })),
};
setCards(cards => [...cards, newCard]);
}
}, [cards]);
// 添加新卡片
const handleAddCard = () => {
const newCard: StoryboardCardType = {
id: `card-${Date.now()}`,
shotId: `SC-${cards.length + 1}`,
scene: undefined,
characters: [],
dialogues: [],
description: '',
shotType: '',
cameraMove: '',
// 组件挂载时获取项目剧本数据
useEffect(() => {
const initializeScript = async () => {
try {
await fetchProjectScript(projectId);
} catch (error) {
console.error('初始化剧本数据失败:', error);
}
};
setCards(cards => [...cards, newCard]);
};
initializeScript();
}, [projectId, fetchProjectScript]);
// 处理AI生成按钮点击
const handleAiGenerate = useCallback(async () => {
if (!userPrompt.trim()) return;
try {
await fetchScriptData(userPrompt);
} catch (error) {
console.error('生成剧本失败:', error);
}
}, [userPrompt, fetchScriptData]);
// 处理重置按钮点击
const handleReset = useCallback(() => {
resetScript();
}, [resetScript]);
// 处理确认按钮点击
const handleConfirm = useCallback(async () => {
try {
await applyScript();
} catch (error) {
console.error('应用剧本失败:', error);
}
}, [applyScript]);
// 处理提示词输入变化
const handlePromptChange = useCallback((value: string) => {
updateUserPrompt(value);
}, [updateUserPrompt]);
// 处理剧本片段文本变化
const handleScriptSliceChange = useCallback((sliceId: string, text: string) => {
setFocusedSlice(sliceId);
updateScriptSliceText(text);
}, [setFocusedSlice, updateScriptSliceText]);
return (
<div className="flex flex-col h-full">
{/* 筛选栏 - 固定在顶部 */}
<div className="flex-shrink-0 bg-black/20 backdrop-blur-sm z-10">
<FilterBar
selectedScenes={selectedScenes}
selectedCharacters={selectedCharacters}
onScenesChange={setSelectedScenes}
onCharactersChange={setSelectedCharacters}
onReset={() => {
setSelectedScenes([]);
setSelectedCharacters([]);
}}
/>
</div>
{/* 卡片网格 - 可滚动区域 */}
<div className="flex-1 overflow-y-auto pt-4">
<DndContext
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={[...filteredCards.map(card => card.id), 'add-card']}
strategy={rectSortingStrategy}
>
<div
className="grid auto-rows-min gap-6 w-full"
style={{
gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
justifyItems: 'center'
<motion.div
className="relative w-full h-[90vh] backdrop-blur-xl rounded-2xl shadow-2xl overflow-hidden flex flex-col"
initial={{ scale: 0.95, y: 10, opacity: 0 }}
animate={{
scale: 1,
y: 0,
opacity: 1,
transition: {
type: "spring",
duration: 0.3,
bounce: 0.15,
stiffness: 300,
damping: 25
}
}}
exit={{
scale: 0.95,
y: 10,
opacity: 0,
transition: {
type: "tween",
duration: 0.1,
ease: "easeOut"
}
}}
>
<AnimatePresence>
{filteredCards.map((card) => (
<div key={card.id} className="w-full max-w-[480px]">
<StoryboardCard
card={card}
onUpdate={(updates) => handleCardUpdate(card.id, updates)}
onDelete={() => handleCardDelete(card.id)}
onDuplicate={() => handleCardDuplicate(card.id)}
/>
</div>
))}
{/* 添加卡片占位符 */}
{/* 标题 */}
<motion.div
className="flex-none px-6 py-4"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Edit Script
</h2>
</motion.div>
{/* 内容区域 */}
<motion.div
className="flex-1 overflow-auto p-6 pt-0 pb-0"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1, duration: 0.2 }}
>
{/* 剧本片段渲染区域 */}
<motion.div
key="add-card"
layout
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
onClick={handleAddCard}
className="w-full max-w-[480px] h-[480px] bg-black/20 backdrop-blur-sm rounded-xl
border border-dashed border-white/10 cursor-pointer
flex items-center justify-center
hover:bg-black/30 hover:border-white/20 transition-all duration-200
group"
style={{
height: 'calc(100% - 88px)'
}}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="space-y-4"
>
<Plus className="w-8 h-8 text-white/40 group-hover:text-white/60 transition-colors" />
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<p className="text-red-600 text-sm">{error}</p>
</div>
)}
{/* 剧本片段列表 */}
<div className="space-y-3">
{scriptSlices.map((slice, index) => (
<motion.div
key={slice.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="relative"
>
<Input
value={slice.text}
onChange={(e) => handleScriptSliceChange(slice.id, e.target.value)}
placeholder={`输入${slice.type}内容...`}
className="w-full bg-white/50 dark:bg-[#5b75ac20] border border-gray-200 dark:border-gray-600 focus:ring-2 focus:ring-blue-500/20 transition-all duration-200"
/>
<div className="absolute top-2 right-2">
<span className="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 rounded">
{slice.type}
</span>
</div>
</motion.div>
))}
</div>
{/* 加载状态 */}
{loading && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center justify-center py-8"
>
<div className="flex items-center space-x-2">
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-gray-600 dark:text-gray-400">
...
</span>
</div>
</motion.div>
)}
</motion.div>
</AnimatePresence>
</div>
</SortableContext>
</DndContext>
</div>
{/* 修改建议输入区域 */}
<motion.div
className="sticky bottom-0 bg-gradient-to-t from-white via-white to-transparent dark:from-[#5b75ac4d] dark:via-[#5b75ac4d] dark:to-transparent pt-8 pb-4 rounded-sm"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className="flex items-center space-x-2 px-4">
<div className="flex-1">
<Input
value={userPrompt}
onChange={(e) => handlePromptChange(e.target.value)}
placeholder="输入提示词然后点击AI生成按钮..."
className="outline-none box-shadow-none bg-white/50 dark:bg-[#5b75ac20] border-0 focus:ring-2 focus:ring-blue-500/20 transition-all duration-200"
disabled={loading}
/>
</div>
<motion.div
initial={false}
animate={{
scale: userPrompt.trim() && !loading ? 1 : 0.8,
opacity: userPrompt.trim() && !loading ? 1 : 0.5,
}}
transition={{
type: "spring",
stiffness: 500,
damping: 30
}}
>
<Button
variant="ghost"
size="icon"
disabled={!userPrompt.trim() || loading}
onClick={handleAiGenerate}
className="aiGenerate relative w-9 h-9 rounded-full bg-blue-500/10 hover:bg-blue-500/20 text-blue-600 dark:text-blue-400"
>
<motion.span
initial={false}
animate={{
opacity: loading ? 0 : 1,
scale: loading ? 0.5 : 1,
}}
>
<svg
className="w-5 h-5 transform rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
</motion.span>
{loading && (
<motion.div
className="absolute inset-0 flex items-center justify-center"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
>
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
</motion.div>
)}
</Button>
</motion.div>
</div>
</motion.div>
</motion.div>
{/* 底部按钮 */}
<motion.div
className="flex-none px-6 py-4 flex justify-end space-x-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<Button
variant="ghost"
className="min-w-[80px] bg-white/10 text-white hover:bg-white/20 transition-colors"
onClick={handleReset}
disabled={loading}
>
Reset
</Button>
<Button
className="min-w-[80px] bg-blue-500 text-white hover:bg-blue-600 transition-colors"
onClick={handleConfirm}
disabled={loading || scriptSlices.length === 0}
>
Confirm
</Button>
</motion.div>
</motion.div>
</div>
);
};