补充AI推理与手工绘制测试覆盖

- 新增 Vitest 配置和前端测试 setup,使 Docker 交付目录可直接运行组件测试
- 新增 ToolsPalette 测试,覆盖创建多边形、AI自动推理、打开 AI 智能分割、工具分组和画笔/橡皮擦尺寸控制
- 新增 CanvasArea 测试,覆盖创建多边形 Enter 完成、点击首节点闭合、Esc 取消临时点、画笔新建/合并、橡皮擦扣除和图像边界裁剪
- 复用并验证 AISegmentation 与 VideoWorkspace 测试,覆盖 AI 智能分割参数、模型不可用禁用、AI自动推理范围选择和多 seed 后台传播任务
- 更新软著功能验证与素材清单,补充 AI 智能分割、AI自动推理、创建多边形等功能的自动化测试映射
This commit is contained in:
2026-05-08 01:55:06 +08:00
parent 09f6137a8f
commit d369674906
6 changed files with 2748 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,179 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useStore } from '../store/useStore';
import { resetStore } from '../test/storeTestUtils';
import { ToolsPalette } from './ToolsPalette';
describe('ToolsPalette', () => {
beforeEach(() => {
resetStore();
});
it('switches workspace editing tools without showing AI prompt or duplicate undo tools', () => {
const setActiveTool = vi.fn();
render(
<ToolsPalette
activeTool="move"
setActiveTool={setActiveTool}
/>,
);
fireEvent.click(screen.getByTitle('创建多边形 (P)'));
fireEvent.click(screen.getByTitle('调整多边形 (E)'));
fireEvent.click(screen.getByTitle('画笔 (B)'));
fireEvent.click(screen.getByTitle('橡皮擦 (X)'));
expect(setActiveTool).toHaveBeenNthCalledWith(1, 'create_polygon');
expect(setActiveTool).toHaveBeenNthCalledWith(2, 'edit_polygon');
expect(setActiveTool).toHaveBeenNthCalledWith(3, 'brush');
expect(setActiveTool).toHaveBeenNthCalledWith(4, 'eraser');
expect(screen.queryByTitle('正向选点 (SAM)')).not.toBeInTheDocument();
expect(screen.queryByTitle('反向选点 (SAM)')).not.toBeInTheDocument();
expect(screen.queryByTitle('边界框选 (SAM)')).not.toBeInTheDocument();
expect(screen.queryByTitle('撤销操作 (Ctrl+Z)')).not.toBeInTheDocument();
expect(screen.queryByTitle('重做操作 (Ctrl+Shift+Z)')).not.toBeInTheDocument();
expect(screen.queryByTitle('创建点 (C)')).not.toBeInTheDocument();
expect(screen.queryByTitle('创建线段 (L)')).not.toBeInTheDocument();
});
it('shows size controls for brush and eraser tools', () => {
const { rerender } = render(<ToolsPalette activeTool="brush" setActiveTool={vi.fn()} />);
const brushSize = screen.getByLabelText('画笔大小');
fireEvent.change(brushSize, { target: { value: '36' } });
expect(useStore.getState().brushSize).toBe(36);
rerender(<ToolsPalette activeTool="eraser" setActiveTool={vi.fn()} />);
const eraserSize = screen.getByLabelText('橡皮擦大小');
fireEvent.change(eraserSize, { target: { value: '48' } });
expect(useStore.getState().eraserSize).toBe(48);
});
it('places GT mask import after overlap removal with a distinct violet style', () => {
const onImportGtMask = vi.fn();
render(
<ToolsPalette
activeTool="move"
setActiveTool={vi.fn()}
onImportGtMask={onImportGtMask}
canImportGtMask
/>,
);
const overlapButton = screen.getByTitle('重叠区域去除 (-)');
const importButton = screen.getByTitle('导入 GT Mask');
fireEvent.click(importButton);
expect(onImportGtMask).toHaveBeenCalled();
expect(importButton).toHaveClass('bg-violet-500/10');
expect(overlapButton.compareDocumentPosition(importButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('exposes clear mask action in the left toolbar', () => {
const onClearMasks = vi.fn();
const onDeleteMasks = vi.fn();
render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} onClearMasks={onClearMasks} onDeleteMasks={onDeleteMasks} />);
fireEvent.click(screen.getByTitle('删除选中遮罩 (Del)'));
fireEvent.click(screen.getByTitle('清空遮罩'));
expect(onDeleteMasks).toHaveBeenCalled();
expect(onClearMasks).toHaveBeenCalled();
});
it('exposes a physical clear selection button next to the selection tool', () => {
const onClearSelection = vi.fn();
render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} onClearSelection={onClearSelection} />);
const moveButton = screen.getByTitle('拖拽 / 选择 (V)');
const clearSelectionButton = screen.getByTitle('取消选中 (Esc)');
const editButton = screen.getByTitle('调整多边形 (E)');
fireEvent.click(clearSelectionButton);
expect(onClearSelection).toHaveBeenCalled();
expect(clearSelectionButton).toHaveAttribute('aria-label', '取消选中');
expect(moveButton.compareDocumentPosition(clearSelectionButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(clearSelectionButton.compareDocumentPosition(editButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('places colored auto propagation below the eraser tool', () => {
const setActiveTool = vi.fn();
const onAutoPropagate = vi.fn();
render(
<ToolsPalette
activeTool="move"
setActiveTool={setActiveTool}
onAutoPropagate={onAutoPropagate}
canAutoPropagate
/>,
);
const eraserButton = screen.getByTitle('橡皮擦 (X)');
const autoButton = screen.getByRole('button', { name: 'AI自动推理' });
fireEvent.click(autoButton);
expect(autoButton).toHaveClass('bg-cyan-500/10');
expect(autoButton.querySelector('[data-testid="ai-auto-inference-icon"]')).toBeInTheDocument();
expect(eraserButton.compareDocumentPosition(autoButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(setActiveTool).toHaveBeenCalledWith('auto_propagate');
expect(onAutoPropagate).toHaveBeenCalled();
});
it('separates drawing, editing, and external action tool groups', () => {
const { container } = render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} canImportGtMask />);
const separators = Array.from(container.querySelectorAll('.h-px'));
const externalActionSeparator = screen.getByTestId('tool-group-separator');
const clearSelectionButton = screen.getByTitle('取消选中 (Esc)');
const circleButton = screen.getByTitle('创建圆 (O)');
const brushButton = screen.getByTitle('画笔 (B)');
const eraserButton = screen.getByTitle('橡皮擦 (X)');
const autoButton = screen.getByRole('button', { name: 'AI自动推理' });
const mergeButton = screen.getByTitle('区域合并 (+)');
const removeButton = screen.getByTitle('重叠区域去除 (-)');
const deleteButton = screen.getByTitle('删除选中遮罩 (Del)');
const clearButton = screen.getByTitle('清空遮罩');
const importButton = screen.getByTitle('导入 GT Mask');
expect(separators).toHaveLength(3);
expect(externalActionSeparator).toBe(separators[2]);
expect(clearSelectionButton.compareDocumentPosition(circleButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(circleButton.compareDocumentPosition(separators[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(separators[0].compareDocumentPosition(brushButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(eraserButton.compareDocumentPosition(autoButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(autoButton.compareDocumentPosition(separators[1]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(separators[1].compareDocumentPosition(mergeButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(removeButton.compareDocumentPosition(deleteButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(deleteButton.compareDocumentPosition(clearButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(clearButton.compareDocumentPosition(separators[2]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(separators[2].compareDocumentPosition(importButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
separators.forEach((separator) => {
expect(separator).toHaveClass('bg-white/15');
});
});
it('switches to SAM trigger and calls the AI navigation hook', () => {
const setActiveTool = vi.fn();
const onTriggerAI = vi.fn();
render(<ToolsPalette activeTool="move" setActiveTool={setActiveTool} onTriggerAI={onTriggerAI} />);
const aiButton = screen.getByTitle('打开 AI 智能分割');
expect(aiButton.querySelector('[data-testid="ai-segmentation-icon"]')).toBeInTheDocument();
fireEvent.click(aiButton);
expect(setActiveTool).toHaveBeenCalledWith('sam_trigger');
expect(onTriggerAI).toHaveBeenCalled();
});
it('uses compact vertically scrollable layout for smaller workspaces', () => {
const { container } = render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} />);
const palette = container.firstElementChild;
expect(palette).toHaveClass('w-14');
expect(palette).toHaveClass('overflow-y-auto');
expect(palette).toHaveClass('seg-scrollbar');
expect(palette?.firstElementChild).toHaveClass('w-12');
expect(screen.getByTitle('创建多边形 (P)')).toHaveClass('h-9');
expect(screen.getByTitle('打开 AI 智能分割')).toHaveClass('h-9');
});
});

178
src/test/setup.tsx Normal file
View File

@@ -0,0 +1,178 @@
import React from 'react';
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
afterEach(() => {
cleanup();
localStorage.clear();
});
vi.stubGlobal('alert', vi.fn());
vi.stubGlobal('confirm', vi.fn(() => true));
URL.createObjectURL = vi.fn(() => 'blob:mock-url');
URL.revokeObjectURL = vi.fn();
HTMLAnchorElement.prototype.click = vi.fn();
function makeStageEvent(x = 120, y = 80) {
const stage = {
getPointerPosition: () => ({ x, y }),
getRelativePointerPosition: () => ({ x, y }),
scaleX: () => 1,
x: () => 0,
y: () => 0,
};
return {
evt: { preventDefault: vi.fn(), deltaY: -1 },
target: {
getStage: () => stage,
},
};
}
vi.mock('react-konva', () => ({
Stage: ({ children, onClick, onMouseDown, onMouseUp, onMouseMove, onWheel, onDragEnd, scaleX, scaleY, x, y, width, height }: any) => {
const coords = (event: React.MouseEvent<HTMLDivElement>, fallbackX: number, fallbackY: number) => ({
x: event.clientX || fallbackX,
y: event.clientY || fallbackY,
});
return (
<div
data-testid="konva-stage"
data-has-drag-end={Boolean(onDragEnd)}
data-scale-x={scaleX}
data-scale-y={scaleY}
data-x={x}
data-y={y}
data-width={width}
data-height={height}
onClick={(event) => {
const point = coords(event, 120, 80);
onClick?.(makeStageEvent(point.x, point.y));
}}
onMouseDown={(event) => {
const point = coords(event, 120, 80);
onMouseDown?.(makeStageEvent(point.x, point.y));
}}
onMouseUp={(event) => {
const point = coords(event, 260, 200);
onMouseUp?.(makeStageEvent(point.x, point.y));
}}
onMouseMove={(event) => {
const point = coords(event, 180, 120);
onMouseMove?.(makeStageEvent(point.x, point.y));
}}
onWheel={() => onWheel?.(makeStageEvent())}
onDragEnd={(event) => {
const stageTarget: any = {
x: () => event.clientX || 0,
y: () => event.clientY || 0,
};
stageTarget.getStage = () => stageTarget;
const childTarget = {
x: () => event.clientX || 0,
y: () => event.clientY || 0,
getStage: () => stageTarget,
};
onDragEnd?.({
target: event.target === event.currentTarget ? stageTarget : childTarget,
});
}}
>
{children}
</div>
);
},
Layer: ({ children }: any) => <div data-testid="konva-layer">{children}</div>,
Group: ({ children, opacity }: any) => <div data-testid="konva-group" data-opacity={opacity}>{children}</div>,
Image: ({ image }: any) => <img data-testid="konva-image" alt="" src={image?.src || ''} />,
Circle: (props: any) => (
<span
data-testid="konva-circle"
data-fill={props.fill}
data-x={props.x}
data-y={props.y}
onClick={(event) => {
const point = {
x: event.clientX || 120,
y: event.clientY || 80,
};
const konvaEvent = { ...makeStageEvent(point.x, point.y), cancelBubble: false };
props.onClick?.(konvaEvent);
if (konvaEvent.cancelBubble) event.stopPropagation();
}}
onMouseDown={(event) => {
const point = {
x: event.clientX || props.x || 120,
y: event.clientY || props.y || 80,
};
const konvaEvent = { ...makeStageEvent(point.x, point.y), cancelBubble: false };
props.onMouseDown?.(konvaEvent);
props.onDragStart?.(konvaEvent);
if (konvaEvent.cancelBubble) event.stopPropagation();
}}
onMouseMove={(event) => props.onDragMove?.({
target: {
x: () => event.clientX || props.x || 0,
y: () => event.clientY || props.y || 0,
},
})}
onMouseUp={(event: React.MouseEvent<HTMLSpanElement>) => props.onDragEnd?.({
target: {
x: () => event.clientX || props.x || 0,
y: () => event.clientY || props.y || 0,
},
})}
onDragEnd={(event: React.DragEvent<HTMLSpanElement>) => props.onDragEnd?.({
target: {
x: () => event.clientX || props.x || 0,
y: () => event.clientY || props.y || 0,
},
})}
/>
),
Rect: (props: any) => <span data-testid="konva-rect" data-width={props.width} />,
Path: (props: any) => (
<span
data-testid="konva-path"
data-path={props.data}
data-fill={props.fill}
data-stroke={props.stroke}
data-stroke-width={props.strokeWidth}
data-dash={props.dash?.join(',') || ''}
data-fill-rule={props.fillRule}
onClick={(event) => {
const point = {
x: event.clientX || 120,
y: event.clientY || 80,
};
const konvaEvent = { ...makeStageEvent(point.x, point.y), cancelBubble: false };
props.onClick?.(konvaEvent);
if (konvaEvent.cancelBubble) event.stopPropagation();
}}
onDoubleClick={(event) => {
const point = {
x: event.clientX || 120,
y: event.clientY || 80,
};
const konvaEvent = { ...makeStageEvent(point.x, point.y), cancelBubble: false };
props.onDblClick?.(konvaEvent);
if (konvaEvent.cancelBubble) event.stopPropagation();
}}
/>
),
}));
vi.mock('use-image', () => ({
default: (src: string) => [
{
src,
width: 640,
height: 360,
naturalWidth: 640,
naturalHeight: 360,
},
'loaded',
],
}));

View File

@@ -0,0 +1,30 @@
import { DEFAULT_AI_MODEL_ID, DEFAULT_BRUSH_SIZE, DEFAULT_ERASER_SIZE, useStore } from '../store/useStore';
export function resetStore() {
useStore.setState({
isAuthenticated: false,
token: null,
currentUser: null,
projects: [],
currentProject: null,
activeModule: 'dashboard',
activeTool: 'move',
aiModel: DEFAULT_AI_MODEL_ID,
frames: [],
currentFrameIndex: 0,
annotations: [],
masks: [],
selectedMaskIds: [],
maskPreviewOpacity: 50,
brushSize: DEFAULT_BRUSH_SIZE,
eraserSize: DEFAULT_ERASER_SIZE,
maskHistory: [],
maskFuture: [],
templates: [],
activeTemplateId: null,
activeClassId: null,
activeClass: null,
isLoading: false,
error: null,
});
}

24
vitest.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import react from '@vitejs/plugin-react';
import path from 'path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
test: {
environment: 'jsdom',
environmentOptions: {
jsdom: {
url: 'http://seg.local:3000',
},
},
globals: true,
setupFiles: './src/test/setup.tsx',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
css: false,
},
});

View File

@@ -40,3 +40,14 @@
## 验证说明 ## 验证说明
本次验证以管理员账号进入线上系统逐项检查登录、总体概况、项目库、分割工作区、AI 智能分割、AI 自动推理入口、GT Mask 导入预览、分割结果导出、模板库、用户管理、审计日志和退出登录等说明书涉及功能。删除项目、恢复演示出厂设置、生成帧确认、导出下载确认等可能改变演示环境或产生下载文件的危险提交动作仅验证入口与确认界面,不执行最终提交。 本次验证以管理员账号进入线上系统逐项检查登录、总体概况、项目库、分割工作区、AI 智能分割、AI 自动推理入口、GT Mask 导入预览、分割结果导出、模板库、用户管理、审计日志和退出登录等说明书涉及功能。删除项目、恢复演示出厂设置、生成帧确认、导出下载确认等可能改变演示环境或产生下载文件的危险提交动作仅验证入口与确认界面,不执行最终提交。
## 自动化测试补充
| 功能点 | 测试文件 | 覆盖内容 |
| --- | --- | --- |
| AI 智能分割 | `src/components/AISegmentation.test.tsx``src/components/ToolsPalette.test.tsx` | 验证 SAM 2.1 模型选择、模型不可用禁用、正向点/反向点/框选提示、执行高精度语义分割请求参数、AI 页面不显示 SAM3 入口、工作区左侧“打开 AI 智能分割”按钮使用 AI 图标并触发导航。 |
| AI 自动推理 | `src/components/VideoWorkspace.test.tsx``src/components/ToolsPalette.test.tsx` | 验证左侧彩色“AI自动推理”入口位于橡皮擦下方、点击后进入传播范围选择、参考帧无遮罩时不入队、传播权重与起止帧进入后台任务、同参考帧多个 mask 会生成多 step 传播任务。 |
| 创建多边形及手工绘制 | `src/components/CanvasArea.test.tsx``src/components/ToolsPalette.test.tsx` | 验证工具栏能切换到“创建多边形”,多边形点击取点后可按 Enter 完成、三点后可点击首节点闭合、Esc 只取消临时点和选区且不删除已有 mask创建完成后自动选中新 mask 并显示边界点。 |
| 画笔、橡皮擦和绘制尺寸 | `src/components/CanvasArea.test.tsx``src/components/ToolsPalette.test.tsx` | 验证画笔/橡皮擦尺寸滑杆、画笔无选中时创建当前语义 mask、有选中时并入选中 mask、画笔不能从图外创建、靠边笔触会裁剪到图像范围内、橡皮擦从选中 mask 扣除。 |
以上测试均使用 Vitest、Testing Library、mock Konva 和 mock 后端接口完成,不依赖真实 GPU、真实模型权重或线上服务可作为说明书截图之外的交互回归验证。