- 新增基于 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 规则。
586 lines
20 KiB
TypeScript
586 lines
20 KiB
TypeScript
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);
|
||
});
|
||
});
|