完善项目导入、模板与分割工作区交互
- 增强 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:
@@ -19,6 +19,16 @@ vi.mock('../lib/api', () => ({
|
||||
}));
|
||||
|
||||
describe('TemplateRegistry', () => {
|
||||
const makeDataTransfer = () => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
effectAllowed: '',
|
||||
dropEffect: '',
|
||||
setData: vi.fn((key: string, value: string) => store.set(key, value)),
|
||||
getData: vi.fn((key: string) => store.get(key) || ''),
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetStore();
|
||||
vi.clearAllMocks();
|
||||
@@ -40,6 +50,8 @@ describe('TemplateRegistry', () => {
|
||||
expect(await screen.findAllByText('腹腔镜胆囊切除术')).toHaveLength(2);
|
||||
expect(screen.getByText('胆囊')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/maskid: ?1/).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('待分类')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/maskid: ?0/).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText(/Z-Level/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -49,7 +61,7 @@ describe('TemplateRegistry', () => {
|
||||
id: 't2',
|
||||
name: 'New Template',
|
||||
description: 'desc',
|
||||
classes: [],
|
||||
classes: [expect.objectContaining({ name: '待分类', maskId: 0, color: '#000000' })],
|
||||
rules: [],
|
||||
});
|
||||
|
||||
@@ -62,7 +74,15 @@ describe('TemplateRegistry', () => {
|
||||
await waitFor(() => expect(apiMock.createTemplate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'New Template',
|
||||
description: 'desc',
|
||||
classes: [],
|
||||
classes: [
|
||||
expect.objectContaining({
|
||||
id: 'reserved-unclassified',
|
||||
name: '待分类',
|
||||
color: '#000000',
|
||||
zIndex: 0,
|
||||
maskId: 0,
|
||||
}),
|
||||
],
|
||||
rules: [],
|
||||
color: '#06b6d4',
|
||||
z_index: 0,
|
||||
@@ -77,15 +97,17 @@ describe('TemplateRegistry', () => {
|
||||
fireEvent.click(screen.getByText('新建方案'));
|
||||
fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: 'With Classes' } });
|
||||
fireEvent.click(screen.getByText('批量导入'));
|
||||
expect(screen.queryByText('📋 载入腹腔镜胆囊切除术模板')).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
|
||||
target: { value: '{"colors":[[255,0,0]],"names":["分类A"]}' },
|
||||
});
|
||||
expect(screen.getByText(/将导入 1 个分类,maskid 从 1 开始分配/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '导入' }));
|
||||
|
||||
expect(screen.getByText('分类A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows JSON import errors as transient notices instead of blocking alerts', async () => {
|
||||
it('shows JSON import errors inline instead of blocking alerts', async () => {
|
||||
apiMock.getTemplates.mockResolvedValueOnce([]);
|
||||
|
||||
render(<TemplateRegistry />);
|
||||
@@ -94,9 +116,9 @@ describe('TemplateRegistry', () => {
|
||||
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
|
||||
target: { value: '{broken-json' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '导入' }));
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('JSON 解析失败');
|
||||
expect(screen.getByText('JSON 解析失败')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '导入' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows template save errors as transient notices', async () => {
|
||||
@@ -132,7 +154,7 @@ describe('TemplateRegistry', () => {
|
||||
});
|
||||
|
||||
render(<TemplateRegistry />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /修改库视图结构/ }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '编辑模板 旧模板' }));
|
||||
fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: '新模板' } });
|
||||
fireEvent.change(screen.getAllByRole('textbox')[1], { target: { value: 'new desc' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
@@ -140,7 +162,10 @@ describe('TemplateRegistry', () => {
|
||||
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
|
||||
name: '新模板',
|
||||
description: 'new desc',
|
||||
classes: [expect.objectContaining({ id: 'c1', name: '胆囊' })],
|
||||
classes: [
|
||||
expect.objectContaining({ id: 'c1', name: '胆囊' }),
|
||||
expect.objectContaining({ name: '待分类', maskId: 0 }),
|
||||
],
|
||||
rules: [],
|
||||
color: '#06b6d4',
|
||||
z_index: 3,
|
||||
@@ -149,6 +174,205 @@ describe('TemplateRegistry', () => {
|
||||
id: 't1',
|
||||
name: '新模板',
|
||||
}));
|
||||
expect(await screen.findAllByText('新模板')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('shows the semantic tree title and opens the add-class modal from the detail view', async () => {
|
||||
apiMock.getTemplates.mockResolvedValueOnce([
|
||||
{
|
||||
id: 't1',
|
||||
name: '模板',
|
||||
description: 'desc',
|
||||
classes: [{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' }],
|
||||
rules: [],
|
||||
color: '#06b6d4',
|
||||
z_index: 3,
|
||||
},
|
||||
]);
|
||||
apiMock.updateTemplate.mockResolvedValueOnce({
|
||||
id: 't1',
|
||||
name: '模板',
|
||||
description: 'desc',
|
||||
classes: [
|
||||
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
|
||||
{ id: 'new-class', name: '新类别', color: '#00ff00', zIndex: 20, maskId: 2, category: '未分类' },
|
||||
],
|
||||
rules: [],
|
||||
color: '#06b6d4',
|
||||
z_index: 3,
|
||||
});
|
||||
|
||||
render(<TemplateRegistry />);
|
||||
|
||||
expect(await screen.findByText('语义分类树(拖拽调层级)')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Painter's Algorithm Weight/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('器官')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /新建分类/ }));
|
||||
expect(screen.getByDisplayValue('新类别')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
|
||||
classes: [
|
||||
expect.objectContaining({ id: 'c1', name: '胆囊', maskId: 1 }),
|
||||
expect.objectContaining({ name: '新类别', maskId: 2, category: '未分类' }),
|
||||
expect.objectContaining({ name: '待分类', maskId: 0, color: '#000000' }),
|
||||
],
|
||||
})));
|
||||
});
|
||||
|
||||
it('deletes a class directly from the semantic tree', async () => {
|
||||
apiMock.getTemplates.mockResolvedValueOnce([
|
||||
{
|
||||
id: 't1',
|
||||
name: '模板',
|
||||
description: 'desc',
|
||||
classes: [
|
||||
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 20, maskId: 2, category: '器官' },
|
||||
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
|
||||
],
|
||||
rules: [],
|
||||
color: '#06b6d4',
|
||||
z_index: 3,
|
||||
},
|
||||
]);
|
||||
apiMock.updateTemplate.mockResolvedValueOnce({
|
||||
id: 't1',
|
||||
name: '模板',
|
||||
description: 'desc',
|
||||
classes: [
|
||||
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
|
||||
{ id: 'reserved-unclassified', name: '待分类', color: '#000000', zIndex: 0, maskId: 0, category: '系统保留' },
|
||||
],
|
||||
rules: [],
|
||||
color: '#06b6d4',
|
||||
z_index: 3,
|
||||
});
|
||||
|
||||
render(<TemplateRegistry />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '删除分类 肝脏' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
|
||||
classes: [
|
||||
expect.objectContaining({ id: 'c1', name: '胆囊', zIndex: 10, maskId: 1 }),
|
||||
expect.objectContaining({ name: '待分类', zIndex: 0, maskId: 0 }),
|
||||
],
|
||||
color: '#06b6d4',
|
||||
z_index: 3,
|
||||
})));
|
||||
await waitFor(() => expect(useStore.getState().templates[0].classes).toEqual([
|
||||
expect.objectContaining({ id: 'c1', name: '胆囊' }),
|
||||
expect.objectContaining({ name: '待分类', maskId: 0 }),
|
||||
]));
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('分类已删除');
|
||||
});
|
||||
|
||||
it('copies a template from the active template list into a new editable template', async () => {
|
||||
apiMock.getTemplates.mockResolvedValueOnce([
|
||||
{
|
||||
id: 't1',
|
||||
name: '头颈部CT分割',
|
||||
description: 'desc',
|
||||
classes: [
|
||||
{ id: 'c1', name: '肿瘤', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' },
|
||||
{ id: 'c2', name: '气管', color: '#00ff00', zIndex: 10, maskId: 4, category: '器官' },
|
||||
],
|
||||
rules: [{ id: 'r1', name: 'rule', sourceKey: 'a', targetKey: 'b', operation: 'copy' }],
|
||||
color: '#ef4444',
|
||||
z_index: 10,
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
name: '头颈部CT分割 副本',
|
||||
description: 'existing copy',
|
||||
classes: [],
|
||||
rules: [],
|
||||
},
|
||||
]);
|
||||
apiMock.createTemplate.mockResolvedValueOnce({
|
||||
id: 't3',
|
||||
name: '头颈部CT分割 副本 2',
|
||||
description: 'desc',
|
||||
classes: [
|
||||
{ id: 'copy-c1', name: '肿瘤', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' },
|
||||
{ id: 'copy-c2', name: '气管', color: '#00ff00', zIndex: 10, maskId: 4, category: '器官' },
|
||||
],
|
||||
rules: [{ id: 'r1', name: 'rule', sourceKey: 'a', targetKey: 'b', operation: 'copy' }],
|
||||
color: '#ef4444',
|
||||
z_index: 10,
|
||||
});
|
||||
|
||||
render(<TemplateRegistry />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '复制模板 头颈部CT分割' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.createTemplate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: '头颈部CT分割 副本 2',
|
||||
description: 'desc',
|
||||
color: '#ef4444',
|
||||
z_index: 10,
|
||||
rules: [expect.objectContaining({ id: 'r1' })],
|
||||
classes: [
|
||||
expect.objectContaining({ name: '肿瘤', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' }),
|
||||
expect.objectContaining({ name: '气管', color: '#00ff00', zIndex: 10, maskId: 4, category: '器官' }),
|
||||
expect.objectContaining({ name: '待分类', color: '#000000', zIndex: 0, maskId: 0 }),
|
||||
],
|
||||
})));
|
||||
const payload = apiMock.createTemplate.mock.calls[0][0];
|
||||
expect(payload.classes[0].id).toMatch(/^cls-copy-/);
|
||||
expect(payload.classes[0].id).not.toBe('c1');
|
||||
expect(useStore.getState().templates.some((template) => template.id === 't3')).toBe(true);
|
||||
expect(await screen.findByText('已复制模板:头颈部CT分割 副本 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('persists dragged class layer order directly from the template detail view', async () => {
|
||||
apiMock.getTemplates.mockResolvedValueOnce([
|
||||
{
|
||||
id: 't1',
|
||||
name: '模板',
|
||||
description: 'desc',
|
||||
classes: [
|
||||
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 20, maskId: 2, category: '器官' },
|
||||
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
|
||||
],
|
||||
rules: [],
|
||||
color: '#06b6d4',
|
||||
z_index: 3,
|
||||
},
|
||||
]);
|
||||
apiMock.updateTemplate.mockResolvedValueOnce({
|
||||
id: 't1',
|
||||
name: '模板',
|
||||
description: 'desc',
|
||||
classes: [
|
||||
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' },
|
||||
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 10, maskId: 2, category: '器官' },
|
||||
],
|
||||
rules: [],
|
||||
color: '#06b6d4',
|
||||
z_index: 3,
|
||||
});
|
||||
|
||||
render(<TemplateRegistry />);
|
||||
const gallbladderRow = (await screen.findByText('胆囊')).closest('[draggable="true"]') as HTMLElement;
|
||||
const liverRow = screen.getByText('肝脏').closest('[draggable="true"]') as HTMLElement;
|
||||
const dataTransfer = makeDataTransfer();
|
||||
|
||||
fireEvent.dragStart(gallbladderRow, { dataTransfer });
|
||||
fireEvent.dragOver(liverRow, { dataTransfer });
|
||||
fireEvent.drop(liverRow, { dataTransfer });
|
||||
|
||||
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
|
||||
classes: [
|
||||
expect.objectContaining({ id: 'c1', zIndex: 20, maskId: 1 }),
|
||||
expect.objectContaining({ id: 'c2', zIndex: 10, maskId: 2 }),
|
||||
expect.objectContaining({ name: '待分类', zIndex: 0, maskId: 0 }),
|
||||
],
|
||||
color: '#06b6d4',
|
||||
z_index: 3,
|
||||
})));
|
||||
await waitFor(() => expect(useStore.getState().templates[0].classes[0]).toEqual(
|
||||
expect.objectContaining({ id: 'c1', zIndex: 20 }),
|
||||
));
|
||||
});
|
||||
|
||||
it('deletes an existing template after confirmation', async () => {
|
||||
@@ -165,8 +389,8 @@ describe('TemplateRegistry', () => {
|
||||
const { container } = render(<TemplateRegistry />);
|
||||
|
||||
await screen.findAllByText('待删除模板');
|
||||
const buttons = Array.from(container.querySelectorAll('button'));
|
||||
fireEvent.click(buttons[2]);
|
||||
fireEvent.click(container.querySelector('button[title="删除模板"]') as HTMLElement);
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.deleteTemplate).toHaveBeenCalledWith('t1'));
|
||||
expect(useStore.getState().templates).toEqual([]);
|
||||
|
||||
Reference in New Issue
Block a user