Files
Pre_Seg_Server/src/components/CanvasArea.test.tsx
admin 4c1d3dba73 feat: 完善 mask 编辑、传播平滑与开发重启闭环
功能增加:

- 新增后端 /api/ai/smooth-mask 接口,对当前 mask polygon 执行 Chaikin 边缘平滑,并返回 polygon、bbox、area 与拓扑锚点。

- 在右侧实例属性面板加入边缘平滑强度和应用边缘平滑操作,应用后将 mask 标记为 draft/dirty,并通过正常保存链路落库。

- 保存标注与传播 seed 时保留 geometry_smoothing 元数据,自动传播 forward/backward 结果保存前应用同一平滑参数。

- 自动传播 seed signature 纳入平滑参数,修改平滑强度后会触发旧同源传播结果清理并重新传播。

- 支持跨帧跟随同一传播链 mask,AI 推送回工作区时保留当前帧视角。

Bugfix:

- 修复中间帧向前传播时旧 forward/backward 同物体结果未被清理导致双重 mask 的问题。

- 修复 propagation worker 写入目标帧前只按旧方向清理导致 backward 重传残留的问题。

- 修复多边形顶点拖拽和编辑后画布视口异常移动的问题,并补充拖拽状态回写。

- 修复实例属性标题跟随全局 active class 而不是当前 mask label 的问题,并移除后端模型置信度展示。

开发与部署:

- 新增 restart_dev_services.sh,使用 setsid 独立后台重启 FastAPI、Celery 和前端,写入 pid/log 文件并做 3000/8000 健康检查。

- 明确后端或 Celery 相关改动完成后需要运行重启脚本,保证运行态加载最新代码。

测试与文档:

- 补充后端 smooth-mask、传播平滑 metadata、seed signature、传播去重方向覆盖等测试。

- 补充前端 OntologyInspector、VideoWorkspace、CanvasArea 和 api 契约测试,覆盖边缘平滑、传播参数、跨帧选区跟随和画布编辑行为。

- 更新 README、AGENTS、安装文档、前端元素审计、需求冻结、设计冻结和测试计划,记录当前真实行为与重启要求。
2026-05-02 17:04:02 +08:00

1134 lines
38 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('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('renders imported GT seed points for editable point regions', () => {
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.getAllByTestId('konva-circle')).toHaveLength(2);
});
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('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');
fireEvent.mouseUp(vertexHandles[0], { clientX: 120, clientY: 120 });
expect(useStore.getState().masks[0].segmentation).toEqual([
[10, 10, 50, 10, 50, 40],
[120, 120, 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')).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('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('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],
}));
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: '#06b6d4',
saveStatus: 'draft',
bbox: [120, 80, 140, 120],
metadata: expect.objectContaining({
source: 'manual',
shape: '圆形',
}),
}));
expect(useStore.getState().masks[0].segmentation?.[0]).toHaveLength(64);
});
it('creates a manual line region from a drag gesture', () => {
render(<CanvasArea activeTool="create_line" 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: '#06b6d4',
saveStatus: 'draft',
metadata: expect.objectContaining({
source: 'manual',
shape: '线段',
}),
}));
expect(useStore.getState().masks[0].segmentation?.[0]).toHaveLength(8);
expect(useStore.getState().masks[0].area).toBeGreaterThan(1000);
});
it('creates an editable point region on click', () => {
render(<CanvasArea activeTool="create_point" frame={frame} />);
fireEvent.click(screen.getByTestId('konva-stage'), { clientX: 120, clientY: 80 });
expect(useStore.getState().masks).toHaveLength(1);
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
frameId: 'frame-1',
label: '手工点区域',
color: '#06b6d4',
saveStatus: 'draft',
points: [[120, 80]],
bbox: expect.arrayContaining([115, 75]),
metadata: expect.objectContaining({
source: 'manual',
shape: '点区域',
}),
}));
});
it('creates a point region when clicking over an existing mask', () => {
useStore.setState({
masks: [
{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 10 10 L 200 10 L 200 200 Z',
label: 'Existing',
color: '#06b6d4',
segmentation: [[10, 10, 200, 10, 200, 200]],
},
],
});
render(<CanvasArea activeTool="create_point" frame={frame} />);
fireEvent.click(screen.getByTestId('konva-path'), { clientX: 120, clientY: 80 });
expect(useStore.getState().masks).toHaveLength(2);
expect(useStore.getState().masks[1]).toEqual(expect.objectContaining({
metadata: expect.objectContaining({ shape: '点区域' }),
points: [[120, 80]],
}));
});
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: '多边形',
}));
});
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')).toHaveLength(0);
});
it('shows contextual guidance for boolean selection ordering', () => {
useStore.setState({
masks: [
{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 10 10 L 90 10 L 90 50 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[10, 10, 90, 10, 90, 50]],
},
{
id: 'm2',
frameId: 'frame-1',
pathData: 'M 50 30 L 120 30 L 120 80 Z',
label: 'B',
color: '#ff0000',
segmentation: [[50, 30, 120, 30, 120, 80]],
},
],
});
render(<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('applies the selected class to current-frame masks and marks saved masks dirty', () => {
useStore.setState({
activeTemplateId: '2',
activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 },
activeClassId: 'c1',
masks: [
{
id: 'm1',
frameId: 'frame-1',
annotationId: '99',
pathData: 'M 0 0 Z',
label: '旧标签',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
},
],
});
render(<CanvasArea activeTool="move" frame={frame} />);
fireEvent.click(screen.getByRole('button', { name: '应用分类' }));
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
templateId: '2',
classId: 'c1',
className: '胆囊',
classZIndex: 20,
label: '胆囊',
color: '#ff0000',
saveStatus: 'dirty',
saved: false,
}));
});
it('delegates clear to the workspace handler so saved annotations can be deleted', () => {
const onClearMasks = vi.fn();
useStore.setState({
masks: [
{ id: 'm1', frameId: 'frame-1', pathData: 'M 0 0 Z', label: 'A', color: '#fff' },
],
});
render(<CanvasArea activeTool="move" frame={frame} onClearMasks={onClearMasks} />);
fireEvent.click(screen.getByRole('button', { name: '清空遮罩' }));
expect(onClearMasks).toHaveBeenCalled();
expect(useStore.getState().masks).toHaveLength(1);
});
});