Files
Pre_Seg_Server/src/components/TemplateRegistry.test.tsx
admin 4d6bbf2b80 同步传播链编辑并保护模板切换
- 修改激活模板时,如果当前项目已有 mask,先提示确认并清空所有本地 mask 和已保存后端标注;无 mask 项目可直接切换。

- 模板库详情页将“+ 新建分类”改为带编辑图标的“编辑模板”,并打开完整模板编辑弹窗。

- 区域合并会按 propagation lineage 找到其它传播帧的对应主区域和参与区域,逐帧执行 union,只删除实际参与合并的对应 mask。

- 重叠区域去除会按 propagation lineage 同步到其它传播帧的对应区域,保留参与扣除 mask,不再只改当前帧。

- 当前帧清空遮罩会同步删除这些 mask 的关联自动传播结果,并新增左侧工具栏清空入口。

- 传播链同步编辑保留 source、source_annotation_id、source_mask_id、propagation_seed_key 等 metadata,避免时间轴帧属性变色。

- 补充模板切换确认、模板编辑按钮、左侧清空入口、传播链合并/去除和清空传播链的前端回归测试。

- 更新 AGENTS、接口契约、冻结需求、设计冻结和测试计划文档。
2026-05-03 19:10:12 +08:00

462 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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';
import { TemplateRegistry } from './TemplateRegistry';
const apiMock = vi.hoisted(() => ({
getTemplates: vi.fn(),
createTemplate: vi.fn(),
updateTemplate: vi.fn(),
deleteTemplate: vi.fn(),
}));
vi.mock('../lib/api', () => ({
getTemplates: apiMock.getTemplates,
createTemplate: apiMock.createTemplate,
updateTemplate: apiMock.updateTemplate,
deleteTemplate: apiMock.deleteTemplate,
}));
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();
});
it('loads and displays templates with unpacked classes', async () => {
apiMock.getTemplates.mockResolvedValueOnce([
{
id: 't1',
name: '腹腔镜胆囊切除术',
description: 'desc',
classes: [{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, category: '器官' }],
rules: [],
},
]);
render(<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();
});
it('creates a template and stores it globally', async () => {
apiMock.getTemplates.mockResolvedValueOnce([]);
apiMock.createTemplate.mockResolvedValueOnce({
id: 't2',
name: 'New Template',
description: 'desc',
classes: [expect.objectContaining({ name: '待分类', maskId: 0, color: '#000000' })],
rules: [],
});
render(<TemplateRegistry />);
fireEvent.click(screen.getByText('新建方案'));
fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: 'New Template' } });
fireEvent.change(screen.getAllByRole('textbox')[1], { target: { value: 'desc' } });
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => expect(apiMock.createTemplate).toHaveBeenCalledWith(expect.objectContaining({
name: 'New Template',
description: 'desc',
classes: [
expect.objectContaining({
id: 'reserved-unclassified',
name: '待分类',
color: '#000000',
zIndex: 0,
maskId: 0,
}),
],
rules: [],
color: '#06b6d4',
z_index: 0,
})));
expect(useStore.getState().templates[0]).toEqual(expect.objectContaining({ id: 't2' }));
});
it('imports JSON classes into the edit modal before saving', async () => {
apiMock.getTemplates.mockResolvedValueOnce([]);
render(<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('imports the array colors/names format into the edit modal', async () => {
apiMock.getTemplates.mockResolvedValueOnce([]);
render(<TemplateRegistry />);
fireEvent.click(screen.getByText('新建方案'));
fireEvent.click(screen.getByText('批量导入'));
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
target: { value: '[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]' },
});
expect(screen.getByText(/将导入 2 个分类maskid 从 1 开始分配/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '导入' }));
expect(screen.getByText('分类A')).toBeInTheDocument();
expect(screen.getByText('分类B')).toBeInTheDocument();
});
it('imports pasted color/name content with a label prefix and loose object keys', async () => {
apiMock.getTemplates.mockResolvedValueOnce([]);
render(<TemplateRegistry />);
fireEvent.click(screen.getByText('新建方案'));
fireEvent.click(screen.getByText('批量导入'));
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
target: {
value: `批量导入分类:{
colors: [[25500], [02550],],
names: ['分类A', '分类B',],
}`,
},
});
expect(screen.getByText(/将导入 2 个分类maskid 从 1 开始分配/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '导入' }));
expect(screen.getByText('分类A')).toBeInTheDocument();
expect(screen.getByText('分类B')).toBeInTheDocument();
});
it('shows JSON import errors inline instead of blocking alerts', async () => {
apiMock.getTemplates.mockResolvedValueOnce([]);
render(<TemplateRegistry />);
fireEvent.click(screen.getByText('新建方案'));
fireEvent.click(screen.getByText('批量导入'));
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
target: { value: '{broken-json' },
});
expect(screen.getByText('JSON 解析失败')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '导入' })).toBeDisabled();
});
it('shows template save errors as transient notices', async () => {
apiMock.getTemplates.mockResolvedValueOnce([]);
apiMock.createTemplate.mockRejectedValueOnce(new Error('boom'));
render(<TemplateRegistry />);
fireEvent.click(screen.getByText('新建方案'));
fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: 'Bad Template' } });
fireEvent.click(screen.getByRole('button', { name: '保存' }));
expect(await screen.findByRole('status')).toHaveTextContent('保存失败,请查看控制台');
});
it('edits an existing template through the backend and store', async () => {
apiMock.getTemplates.mockResolvedValueOnce([
{
id: 't1',
name: '旧模板',
description: 'old desc',
classes: [{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, category: '器官' }],
rules: [],
color: '#06b6d4',
z_index: 3,
},
]);
apiMock.updateTemplate.mockResolvedValueOnce({
id: 't1',
name: '新模板',
description: 'new desc',
classes: [{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, category: '器官' }],
rules: [],
});
render(<TemplateRegistry />);
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: '保存' }));
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
name: '新模板',
description: 'new desc',
classes: [
expect.objectContaining({ id: 'c1', name: '胆囊' }),
expect.objectContaining({ name: '待分类', maskId: 0 }),
],
rules: [],
color: '#06b6d4',
z_index: 3,
})));
expect(useStore.getState().templates[0]).toEqual(expect.objectContaining({
id: 't1',
name: '新模板',
}));
expect(await screen.findAllByText('新模板')).toHaveLength(2);
});
it('does not expose legacy category metadata while editing template classes', async () => {
apiMock.getTemplates.mockResolvedValueOnce([
{
id: 't1',
name: '模板',
description: 'desc',
classes: [{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, category: '器官' }],
rules: [],
color: '#06b6d4',
z_index: 3,
},
]);
render(<TemplateRegistry />);
fireEvent.click(await screen.findByRole('button', { name: '编辑模板 模板' }));
fireEvent.click(screen.getAllByText('胆囊').at(-1)!);
expect(screen.getByDisplayValue('胆囊')).toBeInTheDocument();
expect(screen.queryByDisplayValue('器官')).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('类别')).not.toBeInTheDocument();
});
it('shows the semantic tree title and opens the edit-template 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();
expect(screen.queryByRole('button', { name: /新建分类/ })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '编辑模板' }));
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 () => {
apiMock.getTemplates.mockResolvedValueOnce([
{
id: 't1',
name: '待删除模板',
description: 'desc',
classes: [],
rules: [],
},
]);
apiMock.deleteTemplate.mockResolvedValueOnce(undefined);
const { container } = render(<TemplateRegistry />);
await screen.findAllByText('待删除模板');
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([]);
});
});