import { act, 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 { CanvasArea } from './CanvasArea'; const apiMock = vi.hoisted(() => ({ predictMask: vi.fn(), })); vi.mock('../lib/api', () => ({ predictMask: apiMock.predictMask, })); describe('CanvasArea', () => { const frame = { id: 'frame-1', projectId: 'project-1', index: 0, url: '/frame.jpg', width: 640, height: 360 }; beforeEach(() => { resetStore(); vi.clearAllMocks(); }); it('calls AI prediction with the active frame when a point prompt is placed', async () => { useStore.setState({ activeTemplateId: '2', activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 }, activeClassId: 'c1', }); apiMock.predictMask.mockResolvedValueOnce({ masks: [ { id: 'mask-1', pathData: 'M 0 0 L 10 0 L 10 10 Z', label: 'AI Mask', color: '#06b6d4', segmentation: [[0, 0, 10, 0, 10, 10]], bbox: [0, 0, 10, 10], area: 100, }, ], }); render(); fireEvent.click(screen.getByTestId('konva-stage')); await waitFor(() => expect(apiMock.predictMask).toHaveBeenCalledWith({ imageId: 'frame-1', imageWidth: 640, imageHeight: 360, model: 'sam2.1_hiera_tiny', points: [{ x: 120, y: 80, type: 'pos' }], box: undefined, })); expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ id: 'mask-1', frameId: 'frame-1', pathData: 'M 0 0 L 10 0 L 10 10 Z', templateId: '2', classId: 'c1', className: '胆囊', classZIndex: 20, label: '胆囊', color: '#ff0000', saveStatus: 'draft', })); }); it('uses the workspace mask opacity setting for mask preview rendering', () => { useStore.setState({ masks: [{ id: 'm-opacity', frameId: 'frame-1', pathData: 'M 0 0 L 10 0 L 10 10 Z', label: 'Mask', color: '#06b6d4', segmentation: [[0, 0, 10, 0, 10, 10]], }], maskPreviewOpacity: 50, }); render(); const maskGroup = () => screen.getAllByTestId('konva-group').find((group) => group.getAttribute('data-opacity')); expect(maskGroup()).toHaveAttribute('data-opacity', '0.5'); act(() => { useStore.getState().setMaskPreviewOpacity(30); }); expect(maskGroup()).toHaveAttribute('data-opacity', '0.3'); }); it('refines one SAM2 candidate mask from an initial box with positive and negative points', async () => { apiMock.predictMask .mockResolvedValueOnce({ masks: [ { id: 'mask-box', pathData: 'M 10 10 L 90 10 L 90 90 Z', label: 'AI Mask', color: '#06b6d4', segmentation: [[10, 10, 90, 10, 90, 90]], bbox: [10, 10, 80, 80], area: 6400, }, ], }) .mockResolvedValueOnce({ masks: [ { id: 'mask-refined-pos', pathData: 'M 20 20 L 80 20 L 80 80 Z', label: 'AI Mask', color: '#06b6d4', segmentation: [[20, 20, 80, 20, 80, 80]], bbox: [20, 20, 60, 60], area: 3600, }, ], }) .mockResolvedValueOnce({ masks: [ { id: 'mask-refined-neg', pathData: 'M 30 30 L 70 30 L 70 70 Z', label: 'AI Mask', color: '#06b6d4', segmentation: [[30, 30, 70, 30, 70, 70]], bbox: [30, 30, 40, 40], area: 1600, }, ], }); const { rerender } = render(); const stage = screen.getByTestId('konva-stage'); fireEvent.mouseDown(stage, { clientX: 120, clientY: 80 }); fireEvent.mouseMove(stage, { clientX: 260, clientY: 200 }); fireEvent.mouseUp(stage, { clientX: 260, clientY: 200 }); await waitFor(() => expect(apiMock.predictMask).toHaveBeenNthCalledWith(1, { imageId: 'frame-1', imageWidth: 640, imageHeight: 360, model: 'sam2.1_hiera_tiny', points: undefined, box: { x1: 120, y1: 80, x2: 260, y2: 200 }, })); await waitFor(() => expect(useStore.getState().masks).toHaveLength(1)); rerender(); fireEvent.click(stage, { clientX: 150, clientY: 100 }); await waitFor(() => expect(apiMock.predictMask).toHaveBeenNthCalledWith(2, { imageId: 'frame-1', imageWidth: 640, imageHeight: 360, model: 'sam2.1_hiera_tiny', points: [{ x: 150, y: 100, type: 'pos' }], box: { x1: 120, y1: 80, x2: 260, y2: 200 }, })); expect(useStore.getState().masks).toHaveLength(1); expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ id: 'mask-box', segmentation: [[20, 20, 80, 20, 80, 80]], metadata: expect.objectContaining({ source: 'sam2_interactive', promptPointCount: 1, }), })); rerender(); fireEvent.click(stage, { clientX: 300, clientY: 150 }); await waitFor(() => expect(apiMock.predictMask).toHaveBeenNthCalledWith(3, { imageId: 'frame-1', imageWidth: 640, imageHeight: 360, model: 'sam2.1_hiera_tiny', points: [ { x: 150, y: 100, type: 'pos' }, { x: 300, y: 150, type: 'neg' }, ], box: { x1: 120, y1: 80, x2: 260, y2: 200 }, options: { auto_filter_background: true, min_score: 0.05 }, })); expect(useStore.getState().masks).toHaveLength(1); expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ id: 'mask-box', segmentation: [[30, 30, 70, 30, 70, 70]], points: [[150, 100]], metadata: expect.objectContaining({ promptPointCount: 2, promptNegativePointCount: 1 }), })); }); it('removes the SAM2 candidate when a negative point filters it out', async () => { apiMock.predictMask .mockResolvedValueOnce({ masks: [ { id: 'mask-box', pathData: 'M 10 10 L 90 10 L 90 90 Z', label: 'AI Mask', color: '#06b6d4', segmentation: [[10, 10, 90, 10, 90, 90]], bbox: [10, 10, 80, 80], area: 6400, }, ], }) .mockResolvedValueOnce({ masks: [] }); const { rerender } = render(); const stage = screen.getByTestId('konva-stage'); fireEvent.mouseDown(stage, { clientX: 120, clientY: 80 }); fireEvent.mouseMove(stage, { clientX: 260, clientY: 200 }); fireEvent.mouseUp(stage, { clientX: 260, clientY: 200 }); await waitFor(() => expect(useStore.getState().masks).toHaveLength(1)); rerender(); fireEvent.click(stage, { clientX: 180, clientY: 120 }); await waitFor(() => expect(apiMock.predictMask).toHaveBeenNthCalledWith(2, { imageId: 'frame-1', imageWidth: 640, imageHeight: 360, model: 'sam2.1_hiera_tiny', points: [{ x: 180, y: 120, type: 'neg' }], box: { x1: 120, y1: 80, x2: 260, y2: 200 }, options: { auto_filter_background: true, min_score: 0.05 }, })); await waitFor(() => expect(useStore.getState().masks).toHaveLength(0)); expect(await screen.findByText(/反向点已排除当前候选区域/)).toBeInTheDocument(); }); it('deletes a workspace SAM2 prompt point before the stage can add another point', async () => { apiMock.predictMask .mockResolvedValueOnce({ masks: [ { id: 'mask-prompt', pathData: 'M 10 10 L 90 10 L 90 90 Z', label: 'AI Mask', color: '#06b6d4', segmentation: [[10, 10, 90, 10, 90, 90]], bbox: [10, 10, 80, 80], area: 6400, }, ], }) .mockResolvedValueOnce({ masks: [ { id: 'mask-refined', pathData: 'M 20 20 L 80 20 L 80 80 Z', label: 'AI Mask', color: '#06b6d4', segmentation: [[20, 20, 80, 20, 80, 80]], bbox: [20, 20, 60, 60], area: 3600, }, ], }) .mockResolvedValueOnce({ masks: [ { id: 'mask-after-delete', pathData: 'M 30 30 L 70 30 L 70 70 Z', label: 'AI Mask', color: '#06b6d4', segmentation: [[30, 30, 70, 30, 70, 70]], bbox: [30, 30, 40, 40], area: 1600, }, ], }); const { rerender } = render(); const stage = screen.getByTestId('konva-stage'); fireEvent.click(stage, { clientX: 120, clientY: 80 }); await waitFor(() => expect(apiMock.predictMask).toHaveBeenCalledTimes(1)); rerender(); fireEvent.click(stage, { clientX: 220, clientY: 140 }); await waitFor(() => expect(apiMock.predictMask).toHaveBeenCalledTimes(2)); const promptOuterCircles = () => screen.getAllByTestId('konva-circle') .filter((element) => ['#22c55e', '#ef4444'].includes(element.getAttribute('data-fill') || '')); expect(promptOuterCircles()).toHaveLength(2); fireEvent.click(promptOuterCircles()[0]); await waitFor(() => expect(apiMock.predictMask).toHaveBeenCalledTimes(3)); expect(apiMock.predictMask).toHaveBeenLastCalledWith({ imageId: 'frame-1', imageWidth: 640, imageHeight: 360, model: 'sam2.1_hiera_tiny', points: [{ x: 220, y: 140, type: 'neg' }], box: undefined, options: { auto_filter_background: true, min_score: 0.05 }, }); expect(promptOuterCircles()).toHaveLength(1); }); it('renders only masks that belong to the current frame', () => { useStore.setState({ masks: [ { id: 'm1', frameId: 'frame-1', pathData: 'M 0 0 Z', label: 'A', color: '#fff' }, { id: 'm2', frameId: 'frame-2', pathData: 'M 1 1 Z', label: 'B', color: '#000' }, ], }); render(); expect(screen.getAllByTestId('konva-path')).toHaveLength(1); expect(screen.getByText('遮罩数: 1')).toBeInTheDocument(); }); it('handles stage drag end when the move tool pans the canvas', () => { render(); expect(screen.getByTestId('konva-stage')).toHaveAttribute('data-has-drag-end', 'true'); }); it('centers the frame image with a large default fit that keeps margins', async () => { Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => 1000 }); Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => 700 }); render(); await waitFor(() => { const stage = screen.getByTestId('konva-stage'); expect(Number(stage.getAttribute('data-scale-x'))).toBeCloseTo(1.34375, 4); expect(Number(stage.getAttribute('data-x'))).toBeCloseTo(70, 0); expect(Number(stage.getAttribute('data-y'))).toBeCloseTo(108, 0); }); }); it('publishes the selected mask ids for the ontology panel', async () => { useStore.setState({ masks: [ { id: 'm1', annotationId: '42', frameId: 'frame-1', pathData: 'M 0 0 L 10 0 L 10 10 Z', label: '胆囊', color: '#fff', segmentation: [[0, 0, 10, 0, 10, 10]], }, ], }); render(); fireEvent.click(screen.getByTestId('konva-path')); await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['m1'])); expect(screen.getByText('当前图层: 胆囊 #42')).toBeInTheDocument(); }); it('keeps a mask selected when opening the workspace polygon editor from AI results', () => { useStore.setState({ selectedMaskIds: ['m1'], masks: [ { id: 'm1', frameId: 'frame-1', pathData: 'M 0 0 L 10 0 L 10 10 Z', label: 'A', color: '#fff', segmentation: [[0, 0, 10, 0, 10, 10]], }, ], }); render(); expect(useStore.getState().selectedMaskIds).toEqual(['m1']); expect(screen.getAllByTestId('konva-circle') .filter((element) => element.getAttribute('data-fill') === '#ffffff')).toHaveLength(3); }); it('selects the linked propagated mask when switching from the seed frame', async () => { const propagatedFrame = { ...frame, id: 'frame-2', index: 1, url: '/frame-2.jpg' }; useStore.setState({ selectedMaskIds: ['annotation-7'], masks: [ { id: 'annotation-7', annotationId: '7', frameId: 'frame-1', pathData: 'M 0 0 L 10 0 L 10 10 Z', label: '胆囊', color: '#facc15', segmentation: [[0, 0, 10, 0, 10, 10]], }, { id: 'annotation-20', annotationId: '20', frameId: 'frame-2', pathData: 'M 1 1 L 11 1 L 11 11 Z', label: '胆囊', color: '#facc15', segmentation: [[1, 1, 11, 1, 11, 11]], metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 7, source_mask_id: 'annotation-7', propagation_seed_key: 'annotation:7', }, }, ], }); const { rerender } = render(); rerender(); await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['annotation-20'])); expect(screen.getByText('当前图层: 胆囊 #20')).toBeInTheDocument(); }); it('keeps following the same propagation chain between propagated frames', async () => { const propagatedFrame = { ...frame, id: 'frame-2', index: 1, url: '/frame-2.jpg' }; const laterPropagatedFrame = { ...frame, id: 'frame-3', index: 2, url: '/frame-3.jpg' }; useStore.setState({ selectedMaskIds: ['annotation-20'], masks: [ { id: 'annotation-20', annotationId: '20', frameId: 'frame-2', pathData: 'M 1 1 L 11 1 L 11 11 Z', label: '胆囊', color: '#facc15', segmentation: [[1, 1, 11, 1, 11, 11]], metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 7, source_mask_id: 'annotation-7', propagation_seed_key: 'annotation:7', }, }, { id: 'annotation-21', annotationId: '21', frameId: 'frame-3', pathData: 'M 2 2 L 12 2 L 12 12 Z', label: '胆囊', color: '#facc15', segmentation: [[2, 2, 12, 2, 12, 12]], metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 7, source_mask_id: 'annotation-7', propagation_seed_key: 'annotation:7', }, }, ], }); const { rerender } = render(); rerender(); await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['annotation-21'])); expect(screen.getByText('当前图层: 胆囊 #21')).toBeInTheDocument(); }); it('does not render stored GT seed points as visible editable handles', () => { useStore.setState({ masks: [ { id: 'gt-1', frameId: 'frame-1', pathData: 'M 0 0 L 10 0 L 10 10 Z', label: 'GT', color: '#22c55e', points: [[120, 80]], }, ], }); render(); expect(screen.queryAllByTestId('konva-circle') .filter((element) => element.getAttribute('data-fill') === '#facc15')).toHaveLength(0); }); it('does not derive visible seed points for ordinary polygon masks', () => { useStore.setState({ masks: [ { id: 'manual-1', frameId: 'frame-1', pathData: 'M 10 10 L 90 10 L 90 40 Z', label: 'Manual', color: '#06b6d4', segmentation: [[10, 10, 90, 10, 90, 40]], }, ], }); render(); expect(screen.queryAllByTestId('konva-circle') .filter((element) => element.getAttribute('data-fill') === '#facc15')).toHaveLength(0); }); it('shows editable vertices for inner rings of hollow polygon masks', () => { useStore.setState({ selectedMaskIds: ['hollow-1'], masks: [ { id: 'hollow-1', frameId: 'frame-1', pathData: 'M 10 10 L 90 10 L 90 90 L 10 90 Z M 35 35 L 65 35 L 65 65 L 35 65 Z', label: 'Hollow', color: '#06b6d4', segmentation: [ [10, 10, 90, 10, 90, 90, 10, 90], [35, 35, 65, 35, 65, 65, 35, 65], ], metadata: { hasHoles: true, polygonRingCounts: [2] }, }, ], }); render(); const editableVertices = screen.queryAllByTestId('konva-circle') .filter((element) => element.getAttribute('data-fill') === '#ffffff'); expect(editableVertices).toHaveLength(8); expect(editableVertices.map((element) => [element.getAttribute('data-x'), element.getAttribute('data-y')])) .toContainEqual(['35', '35']); }); it('selects a polygon mask and drags a vertex into dirty saved state', () => { useStore.setState({ masks: [ { id: 'annotation-99', annotationId: '99', frameId: 'frame-1', pathData: 'M 10 10 L 90 10 L 90 40 Z', label: 'Saved', color: '#06b6d4', saved: true, saveStatus: 'saved', segmentation: [[10, 10, 90, 10, 90, 40]], bbox: [10, 10, 80, 30], }, ], }); render(); fireEvent.click(screen.getByTestId('konva-path')); const handles = screen.getAllByTestId('konva-circle') .filter((element) => element.getAttribute('data-fill') === '#ffffff'); expect(handles).toHaveLength(3); fireEvent.mouseUp(handles[0], { clientX: 20, clientY: 30 }); expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ pathData: 'M 20 30 L 90 10 L 90 40 Z', segmentation: [[20, 30, 90, 10, 90, 40]], bbox: [20, 10, 70, 30], area: 1050, saveStatus: 'dirty', saved: false, })); }); it('moves a polygon vertex directly while dragging without a prior vertex click', () => { useStore.setState({ selectedMaskIds: ['draft-1'], masks: [ { id: 'draft-1', frameId: 'frame-1', pathData: 'M 10 10 L 90 10 L 90 40 Z', label: 'Draft', color: '#06b6d4', saveStatus: 'draft', segmentation: [[10, 10, 90, 10, 90, 40]], bbox: [10, 10, 80, 30], }, ], }); render(); const handles = screen.getAllByTestId('konva-circle') .filter((element) => element.getAttribute('data-fill') === '#ffffff'); fireEvent.mouseDown(handles[0]); fireEvent.mouseMove(handles[0], { clientX: 25, clientY: 35 }); expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ pathData: 'M 25 35 L 90 10 L 90 40 Z', segmentation: [[25, 35, 90, 10, 90, 40]], saveStatus: 'draft', })); }); it('does not pan or recenter the stage when a polygon vertex drag ends', () => { useStore.setState({ selectedMaskIds: ['draft-1'], masks: [ { id: 'draft-1', frameId: 'frame-1', pathData: 'M 10 10 L 90 10 L 90 40 Z', label: 'Draft', color: '#06b6d4', saveStatus: 'draft', segmentation: [[10, 10, 90, 10, 90, 40]], bbox: [10, 10, 80, 30], }, ], }); render(); const stage = screen.getByTestId('konva-stage'); const initialX = stage.getAttribute('data-x'); const initialY = stage.getAttribute('data-y'); const handles = screen.getAllByTestId('konva-circle') .filter((element) => element.getAttribute('data-fill') === '#ffffff'); fireEvent.mouseUp(handles[0], { clientX: 25, clientY: 35 }); fireEvent.dragEnd(handles[0], { clientX: 25, clientY: 35 }); expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ pathData: 'M 25 35 L 90 10 L 90 40 Z', segmentation: [[25, 35, 90, 10, 90, 40]], })); expect(screen.getByTestId('konva-stage')).toHaveAttribute('data-x', initialX || ''); expect(screen.getByTestId('konva-stage')).toHaveAttribute('data-y', initialY || ''); }); it('deletes a selected polygon vertex without dropping below three points', () => { useStore.setState({ masks: [ { id: 'draft-1', frameId: 'frame-1', pathData: 'M 10 10 L 90 10 L 90 40 L 10 40 Z', label: 'Draft', color: '#06b6d4', saveStatus: 'draft', segmentation: [[10, 10, 90, 10, 90, 40, 10, 40]], bbox: [10, 10, 80, 30], }, ], }); render(); fireEvent.click(screen.getByTestId('konva-path')); const handles = screen.getAllByTestId('konva-circle') .filter((element) => element.getAttribute('data-fill') === '#ffffff'); fireEvent.click(handles[0]); fireEvent.keyDown(window, { key: 'Delete' }); expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ pathData: 'M 90 10 L 90 40 L 10 40 Z', segmentation: [[90, 10, 90, 40, 10, 40]], saveStatus: 'draft', })); }); it('deletes the selected draft mask with Delete when no vertex is selected', () => { useStore.setState({ masks: [ { id: 'draft-1', frameId: 'frame-1', pathData: 'M 10 10 L 90 10 L 90 40 Z', label: 'Draft', color: '#06b6d4', saveStatus: 'draft', segmentation: [[10, 10, 90, 10, 90, 40]], }, ], }); render(); fireEvent.click(screen.getByTestId('konva-path')); fireEvent.keyDown(window, { key: 'Delete' }); expect(useStore.getState().masks).toEqual([]); expect(useStore.getState().maskHistory.at(-1)).toEqual([ expect.objectContaining({ id: 'draft-1' }), ]); }); it('deletes the selected saved mask locally and notifies the backend deletion callback', () => { const onDeleteMaskAnnotations = vi.fn(); useStore.setState({ masks: [ { id: 'annotation-99', annotationId: '99', frameId: 'frame-1', pathData: 'M 10 10 L 90 10 L 90 40 Z', label: 'Saved', color: '#06b6d4', saveStatus: 'saved', saved: true, segmentation: [[10, 10, 90, 10, 90, 40]], }, ], }); render(); fireEvent.click(screen.getByTestId('konva-path')); fireEvent.keyDown(window, { key: 'Backspace' }); expect(useStore.getState().masks).toEqual([]); expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(['99']); }); it('deletes linked propagated masks while keeping independent AI inference masks', () => { const onDeleteMaskAnnotations = vi.fn(); const propagatedFrame = { ...frame, id: 'frame-2', index: 1, url: '/frame-2.jpg' }; useStore.setState({ masks: [ { id: 'annotation-99', annotationId: '99', frameId: 'frame-1', pathData: 'M 10 10 L 90 10 L 90 40 Z', label: 'Seed', color: '#06b6d4', saveStatus: 'saved', saved: true, segmentation: [[10, 10, 90, 10, 90, 40]], }, { id: 'annotation-100', annotationId: '100', frameId: 'frame-2', pathData: 'M 12 10 L 92 10 L 92 40 Z', label: 'Propagated A', color: '#06b6d4', saveStatus: 'saved', saved: true, segmentation: [[12, 10, 92, 10, 92, 40]], metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 99, source_mask_id: 'annotation-99', propagation_seed_key: 'annotation:99', }, }, { id: 'annotation-101', annotationId: '101', frameId: 'frame-3', pathData: 'M 14 10 L 94 10 L 94 40 Z', label: 'Propagated B', color: '#06b6d4', saveStatus: 'saved', saved: true, segmentation: [[14, 10, 94, 10, 94, 40]], metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 99, source_mask_id: 'annotation-99', propagation_seed_key: 'annotation:99', }, }, { id: 'annotation-102', annotationId: '102', frameId: 'frame-3', pathData: 'M 200 10 L 260 10 L 260 40 Z', label: 'AI Candidate', color: '#22c55e', saveStatus: 'saved', saved: true, segmentation: [[200, 10, 260, 10, 260, 40]], metadata: { source: 'ai_segmentation' }, }, ], }); render(); fireEvent.click(screen.getByTestId('konva-path')); fireEvent.keyDown(window, { key: 'Delete' }); expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['annotation-99', 'annotation-102']); expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(['100', '101']); }); it('inserts a polygon vertex from an edge midpoint handle', () => { useStore.setState({ masks: [ { id: 'draft-1', frameId: 'frame-1', pathData: 'M 10 10 L 90 10 L 90 40 Z', label: 'Draft', color: '#06b6d4', saveStatus: 'draft', segmentation: [[10, 10, 90, 10, 90, 40]], bbox: [10, 10, 80, 30], }, ], }); render(); fireEvent.click(screen.getByTestId('konva-path')); const edgeHandles = screen.getAllByTestId('konva-circle') .filter((element) => element.getAttribute('data-fill') === '#22d3ee'); fireEvent.click(edgeHandles[0]); expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ segmentation: [[10, 10, 50, 10, 90, 10, 90, 40]], pathData: 'M 10 10 L 50 10 L 90 10 L 90 40 Z', saveStatus: 'draft', })); }); it('selects a polygon with the edit tool and inserts a vertex by double-clicking an edge', () => { useStore.setState({ masks: [ { id: 'draft-1', frameId: 'frame-1', pathData: 'M 10 10 L 90 10 L 90 40 Z', label: 'Draft', color: '#06b6d4', saveStatus: 'draft', segmentation: [[10, 10, 90, 10, 90, 40]], bbox: [10, 10, 80, 30], }, ], }); render(); const path = screen.getByTestId('konva-path'); fireEvent.click(path); fireEvent.doubleClick(path, { clientX: 50, clientY: 10 }); expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ segmentation: [[10, 10, 50, 10, 90, 10, 90, 40]], pathData: 'M 10 10 L 50 10 L 90 10 L 90 40 Z', saveStatus: 'draft', })); }); it('edits the selected polygon in a multi-polygon mask', () => { useStore.setState({ masks: [ { id: 'multi-1', frameId: 'frame-1', pathData: 'M 10 10 L 50 10 L 50 40 Z M 100 100 L 150 100 L 150 140 Z', label: 'Multi', color: '#06b6d4', saveStatus: 'draft', segmentation: [ [10, 10, 50, 10, 50, 40], [100, 100, 150, 100, 150, 140], ], bbox: [10, 10, 140, 130], }, ], }); render(); const paths = screen.getAllByTestId('konva-path'); fireEvent.click(paths[1]); const vertexHandles = screen.getAllByTestId('konva-circle') .filter((element) => element.getAttribute('data-fill') === '#ffffff'); expect(vertexHandles).toHaveLength(6); fireEvent.mouseUp(vertexHandles[0], { clientX: 120, clientY: 120 }); expect(useStore.getState().masks[0].segmentation).toEqual([ [120, 120, 50, 10, 50, 40], [100, 100, 150, 100, 150, 140], ]); }); it('merges selected draft masks with polygon union', () => { useStore.setState({ masks: [ { id: 'm1', frameId: 'frame-1', pathData: 'M 10 10 L 90 10 L 90 50 L 10 50 Z', label: 'A', color: '#06b6d4', segmentation: [[10, 10, 90, 10, 90, 50, 10, 50]], }, { id: 'm2', frameId: 'frame-1', pathData: 'M 50 30 L 120 30 L 120 80 L 50 80 Z', label: 'B', color: '#ff0000', segmentation: [[50, 30, 120, 30, 120, 80, 50, 80]], }, ], }); render(); expect(screen.getByText('已选 0')).toBeInTheDocument(); const paths = screen.getAllByTestId('konva-path'); fireEvent.click(paths[0]); expect(screen.getByText('已选 1')).toBeInTheDocument(); expect(screen.queryAllByTestId('konva-circle') .filter((element) => element.getAttribute('data-fill') === '#ffffff')).toHaveLength(0); fireEvent.click(paths[1]); expect(screen.getByText('已选 2')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '合并选中' })); expect(useStore.getState().masks).toHaveLength(1); expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ id: 'm1', segmentation: [[10, 10, 90, 10, 90, 30, 120, 30, 120, 80, 50, 80, 50, 50, 10, 50]], bbox: [10, 10, 110, 70], saveStatus: 'draft', })); }); it('merges corresponding propagated masks on other frames without dropping the propagation lineage', async () => { const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined); useStore.setState({ masks: [ { id: 'annotation-1', annotationId: '1', frameId: 'frame-1', pathData: 'M 10 10 L 60 10 L 60 60 L 10 60 Z', label: 'A', color: '#06b6d4', segmentation: [[10, 10, 60, 10, 60, 60, 10, 60]], saved: true, saveStatus: 'saved', }, { id: 'annotation-2', annotationId: '2', frameId: 'frame-1', pathData: 'M 50 50 L 100 50 L 100 100 L 50 100 Z', label: 'A', color: '#06b6d4', segmentation: [[50, 50, 100, 50, 100, 100, 50, 100]], saved: true, saveStatus: 'saved', }, { id: 'annotation-10', annotationId: '10', frameId: 'frame-2', pathData: 'M 12 12 L 62 12 L 62 62 L 12 62 Z', label: 'A', color: '#06b6d4', segmentation: [[12, 12, 62, 12, 62, 62, 12, 62]], saved: true, saveStatus: 'saved', metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' }, }, { id: 'annotation-20', annotationId: '20', frameId: 'frame-2', pathData: 'M 52 52 L 102 52 L 102 102 L 52 102 Z', label: 'A', color: '#06b6d4', segmentation: [[52, 52, 102, 52, 102, 102, 52, 102]], saved: true, saveStatus: 'saved', metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2, source_mask_id: 'annotation-2', propagation_seed_key: 'annotation:2' }, }, ], }); render(); const paths = screen.getAllByTestId('konva-path'); fireEvent.click(paths[0]); fireEvent.click(paths[1]); fireEvent.click(screen.getByRole('button', { name: '合并选中' })); expect(screen.getByText('选择操作范围')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '处理所有传播帧' })); await waitFor(() => expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(expect.arrayContaining(['2', '20']))); const masks = useStore.getState().masks; expect(masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10']); expect(masks.find((mask) => mask.id === 'annotation-10')).toEqual(expect.objectContaining({ saveStatus: 'dirty', saved: false, metadata: expect.objectContaining({ source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1 }), })); }); it('can hand propagated boolean operations to the workspace frame range selector', () => { const onRequestBooleanFrameRange = vi.fn(); useStore.setState({ masks: [ { id: 'annotation-1', annotationId: '1', frameId: 'frame-1', pathData: 'M 10 10 L 60 10 L 60 60 L 10 60 Z', label: 'A', color: '#06b6d4', segmentation: [[10, 10, 60, 10, 60, 60, 10, 60]], }, { id: 'annotation-2', annotationId: '2', frameId: 'frame-1', pathData: 'M 50 50 L 100 50 L 100 100 L 50 100 Z', label: 'A', color: '#06b6d4', segmentation: [[50, 50, 100, 50, 100, 100, 50, 100]], }, { id: 'annotation-10', annotationId: '10', frameId: 'frame-2', pathData: 'M 12 12 L 62 12 L 62 62 L 12 62 Z', label: 'A', color: '#06b6d4', segmentation: [[12, 12, 62, 12, 62, 62, 12, 62]], metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1, propagation_seed_key: 'annotation:1' }, }, { id: 'annotation-20', annotationId: '20', frameId: 'frame-2', pathData: 'M 52 52 L 102 52 L 102 102 L 52 102 Z', label: 'A', color: '#06b6d4', segmentation: [[52, 52, 102, 52, 102, 102, 52, 102]], metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2, propagation_seed_key: 'annotation:2' }, }, ], }); render(); const paths = screen.getAllByTestId('konva-path'); fireEvent.click(paths[0]); fireEvent.click(paths[1]); fireEvent.click(screen.getByRole('button', { name: '合并选中' })); fireEvent.click(screen.getByRole('button', { name: '按帧范围选择' })); expect(onRequestBooleanFrameRange).toHaveBeenCalledWith(expect.objectContaining({ operation: 'area_merge', currentFrameId: 'frame-1', candidateFrameIds: expect.arrayContaining(['frame-1', 'frame-2']), selectedMaskIds: ['annotation-1', 'annotation-2'], execute: expect.any(Function), })); expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10', 'annotation-2', 'annotation-20']); }); it('removes overlap from the primary selected mask with polygon difference', () => { useStore.setState({ masks: [ { id: 'm1', frameId: 'frame-1', pathData: 'M 10 10 L 90 10 L 90 50 L 10 50 Z', label: 'A', color: '#06b6d4', segmentation: [[10, 10, 90, 10, 90, 50, 10, 50]], }, { id: 'm2', frameId: 'frame-1', pathData: 'M 50 30 L 120 30 L 120 80 L 50 80 Z', label: 'B', color: '#ff0000', segmentation: [[50, 30, 120, 30, 120, 80, 50, 80]], }, ], }); render(); const paths = screen.getAllByTestId('konva-path'); fireEvent.click(paths[0]); fireEvent.click(paths[1]); const selectedPaths = screen.getAllByTestId('konva-path'); expect(selectedPaths[0]).toHaveAttribute('data-stroke', '#facc15'); expect(selectedPaths[0]).toHaveAttribute('data-dash', ''); expect(selectedPaths[1]).toHaveAttribute('data-stroke', '#fb7185'); const scale = Number(screen.getByTestId('konva-stage').getAttribute('data-scale-x')) || 1; const dash = selectedPaths[1].getAttribute('data-dash')?.split(',').map(Number); expect(dash?.[0]).toBeCloseTo(6 / scale, 4); expect(dash?.[1]).toBeCloseTo(4 / scale, 4); fireEvent.click(screen.getByRole('button', { name: '从主区域去除' })); expect(useStore.getState().masks).toHaveLength(2); expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ id: 'm1', segmentation: [[10, 10, 90, 10, 90, 30, 50, 30, 50, 50, 10, 50]], bbox: [10, 10, 80, 40], saveStatus: 'draft', })); expect(useStore.getState().masks[1].id).toBe('m2'); }); it('removes overlap from corresponding propagated masks while preserving secondary masks', async () => { const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined); useStore.setState({ masks: [ { id: 'annotation-1', annotationId: '1', frameId: 'frame-1', pathData: 'M 10 10 L 90 10 L 90 70 L 10 70 Z', label: 'A', color: '#06b6d4', segmentation: [[10, 10, 90, 10, 90, 70, 10, 70]], saved: true, saveStatus: 'saved', }, { id: 'annotation-2', annotationId: '2', frameId: 'frame-1', pathData: 'M 50 30 L 120 30 L 120 80 L 50 80 Z', label: 'B', color: '#ff0000', segmentation: [[50, 30, 120, 30, 120, 80, 50, 80]], saved: true, saveStatus: 'saved', }, { id: 'annotation-10', annotationId: '10', frameId: 'frame-2', pathData: 'M 12 12 L 92 12 L 92 72 L 12 72 Z', label: 'A', color: '#06b6d4', segmentation: [[12, 12, 92, 12, 92, 72, 12, 72]], saved: true, saveStatus: 'saved', metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' }, }, { id: 'annotation-20', annotationId: '20', frameId: 'frame-2', pathData: 'M 52 32 L 122 32 L 122 82 L 52 82 Z', label: 'B', color: '#ff0000', segmentation: [[52, 32, 122, 32, 122, 82, 52, 82]], saved: true, saveStatus: 'saved', metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2, source_mask_id: 'annotation-2', propagation_seed_key: 'annotation:2' }, }, ], }); render(); const paths = screen.getAllByTestId('konva-path'); fireEvent.click(paths[0]); fireEvent.click(paths[1]); fireEvent.click(screen.getByRole('button', { name: '从主区域去除' })); expect(screen.getByText('选择操作范围')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '处理所有传播帧' })); await waitFor(() => expect(useStore.getState().masks.find((mask) => mask.id === 'annotation-10')?.saveStatus).toBe('dirty')); expect(onDeleteMaskAnnotations).not.toHaveBeenCalled(); expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10', 'annotation-2', 'annotation-20']); expect(useStore.getState().masks.find((mask) => mask.id === 'annotation-10')).toEqual(expect.objectContaining({ saved: false, metadata: expect.objectContaining({ source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1 }), })); expect(useStore.getState().masks.find((mask) => mask.id === 'annotation-20')).toEqual(expect.objectContaining({ saveStatus: 'saved', metadata: expect.objectContaining({ source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2 }), })); }); it('renders inner overlap removal as a hole in the primary mask', () => { useStore.setState({ masks: [ { id: 'm1', frameId: 'frame-1', pathData: 'M 10 10 L 110 10 L 110 110 L 10 110 Z', label: 'A', color: '#06b6d4', segmentation: [[10, 10, 110, 10, 110, 110, 10, 110]], }, { id: 'm2', frameId: 'frame-1', pathData: 'M 40 40 L 80 40 L 80 80 L 40 80 Z', label: 'B', color: '#ff0000', segmentation: [[40, 40, 80, 40, 80, 80, 40, 80]], }, ], }); render(); const paths = screen.getAllByTestId('konva-path'); fireEvent.click(paths[0]); fireEvent.click(paths[1]); fireEvent.click(screen.getByRole('button', { name: '从主区域去除' })); const [primary] = useStore.getState().masks; expect(primary).toEqual(expect.objectContaining({ id: 'm1', area: 8400, bbox: [10, 10, 100, 100], metadata: expect.objectContaining({ hasHoles: true }), })); expect(primary.segmentation).toHaveLength(2); expect(screen.getAllByTestId('konva-path')[0]).toHaveAttribute('data-fill-rule', 'evenodd'); }); it('creates a manual rectangle mask that can be undone and redone', () => { useStore.setState({ activeTemplateId: '2', activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 }, activeClassId: 'c1', }); render(); const stage = screen.getByTestId('konva-stage'); fireEvent.mouseDown(stage); fireEvent.mouseMove(stage); fireEvent.mouseUp(stage); expect(useStore.getState().masks).toHaveLength(1); expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ frameId: 'frame-1', label: '胆囊', color: '#ff0000', saveStatus: 'draft', segmentation: [[120, 80, 260, 80, 260, 200, 120, 200]], bbox: [120, 80, 140, 120], })); useStore.getState().undoMasks(); expect(useStore.getState().masks).toEqual([]); useStore.getState().redoMasks(); expect(useStore.getState().masks).toHaveLength(1); }); it('creates a manual circle mask from a drag gesture', () => { render(); const stage = screen.getByTestId('konva-stage'); fireEvent.mouseDown(stage, { clientX: 120, clientY: 80 }); fireEvent.mouseMove(stage, { clientX: 260, clientY: 200 }); fireEvent.mouseUp(stage, { clientX: 260, clientY: 200 }); expect(useStore.getState().masks).toHaveLength(1); expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ frameId: 'frame-1', label: '手工圆形', color: '#06b6d4', saveStatus: 'draft', bbox: [120, 80, 140, 120], metadata: expect.objectContaining({ source: 'manual', shape: '圆形', }), })); expect(useStore.getState().masks[0].segmentation?.[0]).toHaveLength(64); }); it('creates a brush mask when a semantic class is selected', () => { useStore.setState({ activeTemplateId: '2', activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 }, activeClassId: 'c1', }); render(); const stage = screen.getByTestId('konva-stage'); fireEvent.mouseDown(stage, { clientX: 120, clientY: 80 }); fireEvent.mouseMove(stage, { clientX: 180, clientY: 120 }); fireEvent.mouseUp(stage, { clientX: 260, clientY: 200 }); expect(useStore.getState().masks).toHaveLength(1); expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ frameId: 'frame-1', label: '胆囊', color: '#ff0000', classId: 'c1', classMaskId: 1, saveStatus: 'draft', metadata: expect.objectContaining({ source: 'manual', shape: '画笔', }), })); expect(useStore.getState().masks[0].segmentation?.length).toBeGreaterThan(0); expect(useStore.getState().masks[0].area).toBeGreaterThan(1000); }); it('merges a connected brush stroke into the selected mask', () => { useStore.setState({ activeTemplateId: '2', activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 }, activeClassId: 'c1', selectedMaskIds: ['m1'], masks: [ { id: 'm1', frameId: 'frame-1', pathData: 'M 100 70 L 150 70 L 150 120 L 100 120 Z', label: '胆囊', color: '#ff0000', classId: 'c1', segmentation: [[100, 70, 150, 70, 150, 120, 100, 120]], area: 2500, }, ], }); render(); const stage = screen.getByTestId('konva-stage'); fireEvent.mouseDown(stage, { clientX: 130, clientY: 90 }); fireEvent.mouseMove(stage, { clientX: 170, clientY: 100 }); fireEvent.mouseUp(stage, { clientX: 210, clientY: 110 }); expect(useStore.getState().masks).toHaveLength(1); expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ id: 'm1', label: '胆囊', color: '#ff0000', saveStatus: 'draft', })); expect(useStore.getState().masks[0].area).toBeGreaterThan(2500); expect(useStore.getState().selectedMaskIds).toEqual(['m1']); }); it('erases from the selected mask with a sampled stroke', () => { useStore.setState({ selectedMaskIds: ['m1'], masks: [ { id: 'm1', frameId: 'frame-1', pathData: 'M 10 10 L 300 10 L 300 220 L 10 220 Z', label: 'Existing', color: '#06b6d4', segmentation: [[10, 10, 300, 10, 300, 220, 10, 220]], area: 60900, }, ], }); render(); const stage = screen.getByTestId('konva-stage'); fireEvent.mouseDown(stage, { clientX: 120, clientY: 80 }); fireEvent.mouseMove(stage, { clientX: 180, clientY: 120 }); fireEvent.mouseUp(stage, { clientX: 260, clientY: 200 }); expect(useStore.getState().masks).toHaveLength(1); expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ id: 'm1', saveStatus: 'draft', })); expect(useStore.getState().masks[0].area).toBeLessThan(60900); expect(useStore.getState().selectedMaskIds).toEqual(['m1']); }); it('finalizes a clicked polygon with Enter', () => { render(); const stage = screen.getByTestId('konva-stage'); expect(screen.getByText(/点击画布添加顶点/)).toBeInTheDocument(); fireEvent.click(stage, { clientX: 120, clientY: 80 }); fireEvent.click(stage, { clientX: 220, clientY: 80 }); fireEvent.click(stage, { clientX: 180, clientY: 160 }); expect(screen.getByText(/点击黄色首节点或按 Enter 闭合完成/)).toBeInTheDocument(); fireEvent.keyDown(window, { key: 'Enter' }); expect(useStore.getState().masks).toHaveLength(1); expect(useStore.getState().masks[0].metadata).toEqual(expect.objectContaining({ source: 'manual', shape: '多边形', })); }); it('closes a clicked polygon by clicking the first node again', () => { render(); const stage = screen.getByTestId('konva-stage'); fireEvent.click(stage, { clientX: 120, clientY: 80 }); fireEvent.click(stage, { clientX: 220, clientY: 80 }); fireEvent.click(stage, { clientX: 180, clientY: 160 }); const handles = screen.getAllByTestId('konva-circle'); expect(handles[0]).toHaveAttribute('data-fill', '#facc15'); fireEvent.click(handles[0]); expect(useStore.getState().masks).toHaveLength(1); expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ pathData: 'M 120 80 L 220 80 L 180 160 Z', segmentation: [[120, 80, 220, 80, 180, 160]], metadata: expect.objectContaining({ source: 'manual', shape: '多边形', }), })); expect(screen.queryAllByTestId('konva-circle') .filter((element) => element.getAttribute('data-fill') === '#facc15')).toHaveLength(0); }); it('shows contextual guidance for boolean selection ordering', () => { useStore.setState({ masks: [ { id: 'm1', frameId: 'frame-1', pathData: 'M 10 10 L 90 10 L 90 50 Z', label: 'A', color: '#06b6d4', segmentation: [[10, 10, 90, 10, 90, 50]], }, { id: 'm2', frameId: 'frame-1', pathData: 'M 50 30 L 120 30 L 120 80 Z', label: 'B', color: '#ff0000', segmentation: [[50, 30, 120, 30, 120, 80]], }, ], }); render(); expect(screen.getByText(/先点击要保留的主区域/)).toBeInTheDocument(); fireEvent.click(screen.getAllByTestId('konva-path')[0]); expect(screen.getByText(/第一个是保留主区域/)).toBeInTheDocument(); }); it('auto-hides contextual tool guidance after a few seconds', () => { vi.useFakeTimers(); render(); expect(screen.getByText('创建矩形')).toBeInTheDocument(); act(() => { vi.advanceTimersByTime(3600); }); expect(screen.queryByText('创建矩形')).not.toBeInTheDocument(); vi.useRealTimers(); }); it('renders unselected masks by semantic tree layer priority', () => { useStore.setState({ selectedMaskIds: [], masks: [ { id: 'high', frameId: 'frame-1', pathData: 'M 0 0 Z', label: '高优先级', color: '#ef4444', classZIndex: 30, }, { id: 'low', frameId: 'frame-1', pathData: 'M 1 1 Z', label: '低优先级', color: '#22c55e', classZIndex: 10, }, ], }); render(); const paths = screen.getAllByTestId('konva-path'); expect(paths.map((path) => path.getAttribute('data-fill'))).toEqual(['#22c55e', '#ef4444']); }); it('does not render duplicate bottom-right clear or class action buttons', () => { useStore.setState({ activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 }, masks: [ { id: 'm1', frameId: 'frame-1', pathData: 'M 0 0 Z', label: 'A', color: '#fff' }, ], }); render(); expect(screen.queryByRole('button', { name: '清空遮罩' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '应用分类' })).not.toBeInTheDocument(); }); });