diff --git a/src/components/CanvasArea.test.tsx b/src/components/CanvasArea.test.tsx new file mode 100644 index 0000000..3f88470 --- /dev/null +++ b/src/components/CanvasArea.test.tsx @@ -0,0 +1,2326 @@ +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('clears only the selected mask state with Escape', async () => { + useStore.setState({ + activeTemplateId: '2', + activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 }, + activeClassId: 'c1', + selectedMaskIds: ['m1'], + masks: [ + { + id: 'm1', + frameId: 'frame-1', + pathData: 'M 10 10 L 80 10 L 80 80 L 10 80 Z', + label: '胆囊', + color: '#ff0000', + classId: 'c1', + segmentation: [[10, 10, 80, 10, 80, 80, 10, 80]], + }, + ], + }); + + render(); + fireEvent.keyDown(window, { key: 'Escape' }); + + await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([])); + expect(useStore.getState().masks).toHaveLength(1); + expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ + id: 'm1', + label: '胆囊', + color: '#ff0000', + })); + expect(useStore.getState().activeClassId).toBe('c1'); + }); + + it('clears selected masks when the toolbar clear-selection signal changes', async () => { + useStore.setState({ + activeTemplateId: '2', + activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 }, + activeClassId: 'c1', + selectedMaskIds: ['m1'], + masks: [ + { + id: 'm1', + frameId: 'frame-1', + pathData: 'M 10 10 L 80 10 L 80 80 L 10 80 Z', + label: '胆囊', + color: '#ff0000', + classId: 'c1', + segmentation: [[10, 10, 80, 10, 80, 80, 10, 80]], + }, + ], + }); + + const { rerender } = render(); + expect(useStore.getState().selectedMaskIds).toEqual(['m1']); + + rerender(); + + await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([])); + expect(useStore.getState().masks).toHaveLength(1); + expect(useStore.getState().activeClassId).toBe('c1'); + }); + + 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('selects all separated propagated pieces with the same seed signature on the frame', () => { + useStore.setState({ + maskPreviewOpacity: 35, + masks: [ + { + id: 'annotation-20', + annotationId: '20', + frameId: 'frame-1', + pathData: 'M 10 10 L 30 10 L 30 30 Z', + label: 'Merged object', + color: '#facc15', + segmentation: [[10, 10, 30, 10, 30, 30]], + metadata: { + source: 'sam2.1_hiera_tiny_propagation', + propagated_from_frame_id: 'seed-frame', + propagation_seed_signature: 'merged-seed-signature', + }, + }, + { + id: 'annotation-21', + annotationId: '21', + frameId: 'frame-1', + pathData: 'M 80 80 L 100 80 L 100 100 Z', + label: 'Merged object', + color: '#facc15', + segmentation: [[80, 80, 100, 80, 100, 100]], + metadata: { + source: 'sam2.1_hiera_tiny_propagation', + propagated_from_frame_id: 'seed-frame', + propagation_seed_signature: 'merged-seed-signature', + }, + }, + ], + }); + + render(); + fireEvent.click(screen.getAllByTestId('konva-path')[0]); + + expect(useStore.getState().selectedMaskIds).toEqual(['annotation-20', 'annotation-21']); + expect(screen.getAllByTestId('konva-group').map((group) => group.getAttribute('data-opacity'))).toEqual(['0.5', '0.5']); + }); + + it('selects legacy separated propagated pieces without stable seed ids on the frame', () => { + useStore.setState({ + maskPreviewOpacity: 35, + masks: [ + { + id: 'annotation-30', + annotationId: '30', + frameId: 'frame-1', + pathData: 'M 10 10 L 30 10 L 30 30 Z', + label: '胆囊', + color: '#facc15', + segmentation: [[10, 10, 30, 10, 30, 30]], + metadata: { + source: 'sam2_propagation', + propagated_from_frame_id: 'seed-frame', + }, + }, + { + id: 'annotation-31', + annotationId: '31', + frameId: 'frame-1', + pathData: 'M 80 80 L 100 80 L 100 100 Z', + label: '胆囊', + color: '#facc15', + segmentation: [[80, 80, 100, 80, 100, 100]], + metadata: { + source: 'sam2_propagation', + propagated_from_frame_id: 'seed-frame', + }, + }, + { + id: 'annotation-40', + annotationId: '40', + frameId: 'frame-1', + pathData: 'M 130 130 L 150 130 L 150 150 Z', + label: '肝脏', + color: '#22c55e', + segmentation: [[130, 130, 150, 130, 150, 150]], + metadata: { + source: 'sam2_propagation', + propagated_from_frame_id: 'seed-frame', + }, + }, + ], + }); + + render(); + fireEvent.click(screen.getAllByTestId('konva-path')[0]); + + expect(useStore.getState().selectedMaskIds).toEqual(['annotation-30', 'annotation-31']); + expect(screen.getAllByTestId('konva-group').map((group) => group.getAttribute('data-opacity'))).toEqual(['0.5', '0.5', '0.35']); + }); + + 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('uses propagation instance ids to merge only the intended same-class propagated masks', 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', + metadata: { instance_id: 'same-class-primary' }, + }, + { + 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', + metadata: { instance_id: 'same-class-secondary' }, + }, + { + 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: 101, + source_instance_id: 'same-class-primary', + instance_id: 'same-class-primary', + }, + }, + { + 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: 202, + source_instance_id: 'same-class-secondary', + instance_id: 'same-class-secondary', + }, + }, + { + id: 'annotation-30', + annotationId: '30', + frameId: 'frame-2', + pathData: 'M 180 180 L 230 180 L 230 230 L 180 230 Z', + label: 'A', + color: '#06b6d4', + segmentation: [[180, 180, 230, 180, 230, 230, 180, 230]], + saved: true, + saveStatus: 'saved', + metadata: { + source: 'sam2.1_hiera_tiny_propagation', + source_annotation_id: 303, + source_instance_id: 'same-class-unselected', + instance_id: 'same-class-unselected', + }, + }, + ], + }); + + 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']))); + expect(onDeleteMaskAnnotations).not.toHaveBeenCalledWith(expect.arrayContaining(['30'])); + const masks = useStore.getState().masks; + expect(masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10', 'annotation-30']); + expect(masks.find((mask) => mask.id === 'annotation-10')).toEqual(expect.objectContaining({ + saveStatus: 'dirty', + saved: false, + metadata: expect.objectContaining({ source_instance_id: 'same-class-primary' }), + })); + expect(masks.find((mask) => mask.id === 'annotation-30')).toEqual(expect.objectContaining({ + saveStatus: 'saved', + })); + }); + + it('merges legacy propagated masks by nearest same-label result when stable lineage is missing', 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 70 70 L 120 70 L 120 120 L 70 120 Z', + label: 'A', + color: '#06b6d4', + segmentation: [[70, 70, 120, 70, 120, 120, 70, 120]], + 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_propagation', propagated_from_frame_id: 'frame-1' }, + }, + { + id: 'annotation-20', + annotationId: '20', + frameId: 'frame-2', + pathData: 'M 72 72 L 122 72 L 122 122 L 72 122 Z', + label: 'A', + color: '#06b6d4', + segmentation: [[72, 72, 122, 72, 122, 122, 72, 122]], + saved: true, + saveStatus: 'saved', + metadata: { source: 'sam2_propagation', propagated_from_frame_id: 'frame-1' }, + }, + ], + }); + + 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_propagation' }), + })); + }); + + it('does not merge every same-class legacy propagation instance on later frames', async () => { + const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined); + const frame2 = { ...frame, id: 'frame-2', index: 1 }; + const legacyMetadata = { + source: 'sam2_propagation', + propagated_from_frame_id: 'frame-1', + propagation_seed_key: '{"label":"A","color":"#06b6d4"}', + }; + useStore.setState({ + masks: [ + { + 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: legacyMetadata, + }, + { + id: 'annotation-20', + annotationId: '20', + frameId: 'frame-2', + pathData: 'M 72 72 L 122 72 L 122 122 L 72 122 Z', + label: 'A', + color: '#06b6d4', + segmentation: [[72, 72, 122, 72, 122, 122, 72, 122]], + saved: true, + saveStatus: 'saved', + metadata: legacyMetadata, + }, + { + id: 'annotation-30', + annotationId: '30', + frameId: 'frame-2', + pathData: 'M 180 180 L 230 180 L 230 230 L 180 230 Z', + label: 'A', + color: '#06b6d4', + segmentation: [[180, 180, 230, 180, 230, 230, 180, 230]], + saved: true, + saveStatus: 'saved', + metadata: legacyMetadata, + }, + { + id: 'annotation-110', + annotationId: '110', + frameId: 'frame-3', + pathData: 'M 14 14 L 64 14 L 64 64 L 14 64 Z', + label: 'A', + color: '#06b6d4', + segmentation: [[14, 14, 64, 14, 64, 64, 14, 64]], + saved: true, + saveStatus: 'saved', + metadata: legacyMetadata, + }, + { + id: 'annotation-120', + annotationId: '120', + frameId: 'frame-3', + pathData: 'M 74 74 L 124 74 L 124 124 L 74 124 Z', + label: 'A', + color: '#06b6d4', + segmentation: [[74, 74, 124, 74, 124, 124, 74, 124]], + saved: true, + saveStatus: 'saved', + metadata: legacyMetadata, + }, + { + id: 'annotation-130', + annotationId: '130', + frameId: 'frame-3', + pathData: 'M 182 182 L 232 182 L 232 232 L 182 232 Z', + label: 'A', + color: '#06b6d4', + segmentation: [[182, 182, 232, 182, 232, 232, 182, 232]], + saved: true, + saveStatus: 'saved', + metadata: legacyMetadata, + }, + ], + }); + + 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(['20', '120']))); + const masks = useStore.getState().masks; + expect(masks.map((mask) => mask.id).sort()).toEqual(['annotation-10', 'annotation-110', 'annotation-130', 'annotation-30']); + expect(onDeleteMaskAnnotations).not.toHaveBeenCalledWith(expect.arrayContaining(['30', '130'])); + expect(masks.find((mask) => mask.id === 'annotation-130')).toEqual(expect.objectContaining({ + saveStatus: 'saved', + })); + expect(masks.find((mask) => mask.id === 'annotation-110')).toEqual(expect.objectContaining({ + saveStatus: 'dirty', + saved: false, + bbox: [14, 14, 110, 110], + })); + }); + + it('merges propagated A masks into propagated B masks when merging A into B on the reference frame', 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: 'B', + color: '#2563eb', + className: 'B', + classMaskId: 2, + 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: '#dc2626', + className: 'A', + classMaskId: 1, + 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: 'B', + color: '#2563eb', + className: 'B', + classMaskId: 2, + 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: '#dc2626', + className: 'A', + classMaskId: 1, + 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']); + const propagatedB = masks.find((mask) => mask.id === 'annotation-10'); + expect(propagatedB).toEqual(expect.objectContaining({ + label: 'B', + color: '#2563eb', + className: 'B', + classMaskId: 2, + saveStatus: 'dirty', + saved: false, + })); + expect(propagatedB?.bbox).toEqual([12, 12, 90, 90]); + expect(propagatedB?.area).toBe(4900); + expect(propagatedB?.segmentation?.flat()).toEqual(expect.arrayContaining([12, 12, 102, 102])); + }); + + it('turns propagated A masks into B masks when merging A into B and no propagated B exists on that frame', 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: 'B', + color: '#2563eb', + className: 'B', + classMaskId: 2, + 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: '#dc2626', + className: 'A', + classMaskId: 1, + segmentation: [[50, 50, 100, 50, 100, 100, 50, 100]], + saved: true, + saveStatus: 'saved', + }, + { + 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: '#dc2626', + className: 'A', + classMaskId: 1, + 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: '处理所有传播帧' })); + + const masks = useStore.getState().masks; + expect(onDeleteMaskAnnotations).not.toHaveBeenCalledWith(expect.arrayContaining(['20'])); + expect(masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-20']); + expect(masks.find((mask) => mask.id === 'annotation-20')).toEqual(expect.objectContaining({ + label: 'B', + color: '#2563eb', + className: 'B', + classMaskId: 2, + saveStatus: 'dirty', + saved: false, + metadata: expect.objectContaining({ source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2 }), + })); + }); + + 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: '合并选中' })); + const rangeButton = screen.getByRole('button', { name: '按帧范围选择' }); + expect(rangeButton.parentElement).toHaveClass('grid-cols-4'); + expect(screen.getByRole('button', { name: '取消' }).parentElement).toBe(rangeButton.parentElement); + expect(screen.getByRole('button', { name: '只处理当前帧' }).parentElement).toBe(rangeButton.parentElement); + expect(screen.getByRole('button', { name: '处理所有传播帧' }).parentElement).toBe(rangeButton.parentElement); + fireEvent.click(rangeButton); + + 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], + })); + expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[0].id]); + expect(screen.getAllByTestId('konva-circle') + .filter((element) => element.getAttribute('data-fill') === '#ffffff')).toHaveLength(4); + + 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: '#000000', + classId: 'reserved-unclassified', + className: '待分类', + classMaskId: 0, + saveStatus: 'draft', + bbox: [120, 80, 140, 120], + metadata: expect.objectContaining({ + source: 'manual', + shape: '圆形', + }), + })); + expect(useStore.getState().masks[0].segmentation?.[0]).toHaveLength(64); + expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[0].id]); + expect(screen.getAllByTestId('konva-circle') + .filter((element) => element.getAttribute('data-fill') === '#ffffff')).toHaveLength(32); + }); + + it('keeps the selected mask when switching to manual creation tools', async () => { + useStore.setState({ + selectedMaskIds: ['m1'], + masks: [ + { + id: 'm1', + frameId: 'frame-1', + pathData: 'M 10 10 L 80 10 L 80 80 L 10 80 Z', + label: 'Existing', + color: '#06b6d4', + segmentation: [[10, 10, 80, 10, 80, 80, 10, 80]], + }, + ], + }); + + const { rerender } = render(); + await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['m1'])); + + act(() => useStore.getState().setSelectedMaskIds(['m1'])); + rerender(); + await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['m1'])); + + act(() => useStore.getState().setSelectedMaskIds(['m1'])); + rerender(); + await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['m1'])); + }); + + it('merges new manual shapes into the selected mask even when separated', () => { + useStore.setState({ + selectedMaskIds: ['m1'], + masks: [ + { + id: 'm1', + frameId: 'frame-1', + pathData: 'M 20 20 L 80 20 L 80 80 L 20 80 Z', + label: 'Existing', + color: '#06b6d4', + segmentation: [[20, 20, 80, 20, 80, 80, 20, 80]], + area: 3600, + saveStatus: 'saved', + annotationId: '7', + saved: true, + }, + ], + }); + + render(); + const stage = screen.getByTestId('konva-stage'); + fireEvent.mouseDown(stage, { clientX: 180, clientY: 120 }); + 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({ + id: 'm1', + saveStatus: 'dirty', + saved: false, + metadata: expect.objectContaining({ manualMergeShapes: ['矩形'] }), + })); + expect(useStore.getState().masks[0].segmentation).toHaveLength(2); + expect(useStore.getState().masks[0].area).toBeGreaterThan(3600); + expect(useStore.getState().selectedMaskIds).toEqual(['m1']); + }); + + 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('preserves hollow brush masks as editable inner rings', () => { + 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: 100, clientY: 100 }); + fireEvent.mouseMove(stage, { clientX: 240, clientY: 100 }); + fireEvent.mouseMove(stage, { clientX: 240, clientY: 240 }); + fireEvent.mouseMove(stage, { clientX: 100, clientY: 240 }); + fireEvent.mouseMove(stage, { clientX: 100, clientY: 100 }); + fireEvent.mouseUp(stage, { clientX: 100, clientY: 100 }); + + const brushMask = useStore.getState().masks[0]; + expect(brushMask.metadata).toEqual(expect.objectContaining({ + hasHoles: true, + shape: '画笔', + })); + expect(brushMask.metadata?.polygonRingCounts).toEqual([2]); + expect(brushMask.segmentation).toHaveLength(2); + expect(screen.getByTestId('konva-path')).toHaveAttribute('data-fill-rule', 'evenodd'); + expect(screen.getAllByTestId('konva-circle') + .filter((element) => element.getAttribute('data-fill') === '#ffffff').length).toBeGreaterThan(8); + }); + + it('keeps selected mask vertex markers visible while using brush and eraser', () => { + useStore.setState({ + activeTemplateId: '2', + activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 }, + 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, + }, + ], + }); + + const selectedVertexHandles = () => screen.getAllByTestId('konva-circle') + .filter((element) => element.getAttribute('data-fill') === '#ffffff'); + + const { rerender } = render(); + expect(selectedVertexHandles()).toHaveLength(4); + + rerender(); + expect(selectedVertexHandles()).toHaveLength(4); + }); + + it('does not start brush strokes outside the image bounds', () => { + 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: 700, clientY: 400 }); + fireEvent.mouseMove(stage, { clientX: 720, clientY: 420 }); + fireEvent.mouseUp(stage, { clientX: 720, clientY: 420 }); + + expect(useStore.getState().masks).toHaveLength(0); + }); + + it('clips brush stroke geometry to the current image bounds', () => { + useStore.setState({ + activeTemplateId: '2', + activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 }, + activeClassId: 'c1', + brushSize: 40, + }); + + render(); + const stage = screen.getByTestId('konva-stage'); + fireEvent.mouseDown(stage, { clientX: 630, clientY: 350 }); + fireEvent.mouseMove(stage, { clientX: 700, clientY: 420 }); + fireEvent.mouseUp(stage, { clientX: 700, clientY: 420 }); + + expect(useStore.getState().masks).toHaveLength(1); + const coordinates = useStore.getState().masks[0].segmentation?.flat() || []; + for (let index = 0; index < coordinates.length; index += 2) { + expect(coordinates[index]).toBeGreaterThanOrEqual(0); + expect(coordinates[index]).toBeLessThanOrEqual(frame.width); + expect(coordinates[index + 1]).toBeGreaterThanOrEqual(0); + expect(coordinates[index + 1]).toBeLessThanOrEqual(frame.height); + } + }); + + it('merges brush strokes into the selected mask even without an active semantic class', () => { + useStore.setState({ + activeTemplateId: '2', + activeClass: null, + activeClassId: null, + 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', + classId: 'c1', + saveStatus: 'draft', + metadata: expect.objectContaining({ manualMergeShapes: ['画笔'] }), + })); + 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: '多边形', + })); + expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[0].id]); + }); + + it('cancels in-progress polygon creation with Escape', () => { + render(); + const stage = screen.getByTestId('konva-stage'); + fireEvent.click(stage, { clientX: 120, clientY: 80 }); + fireEvent.click(stage, { clientX: 220, clientY: 80 }); + + expect(screen.getAllByTestId('konva-circle')).toHaveLength(2); + fireEvent.keyDown(window, { key: 'Escape' }); + + expect(useStore.getState().masks).toEqual([]); + expect(useStore.getState().selectedMaskIds).toEqual([]); + expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0); + expect(screen.getByText(/点击画布添加顶点/)).toBeInTheDocument(); + }); + + 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); + expect(screen.queryAllByTestId('konva-circle') + .filter((element) => element.getAttribute('data-fill') === '#ffffff')).toHaveLength(3); + }); + + it('clears selected masks with Escape so brush can create a new unmerged mask', async () => { + useStore.setState({ + activeTemplateId: '2', + activeClass: { id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 30, maskId: 2 }, + activeClassId: 'c2', + 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, + }, + ], + }); + + const { rerender } = render(); + fireEvent.keyDown(window, { key: 'Escape' }); + await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([])); + + rerender(); + const stage = screen.getByTestId('konva-stage'); + fireEvent.mouseDown(stage, { clientX: 300, clientY: 220 }); + fireEvent.mouseMove(stage, { clientX: 330, clientY: 240 }); + fireEvent.mouseUp(stage, { clientX: 360, clientY: 260 }); + + expect(useStore.getState().masks).toHaveLength(2); + expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ + id: 'm1', + label: '旧标签', + color: '#ff0000', + })); + expect(useStore.getState().masks[1]).toEqual(expect.objectContaining({ + label: '肝脏', + color: '#00ff00', + classId: 'c2', + classMaskId: 2, + })); + expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[1].id]); + }); + + 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(); + }); +}); diff --git a/src/components/ToolsPalette.test.tsx b/src/components/ToolsPalette.test.tsx new file mode 100644 index 0000000..a3978f2 --- /dev/null +++ b/src/components/ToolsPalette.test.tsx @@ -0,0 +1,179 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useStore } from '../store/useStore'; +import { resetStore } from '../test/storeTestUtils'; +import { ToolsPalette } from './ToolsPalette'; + +describe('ToolsPalette', () => { + beforeEach(() => { + resetStore(); + }); + + it('switches workspace editing tools without showing AI prompt or duplicate undo tools', () => { + const setActiveTool = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByTitle('创建多边形 (P)')); + fireEvent.click(screen.getByTitle('调整多边形 (E)')); + fireEvent.click(screen.getByTitle('画笔 (B)')); + fireEvent.click(screen.getByTitle('橡皮擦 (X)')); + + expect(setActiveTool).toHaveBeenNthCalledWith(1, 'create_polygon'); + expect(setActiveTool).toHaveBeenNthCalledWith(2, 'edit_polygon'); + expect(setActiveTool).toHaveBeenNthCalledWith(3, 'brush'); + expect(setActiveTool).toHaveBeenNthCalledWith(4, 'eraser'); + expect(screen.queryByTitle('正向选点 (SAM)')).not.toBeInTheDocument(); + expect(screen.queryByTitle('反向选点 (SAM)')).not.toBeInTheDocument(); + expect(screen.queryByTitle('边界框选 (SAM)')).not.toBeInTheDocument(); + expect(screen.queryByTitle('撤销操作 (Ctrl+Z)')).not.toBeInTheDocument(); + expect(screen.queryByTitle('重做操作 (Ctrl+Shift+Z)')).not.toBeInTheDocument(); + expect(screen.queryByTitle('创建点 (C)')).not.toBeInTheDocument(); + expect(screen.queryByTitle('创建线段 (L)')).not.toBeInTheDocument(); + }); + + it('shows size controls for brush and eraser tools', () => { + const { rerender } = render(); + const brushSize = screen.getByLabelText('画笔大小'); + fireEvent.change(brushSize, { target: { value: '36' } }); + expect(useStore.getState().brushSize).toBe(36); + + rerender(); + const eraserSize = screen.getByLabelText('橡皮擦大小'); + fireEvent.change(eraserSize, { target: { value: '48' } }); + expect(useStore.getState().eraserSize).toBe(48); + }); + + it('places GT mask import after overlap removal with a distinct violet style', () => { + const onImportGtMask = vi.fn(); + render( + , + ); + + const overlapButton = screen.getByTitle('重叠区域去除 (-)'); + const importButton = screen.getByTitle('导入 GT Mask'); + fireEvent.click(importButton); + + expect(onImportGtMask).toHaveBeenCalled(); + expect(importButton).toHaveClass('bg-violet-500/10'); + expect(overlapButton.compareDocumentPosition(importButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('exposes clear mask action in the left toolbar', () => { + const onClearMasks = vi.fn(); + const onDeleteMasks = vi.fn(); + render(); + + fireEvent.click(screen.getByTitle('删除选中遮罩 (Del)')); + fireEvent.click(screen.getByTitle('清空遮罩')); + + expect(onDeleteMasks).toHaveBeenCalled(); + expect(onClearMasks).toHaveBeenCalled(); + }); + + it('exposes a physical clear selection button next to the selection tool', () => { + const onClearSelection = vi.fn(); + render(); + + const moveButton = screen.getByTitle('拖拽 / 选择 (V)'); + const clearSelectionButton = screen.getByTitle('取消选中 (Esc)'); + const editButton = screen.getByTitle('调整多边形 (E)'); + fireEvent.click(clearSelectionButton); + + expect(onClearSelection).toHaveBeenCalled(); + expect(clearSelectionButton).toHaveAttribute('aria-label', '取消选中'); + expect(moveButton.compareDocumentPosition(clearSelectionButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(clearSelectionButton.compareDocumentPosition(editButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('places colored auto propagation below the eraser tool', () => { + const setActiveTool = vi.fn(); + const onAutoPropagate = vi.fn(); + render( + , + ); + + const eraserButton = screen.getByTitle('橡皮擦 (X)'); + const autoButton = screen.getByRole('button', { name: 'AI自动推理' }); + fireEvent.click(autoButton); + + expect(autoButton).toHaveClass('bg-cyan-500/10'); + expect(autoButton.querySelector('[data-testid="ai-auto-inference-icon"]')).toBeInTheDocument(); + expect(eraserButton.compareDocumentPosition(autoButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(setActiveTool).toHaveBeenCalledWith('auto_propagate'); + expect(onAutoPropagate).toHaveBeenCalled(); + }); + + it('separates drawing, editing, and external action tool groups', () => { + const { container } = render(); + + const separators = Array.from(container.querySelectorAll('.h-px')); + const externalActionSeparator = screen.getByTestId('tool-group-separator'); + const clearSelectionButton = screen.getByTitle('取消选中 (Esc)'); + const circleButton = screen.getByTitle('创建圆 (O)'); + const brushButton = screen.getByTitle('画笔 (B)'); + const eraserButton = screen.getByTitle('橡皮擦 (X)'); + const autoButton = screen.getByRole('button', { name: 'AI自动推理' }); + const mergeButton = screen.getByTitle('区域合并 (+)'); + const removeButton = screen.getByTitle('重叠区域去除 (-)'); + const deleteButton = screen.getByTitle('删除选中遮罩 (Del)'); + const clearButton = screen.getByTitle('清空遮罩'); + const importButton = screen.getByTitle('导入 GT Mask'); + + expect(separators).toHaveLength(3); + expect(externalActionSeparator).toBe(separators[2]); + expect(clearSelectionButton.compareDocumentPosition(circleButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(circleButton.compareDocumentPosition(separators[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(separators[0].compareDocumentPosition(brushButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(eraserButton.compareDocumentPosition(autoButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(autoButton.compareDocumentPosition(separators[1]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(separators[1].compareDocumentPosition(mergeButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(removeButton.compareDocumentPosition(deleteButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(deleteButton.compareDocumentPosition(clearButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(clearButton.compareDocumentPosition(separators[2]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(separators[2].compareDocumentPosition(importButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + separators.forEach((separator) => { + expect(separator).toHaveClass('bg-white/15'); + }); + }); + + it('switches to SAM trigger and calls the AI navigation hook', () => { + const setActiveTool = vi.fn(); + const onTriggerAI = vi.fn(); + + render(); + const aiButton = screen.getByTitle('打开 AI 智能分割'); + expect(aiButton.querySelector('[data-testid="ai-segmentation-icon"]')).toBeInTheDocument(); + fireEvent.click(aiButton); + + expect(setActiveTool).toHaveBeenCalledWith('sam_trigger'); + expect(onTriggerAI).toHaveBeenCalled(); + }); + + it('uses compact vertically scrollable layout for smaller workspaces', () => { + const { container } = render(); + const palette = container.firstElementChild; + + expect(palette).toHaveClass('w-14'); + expect(palette).toHaveClass('overflow-y-auto'); + expect(palette).toHaveClass('seg-scrollbar'); + expect(palette?.firstElementChild).toHaveClass('w-12'); + expect(screen.getByTitle('创建多边形 (P)')).toHaveClass('h-9'); + expect(screen.getByTitle('打开 AI 智能分割')).toHaveClass('h-9'); + }); +}); diff --git a/src/test/setup.tsx b/src/test/setup.tsx new file mode 100644 index 0000000..4a3c58c --- /dev/null +++ b/src/test/setup.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { afterEach, vi } from 'vitest'; +import { cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +vi.stubGlobal('alert', vi.fn()); +vi.stubGlobal('confirm', vi.fn(() => true)); +URL.createObjectURL = vi.fn(() => 'blob:mock-url'); +URL.revokeObjectURL = vi.fn(); +HTMLAnchorElement.prototype.click = vi.fn(); + +function makeStageEvent(x = 120, y = 80) { + const stage = { + getPointerPosition: () => ({ x, y }), + getRelativePointerPosition: () => ({ x, y }), + scaleX: () => 1, + x: () => 0, + y: () => 0, + }; + + return { + evt: { preventDefault: vi.fn(), deltaY: -1 }, + target: { + getStage: () => stage, + }, + }; +} + +vi.mock('react-konva', () => ({ + Stage: ({ children, onClick, onMouseDown, onMouseUp, onMouseMove, onWheel, onDragEnd, scaleX, scaleY, x, y, width, height }: any) => { + const coords = (event: React.MouseEvent, fallbackX: number, fallbackY: number) => ({ + x: event.clientX || fallbackX, + y: event.clientY || fallbackY, + }); + return ( +
{ + const point = coords(event, 120, 80); + onClick?.(makeStageEvent(point.x, point.y)); + }} + onMouseDown={(event) => { + const point = coords(event, 120, 80); + onMouseDown?.(makeStageEvent(point.x, point.y)); + }} + onMouseUp={(event) => { + const point = coords(event, 260, 200); + onMouseUp?.(makeStageEvent(point.x, point.y)); + }} + onMouseMove={(event) => { + const point = coords(event, 180, 120); + onMouseMove?.(makeStageEvent(point.x, point.y)); + }} + onWheel={() => onWheel?.(makeStageEvent())} + onDragEnd={(event) => { + const stageTarget: any = { + x: () => event.clientX || 0, + y: () => event.clientY || 0, + }; + stageTarget.getStage = () => stageTarget; + const childTarget = { + x: () => event.clientX || 0, + y: () => event.clientY || 0, + getStage: () => stageTarget, + }; + onDragEnd?.({ + target: event.target === event.currentTarget ? stageTarget : childTarget, + }); + }} + > + {children} +
+ ); + }, + Layer: ({ children }: any) =>
{children}
, + Group: ({ children, opacity }: any) =>
{children}
, + Image: ({ image }: any) => , + Circle: (props: any) => ( + { + const point = { + x: event.clientX || 120, + y: event.clientY || 80, + }; + const konvaEvent = { ...makeStageEvent(point.x, point.y), cancelBubble: false }; + props.onClick?.(konvaEvent); + if (konvaEvent.cancelBubble) event.stopPropagation(); + }} + onMouseDown={(event) => { + const point = { + x: event.clientX || props.x || 120, + y: event.clientY || props.y || 80, + }; + const konvaEvent = { ...makeStageEvent(point.x, point.y), cancelBubble: false }; + props.onMouseDown?.(konvaEvent); + props.onDragStart?.(konvaEvent); + if (konvaEvent.cancelBubble) event.stopPropagation(); + }} + onMouseMove={(event) => props.onDragMove?.({ + target: { + x: () => event.clientX || props.x || 0, + y: () => event.clientY || props.y || 0, + }, + })} + onMouseUp={(event: React.MouseEvent) => props.onDragEnd?.({ + target: { + x: () => event.clientX || props.x || 0, + y: () => event.clientY || props.y || 0, + }, + })} + onDragEnd={(event: React.DragEvent) => props.onDragEnd?.({ + target: { + x: () => event.clientX || props.x || 0, + y: () => event.clientY || props.y || 0, + }, + })} + /> + ), + Rect: (props: any) => , + Path: (props: any) => ( + { + const point = { + x: event.clientX || 120, + y: event.clientY || 80, + }; + const konvaEvent = { ...makeStageEvent(point.x, point.y), cancelBubble: false }; + props.onClick?.(konvaEvent); + if (konvaEvent.cancelBubble) event.stopPropagation(); + }} + onDoubleClick={(event) => { + const point = { + x: event.clientX || 120, + y: event.clientY || 80, + }; + const konvaEvent = { ...makeStageEvent(point.x, point.y), cancelBubble: false }; + props.onDblClick?.(konvaEvent); + if (konvaEvent.cancelBubble) event.stopPropagation(); + }} + /> + ), +})); + +vi.mock('use-image', () => ({ + default: (src: string) => [ + { + src, + width: 640, + height: 360, + naturalWidth: 640, + naturalHeight: 360, + }, + 'loaded', + ], +})); diff --git a/src/test/storeTestUtils.ts b/src/test/storeTestUtils.ts new file mode 100644 index 0000000..64b25c9 --- /dev/null +++ b/src/test/storeTestUtils.ts @@ -0,0 +1,30 @@ +import { DEFAULT_AI_MODEL_ID, DEFAULT_BRUSH_SIZE, DEFAULT_ERASER_SIZE, useStore } from '../store/useStore'; + +export function resetStore() { + useStore.setState({ + isAuthenticated: false, + token: null, + currentUser: null, + projects: [], + currentProject: null, + activeModule: 'dashboard', + activeTool: 'move', + aiModel: DEFAULT_AI_MODEL_ID, + frames: [], + currentFrameIndex: 0, + annotations: [], + masks: [], + selectedMaskIds: [], + maskPreviewOpacity: 50, + brushSize: DEFAULT_BRUSH_SIZE, + eraserSize: DEFAULT_ERASER_SIZE, + maskHistory: [], + maskFuture: [], + templates: [], + activeTemplateId: null, + activeClassId: null, + activeClass: null, + isLoading: false, + error: null, + }); +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..40b3956 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,24 @@ +import react from '@vitejs/plugin-react'; +import path from 'path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + }, + }, + test: { + environment: 'jsdom', + environmentOptions: { + jsdom: { + url: 'http://seg.local:3000', + }, + }, + globals: true, + setupFiles: './src/test/setup.tsx', + include: ['src/**/*.{test,spec}.{ts,tsx}'], + css: false, + }, +}); diff --git a/新撰写软著文档/功能验证与素材清单.md b/新撰写软著文档/功能验证与素材清单.md index 39d5dcf..e5f4bcb 100644 --- a/新撰写软著文档/功能验证与素材清单.md +++ b/新撰写软著文档/功能验证与素材清单.md @@ -40,3 +40,14 @@ ## 验证说明 本次验证以管理员账号进入线上系统,逐项检查登录、总体概况、项目库、分割工作区、AI 智能分割、AI 自动推理入口、GT Mask 导入预览、分割结果导出、模板库、用户管理、审计日志和退出登录等说明书涉及功能。删除项目、恢复演示出厂设置、生成帧确认、导出下载确认等可能改变演示环境或产生下载文件的危险提交动作仅验证入口与确认界面,不执行最终提交。 + +## 自动化测试补充 + +| 功能点 | 测试文件 | 覆盖内容 | +| --- | --- | --- | +| AI 智能分割 | `src/components/AISegmentation.test.tsx`、`src/components/ToolsPalette.test.tsx` | 验证 SAM 2.1 模型选择、模型不可用禁用、正向点/反向点/框选提示、执行高精度语义分割请求参数、AI 页面不显示 SAM3 入口、工作区左侧“打开 AI 智能分割”按钮使用 AI 图标并触发导航。 | +| AI 自动推理 | `src/components/VideoWorkspace.test.tsx`、`src/components/ToolsPalette.test.tsx` | 验证左侧彩色“AI自动推理”入口位于橡皮擦下方、点击后进入传播范围选择、参考帧无遮罩时不入队、传播权重与起止帧进入后台任务、同参考帧多个 mask 会生成多 step 传播任务。 | +| 创建多边形及手工绘制 | `src/components/CanvasArea.test.tsx`、`src/components/ToolsPalette.test.tsx` | 验证工具栏能切换到“创建多边形”,多边形点击取点后可按 Enter 完成、三点后可点击首节点闭合、Esc 只取消临时点和选区且不删除已有 mask,创建完成后自动选中新 mask 并显示边界点。 | +| 画笔、橡皮擦和绘制尺寸 | `src/components/CanvasArea.test.tsx`、`src/components/ToolsPalette.test.tsx` | 验证画笔/橡皮擦尺寸滑杆、画笔无选中时创建当前语义 mask、有选中时并入选中 mask、画笔不能从图外创建、靠边笔触会裁剪到图像范围内、橡皮擦从选中 mask 扣除。 | + +以上测试均使用 Vitest、Testing Library、mock Konva 和 mock 后端接口完成,不依赖真实 GPU、真实模型权重或线上服务,可作为说明书截图之外的交互回归验证。