完善项目导入、模板与分割工作区交互
- 增强 DICOM/视频项目导入与演示数据:DICOM 按文件名自然顺序处理,导入后展示上传与解析任务进度,恢复演示出厂设置保留演示视频和演示 DICOM 项目,并补充 demo media seed 逻辑。 - 完善项目管理:项目支持重命名、删除、复制,删除使用站内确认弹窗,复制支持新项目重置和全内容复制,DICOM 项目不显示生成帧入口。 - 完善 GT Mask 与导出链路:只支持 8-bit maskid 图导入,非法/全背景图明确拒绝,尺寸自动适配,高精度 polygon 回显;统一导出默认当前帧,GT_label 使用 uint8 和真实 maskid,待分类 maskid 0 与背景一致。 - 完善分割工作区交互:新增画笔和橡皮擦并支持尺寸控制,移除创建点/线段入口,工具栏按类别分隔,AI 智能分割使用明确 AI 图标,取消黄色 seed point,清空/删除传播 mask 后同步清理空帧时间轴状态。 - 完善传播与时间轴:自动传播使用 SAM 2.1 权重任务,参考帧无遮罩时提示,传播历史按同一蓝色系递进变暗,删除/清空传播链时保留人工或独立 AI 标注来源。 - 完善模板库:新增头颈部 CT 分割默认模板,所有模板保留 maskid 0 待分类,支持鼠标复制模板、拖拽层级、JSON 批量导入预览、删除 label 和站内删除确认。 - 完善用户与高风险确认:用户改密码、删除用户、恢复演示出厂设置和清空人工/AI 标注帧均改为站内确认交互,避免浏览器原生 prompt/confirm。 - 补充前后端测试与文档:更新项目、模板、GT 导入、导出、传播、DICOM、用户管理等测试,并同步 README、AGENTS 和 doc 下实现/契约/测试计划文档。
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetStore } from '../test/storeTestUtils';
|
||||
import { useStore } from '../store/useStore';
|
||||
@@ -7,19 +7,25 @@ import { ProjectLibrary } from './ProjectLibrary';
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
getProjects: vi.fn(),
|
||||
createProject: vi.fn(),
|
||||
updateProject: vi.fn(),
|
||||
copyProject: vi.fn(),
|
||||
uploadMedia: vi.fn(),
|
||||
parseMedia: vi.fn(),
|
||||
uploadDicomBatch: vi.fn(),
|
||||
deleteProject: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
getProjects: apiMock.getProjects,
|
||||
createProject: apiMock.createProject,
|
||||
updateProject: apiMock.updateProject,
|
||||
copyProject: apiMock.copyProject,
|
||||
uploadMedia: apiMock.uploadMedia,
|
||||
parseMedia: apiMock.parseMedia,
|
||||
uploadDicomBatch: apiMock.uploadDicomBatch,
|
||||
deleteProject: apiMock.deleteProject,
|
||||
getTask: apiMock.getTask,
|
||||
}));
|
||||
|
||||
describe('ProjectLibrary', () => {
|
||||
@@ -93,11 +99,38 @@ describe('ProjectLibrary', () => {
|
||||
await waitFor(() => expect(apiMock.createProject).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'clip.mp4',
|
||||
})));
|
||||
expect(apiMock.uploadMedia).toHaveBeenCalledWith(file, 'p3');
|
||||
expect(apiMock.uploadMedia).toHaveBeenCalledWith(file, 'p3', expect.objectContaining({
|
||||
onProgress: expect.any(Function),
|
||||
}));
|
||||
expect(apiMock.parseMedia).not.toHaveBeenCalled();
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('视频导入成功');
|
||||
});
|
||||
|
||||
it('visualizes video upload progress while importing media', async () => {
|
||||
let resolveUpload: ((value: { url: string; id: string }) => void) | undefined;
|
||||
apiMock.createProject.mockResolvedValueOnce({ id: 'p-progress', name: 'large.mp4', status: 'pending' });
|
||||
apiMock.uploadMedia.mockImplementationOnce((_file, _projectId, options) => {
|
||||
options.onProgress({ loaded: 50, total: 100, percent: 50 });
|
||||
return new Promise((resolve) => {
|
||||
resolveUpload = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
const { container } = render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
||||
const input = container.querySelector('input[accept="video/*"]') as HTMLInputElement;
|
||||
const file = new File(['video'], 'large.mp4', { type: 'video/mp4' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
fireEvent.click(await screen.findByRole('button', { name: '开始导入' }));
|
||||
|
||||
expect(await screen.findByText('正在上传视频文件')).toBeInTheDocument();
|
||||
expect(screen.getByRole('progressbar', { name: '导入进度' })).toHaveAttribute('aria-valuenow', '50');
|
||||
|
||||
await act(async () => {
|
||||
resolveUpload?.({ url: 'http://file', id: 'object' });
|
||||
});
|
||||
expect(await screen.findByText('视频导入完成')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('generates frames from an imported video with the selected FPS', async () => {
|
||||
apiMock.getProjects
|
||||
.mockResolvedValueOnce([{ id: 'p4', name: 'clip.mp4', status: 'pending', frames: 0, video_path: 'uploads/clip.mp4', parse_fps: 30 }])
|
||||
@@ -115,6 +148,31 @@ describe('ProjectLibrary', () => {
|
||||
expect(await screen.findByText('12FPS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides frame generation while editing a project name', async () => {
|
||||
apiMock.getProjects.mockResolvedValueOnce([
|
||||
{ id: 'p-edit', name: 'Editable Clip', status: 'pending', frames: 0, video_path: 'uploads/editable.mp4', parse_fps: 30, source_type: 'video' },
|
||||
]);
|
||||
|
||||
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
||||
expect(await screen.findByRole('button', { name: '生成帧' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '修改项目名称 Editable Clip' }));
|
||||
|
||||
expect(screen.queryByRole('button', { name: '生成帧' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show frame generation for DICOM projects', async () => {
|
||||
apiMock.getProjects.mockResolvedValueOnce([
|
||||
{ id: 'p-dicom', name: 'DICOM Series', status: 'ready', frames: 0, video_path: 'uploads/dicom', source_type: 'dicom' },
|
||||
]);
|
||||
|
||||
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText('DICOM Series')).toBeInTheDocument();
|
||||
expect(screen.getByText('DICOM')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '生成帧' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('deletes a project from the project card without entering the workspace', async () => {
|
||||
const onProjectSelect = vi.fn();
|
||||
apiMock.getProjects.mockResolvedValueOnce([
|
||||
@@ -131,6 +189,7 @@ describe('ProjectLibrary', () => {
|
||||
|
||||
render(<ProjectLibrary onProjectSelect={onProjectSelect} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '删除项目 Delete Me' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.deleteProject).toHaveBeenCalledWith('p5'));
|
||||
expect(onProjectSelect).not.toHaveBeenCalled();
|
||||
@@ -141,18 +200,129 @@ describe('ProjectLibrary', () => {
|
||||
expect(useStore.getState().selectedMaskIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('imports only valid DICOM files and parses the returned project', async () => {
|
||||
apiMock.uploadDicomBatch.mockResolvedValueOnce({ project_id: 77, uploaded_count: 1, message: 'ok' });
|
||||
apiMock.parseMedia.mockResolvedValueOnce({ frames_extracted: 1 });
|
||||
it('renames a project from the project card without entering the workspace', async () => {
|
||||
const onProjectSelect = vi.fn();
|
||||
apiMock.getProjects.mockResolvedValueOnce([
|
||||
{ id: 'p7', name: 'Old Name', status: 'ready', frames: 3, fps: '30FPS' },
|
||||
]);
|
||||
apiMock.updateProject.mockResolvedValueOnce({ id: 'p7', name: 'New Name', status: 'ready', frames: 3, fps: '30FPS' });
|
||||
useStore.setState({
|
||||
currentProject: { id: 'p7', name: 'Old Name', status: 'ready' },
|
||||
});
|
||||
|
||||
render(<ProjectLibrary onProjectSelect={onProjectSelect} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '修改项目名称 Old Name' }));
|
||||
fireEvent.change(screen.getByDisplayValue('Old Name'), { target: { value: 'New Name' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存项目名称 Old Name' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.updateProject).toHaveBeenCalledWith('p7', { name: 'New Name' }));
|
||||
expect(onProjectSelect).not.toHaveBeenCalled();
|
||||
expect(useStore.getState().projects[0]).toEqual(expect.objectContaining({ id: 'p7', name: 'New Name' }));
|
||||
expect(useStore.getState().currentProject).toEqual(expect.objectContaining({ id: 'p7', name: 'New Name' }));
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('项目名称已更新');
|
||||
});
|
||||
|
||||
it('copies a project as a reset project from the project card', async () => {
|
||||
const onProjectSelect = vi.fn();
|
||||
apiMock.getProjects
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'p8', name: 'Source Project', status: 'ready', frames: 3, fps: '30FPS' },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'p9', name: 'Source Project 副本', status: 'ready', frames: 3, fps: '30FPS' },
|
||||
{ id: 'p8', name: 'Source Project', status: 'ready', frames: 3, fps: '30FPS' },
|
||||
]);
|
||||
apiMock.copyProject.mockResolvedValueOnce({ id: 'p9', name: 'Source Project 副本', status: 'ready', frames: 3, fps: '30FPS' });
|
||||
|
||||
render(<ProjectLibrary onProjectSelect={onProjectSelect} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '复制项目 Source Project' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /新项目重置/ }));
|
||||
|
||||
await waitFor(() => expect(apiMock.copyProject).toHaveBeenCalledWith('p8', { mode: 'reset' }));
|
||||
expect(onProjectSelect).not.toHaveBeenCalled();
|
||||
expect(useStore.getState().projects.map((project) => project.id)).toEqual(['p9', 'p8']);
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('已复制为重置项目:Source Project 副本');
|
||||
});
|
||||
|
||||
it('copies a project with all content from the project card', async () => {
|
||||
apiMock.getProjects
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'p10', name: 'Annotated Project', status: 'ready', frames: 2, fps: '30FPS' },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'p11', name: 'Annotated Project 副本', status: 'ready', frames: 2, fps: '30FPS' },
|
||||
{ id: 'p10', name: 'Annotated Project', status: 'ready', frames: 2, fps: '30FPS' },
|
||||
]);
|
||||
apiMock.copyProject.mockResolvedValueOnce({ id: 'p11', name: 'Annotated Project 副本', status: 'ready', frames: 2, fps: '30FPS' });
|
||||
|
||||
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '复制项目 Annotated Project' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /全内容复制/ }));
|
||||
|
||||
await waitFor(() => expect(apiMock.copyProject).toHaveBeenCalledWith('p10', { mode: 'full' }));
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('已全内容复制项目:Annotated Project 副本');
|
||||
});
|
||||
|
||||
it('imports valid DICOM files in natural filename order and parses the returned project', async () => {
|
||||
apiMock.uploadDicomBatch.mockResolvedValueOnce({ project_id: 77, uploaded_count: 3, message: 'ok' });
|
||||
apiMock.parseMedia.mockResolvedValueOnce({ frames_extracted: 3 });
|
||||
|
||||
const { container } = render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
||||
const input = container.querySelector('input[accept=".dcm"]') as HTMLInputElement;
|
||||
const dcm = new File(['dcm'], 'scan.dcm', { type: 'application/dicom' });
|
||||
const ten = new File(['dcm10'], '10.dcm', { type: 'application/dicom' });
|
||||
const two = new File(['dcm2'], '2.dcm', { type: 'application/dicom' });
|
||||
const one = new File(['dcm1'], '1.dcm', { type: 'application/dicom' });
|
||||
const ignored = new File(['txt'], 'notes.txt', { type: 'text/plain' });
|
||||
fireEvent.change(input, { target: { files: [dcm, ignored] } });
|
||||
fireEvent.change(input, { target: { files: [ten, ignored, two, one] } });
|
||||
|
||||
await waitFor(() => expect(apiMock.uploadDicomBatch).toHaveBeenCalledWith([dcm]));
|
||||
await waitFor(() => expect(apiMock.uploadDicomBatch).toHaveBeenCalledWith([one, two, ten], undefined, expect.objectContaining({
|
||||
onProgress: expect.any(Function),
|
||||
})));
|
||||
expect(apiMock.parseMedia).toHaveBeenCalledWith('77');
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('DICOM 上传成功: 1 个文件');
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('DICOM 导入完成: 3 个文件');
|
||||
});
|
||||
|
||||
it('visualizes DICOM upload progress and parsing queue handoff', async () => {
|
||||
let resolveDicomUpload: ((value: { project_id: number; uploaded_count: number; message: string }) => void) | undefined;
|
||||
apiMock.uploadDicomBatch.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveDicomUpload = resolve;
|
||||
}));
|
||||
apiMock.parseMedia.mockResolvedValueOnce({ id: 44, status: 'queued', progress: 0 });
|
||||
apiMock.getTask
|
||||
.mockResolvedValueOnce({ id: 44, status: 'running', progress: 55, message: '正在写入帧索引' })
|
||||
.mockResolvedValueOnce({ id: 44, status: 'success', progress: 100, message: '解析完成' });
|
||||
|
||||
const { container } = render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
||||
const input = container.querySelector('input[accept=".dcm"]') as HTMLInputElement;
|
||||
const one = new File(['dcm1'], '1.dcm', { type: 'application/dicom' });
|
||||
const two = new File(['dcm2'], '2.dcm', { type: 'application/dicom' });
|
||||
fireEvent.change(input, { target: { files: [two, one] } });
|
||||
|
||||
await waitFor(() => expect(apiMock.uploadDicomBatch).toHaveBeenCalled());
|
||||
const progressOptions = apiMock.uploadDicomBatch.mock.calls[0][2];
|
||||
await act(async () => {
|
||||
progressOptions.onProgress({ loaded: 80, total: 100, percent: 80 });
|
||||
});
|
||||
|
||||
expect(await screen.findByText('正在上传 DICOM 序列')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 文件')).toBeInTheDocument();
|
||||
expect(screen.getByRole('progressbar', { name: '导入进度' })).toHaveAttribute('aria-valuenow', '80');
|
||||
|
||||
vi.useFakeTimers();
|
||||
await act(async () => {
|
||||
resolveDicomUpload?.({ project_id: 78, uploaded_count: 2, message: 'ok' });
|
||||
});
|
||||
expect(screen.getByText('正在解析 DICOM 序列')).toBeInTheDocument();
|
||||
expect(screen.getByRole('progressbar', { name: '导入进度' })).toHaveAttribute('aria-valuenow', '0');
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1200);
|
||||
});
|
||||
expect(apiMock.getTask).toHaveBeenCalledWith(44);
|
||||
expect(screen.getByText('正在写入帧索引')).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1200);
|
||||
});
|
||||
expect(screen.getByText('DICOM 导入完成')).toBeInTheDocument();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user