Files
Pre_Seg_Server/src/components/OntologyInspector.test.tsx
admin afcddfaeb9 feat: 完善分割工作区导入导出与管理流程
- 新增基于 JWT 当前用户的登录恢复、角色权限、用户管理、审计日志和演示出厂重置后台接口与前端管理页。

- 重串 GT_label 导出和 GT Mask 导入逻辑:导出保留类别真实 maskid,导入仅接受灰度或 RGB 等通道 maskid 图,支持未知 maskid 策略、尺寸最近邻拉伸和导入预览。

- 统一分割结果导出体验:默认当前帧,按项目抽帧顺序和 XhXXmXXsXXXms 时间戳命名 ZIP 与图片,补齐 GT/Pro/Mix/分开 Mask 输出和映射 JSON。

- 调整工作区左侧工具栏:移除创建点/线段入口,新增画笔、橡皮擦及尺寸控制,并按绘制、布尔、导入/AI 工具分组分隔。

- 扩展 Canvas 编辑能力:画笔按语义分类绘制并可自动并入连通选中 mask,橡皮擦对选中区域扣除,优化布尔操作、选区、撤销重做和保存状态联动。

- 优化自动传播时间轴显示:同一蓝色系按传播新旧递进变暗,老传播记录达到阈值后统一旧记录色,并维护范围选择与清空后的历史显示。

- 将 AI 智能分割入口替换为更明确的 AI 元素图标,并同步侧栏、工作区和 AI 页面入口表现。

- 完善模板分类、maskid 工具函数、分类树联动、遮罩透明度、边缘平滑和传播链同步相关前端状态。

- 扩展后端项目、媒体、任务、Dashboard、模板和传播 runner 的用户隔离、任务控制、进度事件与兼容处理。

- 补充前后端测试,覆盖用户管理、GT_label 往返导入导出、GT Mask 校验和预览、画笔/橡皮擦、时间轴传播历史、导出范围、WebSocket 与 API 封装。

- 更新 AGENTS、README 和 doc 文档,记录当前接口契约、实现状态、测试计划、安装说明和 maskid/GT_label 规则。
2026-05-03 03:52:32 +08:00

