, fallbackX: number, fallbackY: number) => ({
+ x: event.clientX || fallbackX,
+ y: event.clientY || fallbackY,
+ });
+ return (
+ {
+ const point = coords(event, 120, 80);
+ onClick?.(makeStageEvent(point.x, point.y));
+ }}
+ onMouseDown={(event) => {
+ const point = coords(event, 120, 80);
+ onMouseDown?.(makeStageEvent(point.x, point.y));
+ }}
+ onMouseUp={(event) => {
+ const point = coords(event, 260, 200);
+ onMouseUp?.(makeStageEvent(point.x, point.y));
+ }}
+ onMouseMove={(event) => {
+ const point = coords(event, 180, 120);
+ onMouseMove?.(makeStageEvent(point.x, point.y));
+ }}
+ onWheel={() => onWheel?.(makeStageEvent())}
+ onDragEnd={(event) => {
+ const stageTarget: any = {
+ x: () => event.clientX || 0,
+ y: () => event.clientY || 0,
+ };
+ stageTarget.getStage = () => stageTarget;
+ const childTarget = {
+ x: () => event.clientX || 0,
+ y: () => event.clientY || 0,
+ getStage: () => stageTarget,
+ };
+ onDragEnd?.({
+ target: event.target === event.currentTarget ? stageTarget : childTarget,
+ });
+ }}
+ >
+ {children}
+
+ );
+ },
+ Layer: ({ children }: any) => {children}
,
+ Group: ({ children, opacity }: any) => {children}
,
+ Image: ({ image }: any) =>
,
+ Circle: (props: any) => (
+ {
+ const point = {
+ x: event.clientX || 120,
+ y: event.clientY || 80,
+ };
+ const konvaEvent = { ...makeStageEvent(point.x, point.y), cancelBubble: false };
+ props.onClick?.(konvaEvent);
+ if (konvaEvent.cancelBubble) event.stopPropagation();
+ }}
+ onMouseDown={(event) => {
+ const point = {
+ x: event.clientX || props.x || 120,
+ y: event.clientY || props.y || 80,
+ };
+ const konvaEvent = { ...makeStageEvent(point.x, point.y), cancelBubble: false };
+ props.onMouseDown?.(konvaEvent);
+ props.onDragStart?.(konvaEvent);
+ if (konvaEvent.cancelBubble) event.stopPropagation();
+ }}
+ onMouseMove={(event) => props.onDragMove?.({
+ target: {
+ x: () => event.clientX || props.x || 0,
+ y: () => event.clientY || props.y || 0,
+ },
+ })}
+ onMouseUp={(event: React.MouseEvent) => props.onDragEnd?.({
+ target: {
+ x: () => event.clientX || props.x || 0,
+ y: () => event.clientY || props.y || 0,
+ },
+ })}
+ onDragEnd={(event: React.DragEvent) => props.onDragEnd?.({
+ target: {
+ x: () => event.clientX || props.x || 0,
+ y: () => event.clientY || props.y || 0,
+ },
+ })}
+ />
+ ),
+ Rect: (props: any) => ,
+ Path: (props: any) => (
+ {
+ const point = {
+ x: event.clientX || 120,
+ y: event.clientY || 80,
+ };
+ const konvaEvent = { ...makeStageEvent(point.x, point.y), cancelBubble: false };
+ props.onClick?.(konvaEvent);
+ if (konvaEvent.cancelBubble) event.stopPropagation();
+ }}
+ onDoubleClick={(event) => {
+ const point = {
+ x: event.clientX || 120,
+ y: event.clientY || 80,
+ };
+ const konvaEvent = { ...makeStageEvent(point.x, point.y), cancelBubble: false };
+ props.onDblClick?.(konvaEvent);
+ if (konvaEvent.cancelBubble) event.stopPropagation();
+ }}
+ />
+ ),
+}));
+
+vi.mock('use-image', () => ({
+ default: (src: string) => [
+ {
+ src,
+ width: 640,
+ height: 360,
+ naturalWidth: 640,
+ naturalHeight: 360,
+ },
+ 'loaded',
+ ],
+}));
diff --git a/src/test/storeTestUtils.ts b/src/test/storeTestUtils.ts
new file mode 100644
index 0000000..64b25c9
--- /dev/null
+++ b/src/test/storeTestUtils.ts
@@ -0,0 +1,30 @@
+import { DEFAULT_AI_MODEL_ID, DEFAULT_BRUSH_SIZE, DEFAULT_ERASER_SIZE, useStore } from '../store/useStore';
+
+export function resetStore() {
+ useStore.setState({
+ isAuthenticated: false,
+ token: null,
+ currentUser: null,
+ projects: [],
+ currentProject: null,
+ activeModule: 'dashboard',
+ activeTool: 'move',
+ aiModel: DEFAULT_AI_MODEL_ID,
+ frames: [],
+ currentFrameIndex: 0,
+ annotations: [],
+ masks: [],
+ selectedMaskIds: [],
+ maskPreviewOpacity: 50,
+ brushSize: DEFAULT_BRUSH_SIZE,
+ eraserSize: DEFAULT_ERASER_SIZE,
+ maskHistory: [],
+ maskFuture: [],
+ templates: [],
+ activeTemplateId: null,
+ activeClassId: null,
+ activeClass: null,
+ isLoading: false,
+ error: null,
+ });
+}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..40b3956
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,24 @@
+import react from '@vitejs/plugin-react';
+import path from 'path';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, '.'),
+ },
+ },
+ test: {
+ environment: 'jsdom',
+ environmentOptions: {
+ jsdom: {
+ url: 'http://seg.local:3000',
+ },
+ },
+ globals: true,
+ setupFiles: './src/test/setup.tsx',
+ include: ['src/**/*.{test,spec}.{ts,tsx}'],
+ css: false,
+ },
+});
diff --git a/新撰写软著文档/功能验证与素材清单.md b/新撰写软著文档/功能验证与素材清单.md
index 39d5dcf..e5f4bcb 100644
--- a/新撰写软著文档/功能验证与素材清单.md
+++ b/新撰写软著文档/功能验证与素材清单.md
@@ -40,3 +40,14 @@
## 验证说明
本次验证以管理员账号进入线上系统,逐项检查登录、总体概况、项目库、分割工作区、AI 智能分割、AI 自动推理入口、GT Mask 导入预览、分割结果导出、模板库、用户管理、审计日志和退出登录等说明书涉及功能。删除项目、恢复演示出厂设置、生成帧确认、导出下载确认等可能改变演示环境或产生下载文件的危险提交动作仅验证入口与确认界面,不执行最终提交。
+
+## 自动化测试补充
+
+| 功能点 | 测试文件 | 覆盖内容 |
+| --- | --- | --- |
+| AI 智能分割 | `src/components/AISegmentation.test.tsx`、`src/components/ToolsPalette.test.tsx` | 验证 SAM 2.1 模型选择、模型不可用禁用、正向点/反向点/框选提示、执行高精度语义分割请求参数、AI 页面不显示 SAM3 入口、工作区左侧“打开 AI 智能分割”按钮使用 AI 图标并触发导航。 |
+| AI 自动推理 | `src/components/VideoWorkspace.test.tsx`、`src/components/ToolsPalette.test.tsx` | 验证左侧彩色“AI自动推理”入口位于橡皮擦下方、点击后进入传播范围选择、参考帧无遮罩时不入队、传播权重与起止帧进入后台任务、同参考帧多个 mask 会生成多 step 传播任务。 |
+| 创建多边形及手工绘制 | `src/components/CanvasArea.test.tsx`、`src/components/ToolsPalette.test.tsx` | 验证工具栏能切换到“创建多边形”,多边形点击取点后可按 Enter 完成、三点后可点击首节点闭合、Esc 只取消临时点和选区且不删除已有 mask,创建完成后自动选中新 mask 并显示边界点。 |
+| 画笔、橡皮擦和绘制尺寸 | `src/components/CanvasArea.test.tsx`、`src/components/ToolsPalette.test.tsx` | 验证画笔/橡皮擦尺寸滑杆、画笔无选中时创建当前语义 mask、有选中时并入选中 mask、画笔不能从图外创建、靠边笔触会裁剪到图像范围内、橡皮擦从选中 mask 扣除。 |
+
+以上测试均使用 Vitest、Testing Library、mock Konva 和 mock 后端接口完成,不依赖真实 GPU、真实模型权重或线上服务,可作为说明书截图之外的交互回归验证。