- 区域合并/去除同步传播帧时改用严格实例匹配,优先可靠 source_annotation_id/source_mask_id lineage。 - 旧传播结果缺少可靠 lineage 时,每个已选 mask 只匹配空间最近的一个同语义传播实例,避免同类别其它 mask 被一起合并或扣除。 - 保留点选高亮的宽松 legacy 分组,避免破坏同一传播 mask 不连通片段联动高亮体验。 - 新增 CanvasArea 回归测试,覆盖同类别多个 legacy 传播实例只合并目标实例。 - 更新 AGENTS、设计冻结和测试计划文档,明确布尔同步和高亮匹配策略的差异。
2223 lines
78 KiB
TypeScript
2223 lines
78 KiB
TypeScript
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(<CanvasArea activeTool="point_pos" frame={frame} />);
|
|
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(<CanvasArea activeTool="move" frame={frame} />);
|
|
|
|
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(<CanvasArea activeTool="move" frame={frame} />);
|
|
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(<CanvasArea activeTool="move" frame={frame} clearSelectionSignal={0} />);
|
|
expect(useStore.getState().selectedMaskIds).toEqual(['m1']);
|
|
|
|
rerender(<CanvasArea activeTool="move" frame={frame} clearSelectionSignal={1} />);
|
|
|
|
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(<CanvasArea activeTool="box_select" frame={frame} />);
|
|
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(<CanvasArea activeTool="point_pos" frame={frame} />);
|
|
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(<CanvasArea activeTool="point_neg" frame={frame} />);
|
|
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(<CanvasArea activeTool="box_select" frame={frame} />);
|
|
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(<CanvasArea activeTool="point_neg" frame={frame} />);
|
|
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(<CanvasArea activeTool="point_pos" frame={frame} />);
|
|
const stage = screen.getByTestId('konva-stage');
|
|
fireEvent.click(stage, { clientX: 120, clientY: 80 });
|
|
await waitFor(() => expect(apiMock.predictMask).toHaveBeenCalledTimes(1));
|
|
|
|
rerender(<CanvasArea activeTool="point_neg" frame={frame} />);
|
|
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(<CanvasArea activeTool="move" frame={frame} />);
|
|
|
|
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(<CanvasArea activeTool="move" frame={frame} />);
|
|
|
|
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(<CanvasArea activeTool="move" frame={frame} />);
|
|
|
|
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(<CanvasArea activeTool="move" frame={frame} />);
|
|
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(<CanvasArea activeTool="edit_polygon" frame={frame} />);
|
|
|
|
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(<CanvasArea activeTool="edit_polygon" frame={frame} />);
|
|
rerender(<CanvasArea activeTool="edit_polygon" frame={propagatedFrame} />);
|
|
|
|
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(<CanvasArea activeTool="edit_polygon" frame={propagatedFrame} />);
|
|
rerender(<CanvasArea activeTool="edit_polygon" frame={laterPropagatedFrame} />);
|
|
|
|
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(<CanvasArea activeTool="edit_polygon" frame={frame} />);
|
|
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(<CanvasArea activeTool="edit_polygon" frame={frame} />);
|
|
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(<CanvasArea activeTool="move" frame={frame} />);
|
|
|
|
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(<CanvasArea activeTool="move" frame={frame} />);
|
|
|
|
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(<CanvasArea activeTool="edit_polygon" frame={frame} />);
|
|
|
|
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(<CanvasArea activeTool="move" frame={frame} />);
|
|
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(<CanvasArea activeTool="edit_polygon" frame={frame} />);
|
|
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(<CanvasArea activeTool="edit_polygon" frame={frame} />);
|
|
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(<CanvasArea activeTool="move" frame={frame} />);
|
|
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(<CanvasArea activeTool="move" frame={frame} />);
|
|
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(<CanvasArea activeTool="move" frame={frame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
|
|
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(<CanvasArea activeTool="move" frame={propagatedFrame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
|
|
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(<CanvasArea activeTool="move" frame={frame} />);
|
|
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(<CanvasArea activeTool="edit_polygon" frame={frame} />);
|
|
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(<CanvasArea activeTool="move" frame={frame} />);
|
|
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(<CanvasArea activeTool="area_merge" frame={frame} />);
|
|
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(<CanvasArea activeTool="area_merge" frame={frame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
|
|
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('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(<CanvasArea activeTool="area_merge" frame={frame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
|
|
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(<CanvasArea activeTool="area_merge" frame={frame2} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
|
|
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(<CanvasArea activeTool="area_merge" frame={frame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
|
|
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(<CanvasArea activeTool="area_merge" frame={frame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
|
|
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(<CanvasArea activeTool="area_merge" frame={frame} onRequestBooleanFrameRange={onRequestBooleanFrameRange} />);
|
|
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(<CanvasArea activeTool="area_remove" frame={frame} />);
|
|
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(<CanvasArea activeTool="area_remove" frame={frame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
|
|
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(<CanvasArea activeTool="area_remove" frame={frame} />);
|
|
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(<CanvasArea activeTool="create_rectangle" frame={frame} />);
|
|
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(<CanvasArea activeTool="create_circle" frame={frame} />);
|
|
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(<CanvasArea activeTool="create_polygon" frame={frame} />);
|
|
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['m1']));
|
|
|
|
act(() => useStore.getState().setSelectedMaskIds(['m1']));
|
|
rerender(<CanvasArea activeTool="create_rectangle" frame={frame} />);
|
|
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['m1']));
|
|
|
|
act(() => useStore.getState().setSelectedMaskIds(['m1']));
|
|
rerender(<CanvasArea activeTool="create_circle" frame={frame} />);
|
|
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(<CanvasArea activeTool="create_rectangle" frame={frame} />);
|
|
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(<CanvasArea activeTool="brush" frame={frame} />);
|
|
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(<CanvasArea activeTool="brush" frame={frame} />);
|
|
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(<CanvasArea activeTool="brush" frame={frame} />);
|
|
expect(selectedVertexHandles()).toHaveLength(4);
|
|
|
|
rerender(<CanvasArea activeTool="eraser" frame={frame} />);
|
|
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(<CanvasArea activeTool="brush" frame={frame} />);
|
|
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(<CanvasArea activeTool="brush" frame={frame} />);
|
|
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(<CanvasArea activeTool="brush" frame={frame} />);
|
|
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(<CanvasArea activeTool="eraser" frame={frame} />);
|
|
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(<CanvasArea activeTool="create_polygon" frame={frame} />);
|
|
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(<CanvasArea activeTool="create_polygon" frame={frame} />);
|
|
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(<CanvasArea activeTool="create_polygon" frame={frame} />);
|
|
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(<CanvasArea activeTool="move" frame={frame} />);
|
|
fireEvent.keyDown(window, { key: 'Escape' });
|
|
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([]));
|
|
|
|
rerender(<CanvasArea activeTool="brush" frame={frame} />);
|
|
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(<CanvasArea activeTool="area_remove" frame={frame} />);
|
|
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(<CanvasArea activeTool="create_rectangle" frame={frame} />);
|
|
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(<CanvasArea activeTool="move" frame={frame} />);
|
|
|
|
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(<CanvasArea activeTool="move" frame={frame} />);
|
|
|
|
expect(screen.queryByRole('button', { name: '清空遮罩' })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: '应用分类' })).not.toBeInTheDocument();
|
|
});
|
|
});
|