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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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); }); });