586 lines
20 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 { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetStore } from '../test/storeTestUtils';
import { useStore } from '../store/useStore';
import { OntologyInspector } from './OntologyInspector';
const apiMock = vi.hoisted(() => ({
analyzeMask: vi.fn(),
smoothMaskGeometry: vi.fn(),
updateTemplate: vi.fn(),
}));
vi.mock('../lib/api', () => ({
analyzeMask: apiMock.analyzeMask,
smoothMaskGeometry: apiMock.smoothMaskGeometry,
updateTemplate: apiMock.updateTemplate,
}));
describe('OntologyInspector', () => {
beforeEach(() => {
resetStore();
vi.clearAllMocks();
apiMock.analyzeMask.mockResolvedValue({
confidence: 0.82,
confidence_source: 'model_score',
topology_anchor_count: 4,
topology_anchors: [],
area: 0.1,
bbox: [0, 0, 0.1, 0.1],
source: 'sam2.1_hiera_tiny',
message: '已读取后端几何属性',
});
apiMock.smoothMaskGeometry.mockResolvedValue({
polygons: [[[0.12, 0.12], [0.28, 0.12], [0.28, 0.28], [0.12, 0.28]]],
pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z',
segmentation: [[12, 12, 28, 12, 28, 28, 12, 28]],
bbox: [12, 12, 16, 16],
area: 256,
topology_anchor_count: 4,
topology_anchors: [],
smoothing: { strength: 35, method: 'chaikin' },
message: '已应用边缘平滑强度 35',
});
useStore.setState({
templates: [
{
id: 't1',
name: '腹腔镜模板',
classes: [
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, category: '器官' },
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 10, category: '器官' },
],
rules: [],
},
],
});
});
it('shows template classes and changes the active template', () => {
const { container } = render(<OntologyInspector />);
const templateSelect = screen.getByRole('combobox');
expect(container.querySelector('.seg-scrollbar')).toBeInTheDocument();
expect(screen.queryByText('本体论与属性分类管理树')).not.toBeInTheDocument();
fireEvent.change(templateSelect, { target: { value: 't1' } });
expect(useStore.getState().activeTemplateId).toBe('t1');
expect(screen.getByText('胆囊')).toBeInTheDocument();
expect(screen.getByText('肝脏')).toBeInTheDocument();
expect(screen.getByText('maskid:1')).toBeInTheDocument();
expect(screen.getByText('maskid:2')).toBeInTheDocument();
expect(screen.queryByText(/z:/)).not.toBeInTheDocument();
});
it('adjusts workspace mask opacity from above the semantic tree', () => {
render(<OntologyInspector />);
fireEvent.change(screen.getByLabelText('遮罩透明度'), { target: { value: '35' } });
expect(useStore.getState().maskPreviewOpacity).toBe(35);
expect(screen.getByText('35%')).toBeInTheDocument();
});
it('focuses the matching semantic class when a mask is selected', async () => {
if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = vi.fn();
}
useStore.setState({
masks: [{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 0 0 Z',
label: '肝脏',
color: '#00ff00',
classId: 'c2',
className: '肝脏',
}],
selectedMaskIds: ['m1'],
});
render(<OntologyInspector />);
const liverButton = screen.getByRole('button', { name: /肝脏/ });
await waitFor(() => expect(useStore.getState().activeClassId).toBe('c2'));
expect(liverButton).toHaveAttribute('aria-current', 'true');
expect(document.activeElement).toBe(liverButton);
});
it('selects a concrete class for subsequent masks', () => {
render(<OntologyInspector />);
fireEvent.click(screen.getByText('胆囊'));
expect(useStore.getState().activeClassId).toBe('c1');
expect(useStore.getState().activeClass).toEqual(expect.objectContaining({
id: 'c1',
name: '胆囊',
zIndex: 20,
}));
});
it('applies the selected class to currently selected masks', () => {
useStore.setState({
selectedMaskIds: ['m1'],
masks: [
{
id: 'm2',
frameId: 'frame-1',
pathData: 'M 10 10 Z',
label: '未选区域',
color: '#ffffff',
saveStatus: 'draft',
},
{
id: 'm1',
annotationId: '99',
frameId: 'frame-1',
pathData: 'M 0 0 Z',
label: '旧标签',
color: '#06b6d4',
saveStatus: 'saved',
saved: true,
},
],
});
render(<OntologyInspector />);
fireEvent.click(screen.getByText('肝脏'));
expect(useStore.getState().activeClassId).toBe('c2');
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['m2', 'm1']);
expect(useStore.getState().masks[1]).toEqual(expect.objectContaining({
templateId: 't1',
classId: 'c2',
className: '肝脏',
classZIndex: 10,
classMaskId: 2,
label: '肝脏',
color: '#00ff00',
saveStatus: 'dirty',
saved: false,
}));
expect(screen.queryByText('当前选中区域:')).not.toBeInTheDocument();
});
it('applies class changes to the same propagation chain across frames', () => {
useStore.setState({
selectedMaskIds: ['annotation-10'],
masks: [
{
id: 'annotation-10',
annotationId: '10',
frameId: 'frame-1',
pathData: 'M 0 0 Z',
label: '旧标签',
color: '#06b6d4',
saveStatus: 'saved',
saved: true,
},
{
id: 'annotation-11',
annotationId: '11',
frameId: 'frame-2',
pathData: 'M 1 1 Z',
label: '旧传播标签',
color: '#06b6d4',
metadata: {
source_annotation_id: 10,
source_mask_id: 'annotation-10',
propagation_seed_key: 'annotation:10',
},
saveStatus: 'saved',
saved: true,
},
{
id: 'annotation-99',
annotationId: '99',
frameId: 'frame-3',
pathData: 'M 2 2 Z',
label: '无关区域',
color: '#ffffff',
metadata: { source_annotation_id: 99 },
saveStatus: 'saved',
saved: true,
},
],
});
render(<OntologyInspector />);
fireEvent.click(screen.getByText('肝脏'));
const updated = useStore.getState().masks;
expect(updated.find((mask) => mask.id === 'annotation-10')).toEqual(expect.objectContaining({
classId: 'c2',
className: '肝脏',
classMaskId: 2,
label: '肝脏',
color: '#00ff00',
saveStatus: 'dirty',
saved: false,
}));
expect(updated.find((mask) => mask.id === 'annotation-11')).toEqual(expect.objectContaining({
classId: 'c2',
className: '肝脏',
classMaskId: 2,
label: '肝脏',
color: '#00ff00',
saveStatus: 'dirty',
saved: false,
}));
expect(updated.find((mask) => mask.id === 'annotation-99')).toEqual(expect.objectContaining({
label: '无关区域',
color: '#ffffff',
saveStatus: 'saved',
saved: true,
}));
});
it('persists custom classes to the active backend template', async () => {
apiMock.updateTemplate.mockResolvedValueOnce({
id: 't1',
name: '腹腔镜模板',
classes: [
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, category: '器官' },
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 10, category: '器官' },
{ id: 'custom-1', name: '新局部分类', color: '#06b6d4', zIndex: 30, category: '自定义' },
],
rules: [],
});
render(<OntologyInspector />);
fireEvent.change(screen.getByRole('combobox'), { target: { value: 't1' } });
const customSection = screen.getByText('自定义分类').parentElement!;
fireEvent.click(within(customSection).getByRole('button'));
fireEvent.change(screen.getByPlaceholderText('分类名称'), { target: { value: '新局部分类' } });
fireEvent.keyDown(screen.getByPlaceholderText('分类名称'), { key: 'Enter' });
expect(await screen.findByText('自定义分类已保存到后端模板')).toBeInTheDocument();
expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
classes: expect.arrayContaining([expect.objectContaining({ name: '新局部分类', category: '自定义' })]),
}));
expect(useStore.getState().activeClass).toEqual(expect.objectContaining({ name: '新局部分类' }));
expect(useStore.getState().templates[0].classes).toHaveLength(3);
});
it('persists dragged semantic class order as layer priority without changing maskid', async () => {
apiMock.updateTemplate.mockResolvedValueOnce({
id: 't1',
name: '腹腔镜模板',
classes: [
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 20, maskId: 2, category: '器官' },
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
],
rules: [],
});
useStore.setState({
masks: [{
id: 'm-liver',
annotationId: '42',
frameId: 'frame-1',
classId: 'c2',
className: '肝脏',
classZIndex: 10,
pathData: 'M 0 0 Z',
label: '肝脏',
color: '#00ff00',
saveStatus: 'saved',
saved: true,
}],
});
render(<OntologyInspector />);
const liverButton = screen.getByRole('button', { name: /肝脏/ });
const gallbladderButton = screen.getByRole('button', { name: /胆囊/ });
const dataTransfer = {
effectAllowed: '',
dropEffect: '',
setData: vi.fn(),
getData: vi.fn(() => 'c2'),
};
fireEvent.dragStart(liverButton, { dataTransfer });
fireEvent.dragOver(gallbladderButton, { dataTransfer });
fireEvent.drop(gallbladderButton, { dataTransfer });
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
classes: [
expect.objectContaining({ id: 'c2', zIndex: 20, maskId: 2 }),
expect.objectContaining({ id: 'c1', zIndex: 10, maskId: 1 }),
],
})));
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
classZIndex: 20,
saveStatus: 'dirty',
saved: false,
}));
});
it('loads selected mask properties from the backend analyzer', async () => {
useStore.setState({
frames: [{ id: 'frame-1', projectId: 'p1', index: 0, url: '/1.jpg', width: 100, height: 100 }],
activeClass: { id: 'c3', name: '肿瘤', color: '#f97316', zIndex: 30 },
activeClassId: 'c3',
selectedMaskIds: ['m1'],
masks: [
{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[10, 10, 20, 10, 20, 20]],
metadata: { source: 'sam2.1_hiera_tiny', score: 0.82 },
},
],
});
render(<OntologyInspector />);
expect(await screen.findByText('4 节点')).toBeInTheDocument();
expect(screen.getAllByText('胆囊')).toHaveLength(2);
expect(screen.queryByText('肿瘤')).not.toBeInTheDocument();
expect(screen.queryByText('后端模型置信度')).not.toBeInTheDocument();
expect(screen.queryByText('0.8200')).not.toBeInTheDocument();
expect(screen.getByText('4 节点')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '重新提取拓扑锚点' })).not.toBeInTheDocument();
expect(apiMock.analyzeMask).toHaveBeenLastCalledWith(
expect.objectContaining({ id: 'm1' }),
expect.objectContaining({ id: 'frame-1' }),
);
});
it('ignores aborted mask analysis requests without showing an error', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
apiMock.analyzeMask.mockRejectedValueOnce({ code: 'ECONNABORTED', message: 'Request aborted' });
useStore.setState({
frames: [{ id: 'frame-1', projectId: 'p1', index: 0, url: '/1.jpg', width: 100, height: 100 }],
selectedMaskIds: ['m1'],
masks: [
{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[10, 10, 20, 10, 20, 20]],
},
],
});
render(<OntologyInspector />);
await waitFor(() => expect(apiMock.analyzeMask).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByText('后端属性读取失败')).not.toBeInTheDocument());
expect(consoleError).not.toHaveBeenCalled();
consoleError.mockRestore();
});
it('previews backend edge smoothing while moving the slider without marking the mask dirty', async () => {
useStore.setState({
frames: [{ id: 'frame-1', projectId: 'p1', index: 0, url: '/1.jpg', width: 100, height: 100 }],
selectedMaskIds: ['m1'],
masks: [
{
id: 'm1',
annotationId: '10',
frameId: 'frame-1',
pathData: 'M 10 10 L 30 10 L 30 30 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[10, 10, 30, 10, 30, 30]],
saveStatus: 'saved',
saved: true,
},
],
});
render(<OntologyInspector />);
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '35' } });
await waitFor(() => expect(apiMock.smoothMaskGeometry).toHaveBeenCalledWith(
expect.objectContaining({ id: 'm1' }),
expect.objectContaining({ id: 'frame-1' }),
35,
));
await waitFor(() => expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z',
segmentation: [[12, 12, 28, 12, 28, 28, 12, 28]],
bbox: [12, 12, 16, 16],
area: 256,
saveStatus: 'saved',
saved: true,
metadata: { geometry_smoothing_preview: { strength: 35, method: 'chaikin' } },
})));
expect(screen.getByText('已应用边缘平滑强度 35预览中点击应用后写入当前 mask。')).toBeInTheDocument();
});
it('debounces backend edge smoothing preview while dragging the slider', async () => {
vi.useFakeTimers();
try {
useStore.setState({
frames: [{ id: 'frame-1', projectId: 'p1', index: 0, url: '/1.jpg', width: 100, height: 100 }],
selectedMaskIds: ['m1'],
masks: [
{
id: 'm1',
annotationId: '10',
frameId: 'frame-1',
pathData: 'M 10 10 L 30 10 L 30 30 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[10, 10, 30, 10, 30, 30]],
saveStatus: 'saved',
saved: true,
},
],
});
render(<OntologyInspector />);
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '15' } });
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '25' } });
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '35' } });
expect(screen.getByText('正在等待停止拖动后生成边缘平滑预览...')).toBeInTheDocument();
expect(apiMock.smoothMaskGeometry).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(219);
});
expect(apiMock.smoothMaskGeometry).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(1);
await Promise.resolve();
});
expect(apiMock.smoothMaskGeometry).toHaveBeenCalledTimes(1);
expect(apiMock.smoothMaskGeometry).toHaveBeenCalledWith(
expect.objectContaining({ id: 'm1' }),
expect.objectContaining({ id: 'frame-1' }),
35,
);
} finally {
vi.useRealTimers();
}
});
it('applies a previewed edge smoothing result to the selected mask and marks it dirty', async () => {
useStore.setState({
frames: [{ id: 'frame-1', projectId: 'p1', index: 0, url: '/1.jpg', width: 100, height: 100 }],
selectedMaskIds: ['m1'],
masks: [
{
id: 'm1',
annotationId: '10',
frameId: 'frame-1',
pathData: 'M 10 10 L 30 10 L 30 30 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[10, 10, 30, 10, 30, 30]],
saveStatus: 'saved',
saved: true,
},
],
});
render(<OntologyInspector />);
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '35' } });
await waitFor(() => expect(screen.getByRole('button', { name: '应用边缘平滑' })).not.toBeDisabled());
fireEvent.click(screen.getByRole('button', { name: '应用边缘平滑' }));
await waitFor(() => expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z',
segmentation: [[12, 12, 28, 12, 28, 28, 12, 28]],
bbox: [12, 12, 16, 16],
area: 256,
saveStatus: 'dirty',
saved: false,
})));
expect(useStore.getState().masks[0].metadata?.geometry_smoothing).toBeUndefined();
expect(apiMock.smoothMaskGeometry).toHaveBeenCalledTimes(1);
expect(screen.getByText('0%')).toBeInTheDocument();
expect(screen.getByText('已应用边缘平滑强度 35已变为新的 mask强度已重置为 0请保存后生效')).toBeInTheDocument();
});
it('applies smoothing to linked propagation masks as one undoable geometry edit', async () => {
useStore.setState({
frames: [
{ id: 'frame-0', projectId: 'p1', index: 0, url: '/0.jpg', width: 100, height: 100 },
{ id: 'frame-1', projectId: 'p1', index: 1, url: '/1.jpg', width: 100, height: 100 },
{ id: 'frame-2', projectId: 'p1', index: 2, url: '/2.jpg', width: 100, height: 100 },
],
selectedMaskIds: ['seed-mask'],
masks: [
{
id: 'seed-mask',
annotationId: '10',
frameId: 'frame-1',
pathData: 'M 10 10 L 30 10 L 30 30 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[10, 10, 30, 10, 30, 30]],
saveStatus: 'saved',
saved: true,
},
{
id: 'prop-backward',
annotationId: '11',
frameId: 'frame-0',
pathData: 'M 11 11 L 31 11 L 31 31 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[11, 11, 31, 11, 31, 31]],
saveStatus: 'saved',
saved: true,
metadata: { source_annotation_id: 10, source_mask_id: 'annotation-10', propagated_from_frame_id: 10 },
},
{
id: 'prop-forward',
annotationId: '12',
frameId: 'frame-2',
pathData: 'M 12 12 L 32 12 L 32 32 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[12, 12, 32, 12, 32, 32]],
saveStatus: 'saved',
saved: true,
metadata: { source_annotation_id: 10, source_mask_id: 'annotation-10', propagated_from_frame_id: 10 },
},
],
});
render(<OntologyInspector />);
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '35' } });
await waitFor(() => expect(screen.getByRole('button', { name: '应用边缘平滑' })).not.toBeDisabled());
fireEvent.click(screen.getByRole('button', { name: '应用边缘平滑' }));
await waitFor(() => expect(apiMock.smoothMaskGeometry).toHaveBeenCalledTimes(3));
await waitFor(() => expect(useStore.getState().masks).toEqual([
expect.objectContaining({ id: 'seed-mask', pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z', saveStatus: 'dirty', saved: false }),
expect.objectContaining({ id: 'prop-backward', pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z', saveStatus: 'dirty', saved: false }),
expect.objectContaining({ id: 'prop-forward', pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z', saveStatus: 'dirty', saved: false }),
]));
expect(useStore.getState().masks.every((mask) => !mask.metadata?.geometry_smoothing)).toBe(true);
expect(screen.getByText('已应用边缘平滑强度 35已同步应用到传播链 3 个对应 mask强度已重置为 0请保存后生效')).toBeInTheDocument();
act(() => {
useStore.getState().undoMasks();
});
expect(useStore.getState().masks.map((mask) => mask.pathData)).toEqual([
'M 10 10 L 30 10 L 30 30 Z',
'M 11 11 L 31 11 L 31 31 Z',
'M 12 12 L 32 12 L 32 32 Z',
]);
act(() => {
useStore.getState().redoMasks();
});
expect(useStore.getState().masks.every((mask) => mask.pathData === 'M 12 12 L 28 12 L 28 28 L 12 28 Z')).toBe(true);
});
});