Files
Pre_Seg_Server/src/components/VideoWorkspace.test.tsx
admin 275be62db5 修复工作区撤销重做快捷键
- 抽出 keyboardShortcuts helper,统一识别 Ctrl/Cmd+Z、Ctrl/Cmd+Shift+Z 和 Ctrl/Cmd+Y

- 快捷键监听改为 window capture 阶段,减少 Canvas/Konva 焦点或冒泡拦截导致的失效

- 兼容 event.code=KeyZ/KeyY,覆盖输入法或键盘布局下 event.key 不可靠的情况

- 保持输入框、文本域、下拉框和可编辑文本聚焦时不拦截撤销/重做

- 增加快捷键 helper 与 VideoWorkspace 回归测试,并更新需求、设计、测试计划和 AGENTS 文档
2026-05-03 21:10:38 +08:00

1834 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetStore } from '../test/storeTestUtils';
import { useStore } from '../store/useStore';
import { VideoWorkspace } from './VideoWorkspace';
const apiMock = vi.hoisted(() => ({
getProjectFrames: vi.fn(),
parseMedia: vi.fn(),
propagateMasks: vi.fn(),
queuePropagationTask: vi.fn(),
getTask: vi.fn(),
cancelTask: vi.fn(),
getTemplates: vi.fn(),
getProjectAnnotations: vi.fn(),
saveAnnotation: vi.fn(),
updateAnnotation: vi.fn(),
deleteAnnotation: vi.fn(),
exportCoco: vi.fn(),
exportMasks: vi.fn(),
exportSegmentationResults: vi.fn(),
importGtMask: vi.fn(),
annotationToMask: vi.fn(),
buildAnnotationPayload: vi.fn(),
getAiModelStatus: vi.fn(),
analyzeMask: vi.fn(),
}));
vi.mock('../lib/api', () => ({
getProjectFrames: apiMock.getProjectFrames,
parseMedia: apiMock.parseMedia,
propagateMasks: apiMock.propagateMasks,
queuePropagationTask: apiMock.queuePropagationTask,
getTask: apiMock.getTask,
cancelTask: apiMock.cancelTask,
getTemplates: apiMock.getTemplates,
getProjectAnnotations: apiMock.getProjectAnnotations,
saveAnnotation: apiMock.saveAnnotation,
updateAnnotation: apiMock.updateAnnotation,
deleteAnnotation: apiMock.deleteAnnotation,
exportCoco: apiMock.exportCoco,
exportMasks: apiMock.exportMasks,
exportSegmentationResults: apiMock.exportSegmentationResults,
importGtMask: apiMock.importGtMask,
annotationToMask: apiMock.annotationToMask,
buildAnnotationPayload: apiMock.buildAnnotationPayload,
getAiModelStatus: apiMock.getAiModelStatus,
analyzeMask: apiMock.analyzeMask,
}));
describe('VideoWorkspace', () => {
beforeEach(() => {
resetStore();
vi.clearAllMocks();
useStore.setState({ currentProject: { id: '1', name: 'Demo', status: 'ready', video_path: 'uploads/demo.mp4' } });
apiMock.getTemplates.mockResolvedValue([]);
apiMock.getProjectAnnotations.mockResolvedValue([]);
apiMock.annotationToMask.mockReturnValue(null);
apiMock.queuePropagationTask.mockResolvedValue({ id: 31, status: 'queued', progress: 0, message: '自动传播任务已入队' });
apiMock.getTask.mockResolvedValue({
id: 31,
status: 'success',
progress: 100,
message: '自动传播完成',
result: { processed_frame_count: 3, created_annotation_count: 2, completed_steps: 1 },
});
apiMock.cancelTask.mockResolvedValue({ id: 31, status: 'cancelled', progress: 100, message: '任务已取消' });
apiMock.propagateMasks.mockResolvedValue({
model: 'sam2.1_hiera_tiny',
direction: 'forward',
source_frame_id: 10,
processed_frame_count: 3,
created_annotation_count: 2,
annotations: [],
});
apiMock.getAiModelStatus.mockResolvedValue({
selected_model: 'sam2.1_hiera_tiny',
gpu: { available: false, device: 'cpu', name: null, torch_available: true },
models: [
{ id: 'sam2.1_hiera_tiny', label: 'SAM 2.1 Tiny', available: true, loaded: false, device: 'cpu', supports: [], message: 'ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false },
],
});
apiMock.analyzeMask.mockResolvedValue({
confidence: 0.7,
confidence_source: 'model_score',
topology_anchor_count: 0,
topology_anchors: [],
area: 0.1,
bbox: [0, 0, 0.1, 0.1],
source: 'test',
message: 'ok',
});
});
it('loads project frames into the workspace store', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
]);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toEqual([
{ id: '10', projectId: '1', index: 0, url: '/frame.jpg', width: 640, height: 360 },
]));
expect(screen.getByText('Demo')).toBeInTheDocument();
expect(apiMock.parseMedia).not.toHaveBeenCalled();
expect(apiMock.getProjectAnnotations).toHaveBeenCalledWith('1');
});
it('exposes workspace undo/redo buttons and keyboard shortcuts without hijacking inputs', async () => {
const mask = {
id: 'mask-undo',
frameId: '10',
pathData: 'M 0 0 Z',
label: 'Draft',
color: '#06b6d4',
};
useStore.setState({
currentProject: null,
masks: [mask],
maskHistory: [[]],
maskFuture: [],
});
render(<VideoWorkspace />);
const undoButton = screen.getByRole('button', { name: '撤销操作' });
const redoButton = screen.getByRole('button', { name: '重做操作' });
expect(undoButton.querySelector('svg')).toHaveClass('text-amber-300');
expect(redoButton.querySelector('svg')).toHaveClass('text-indigo-300');
fireEvent.click(undoButton);
expect(useStore.getState().masks).toEqual([]);
fireEvent.click(redoButton);
expect(useStore.getState().masks).toEqual([mask]);
fireEvent.keyDown(window, { key: 'z', ctrlKey: true });
expect(useStore.getState().masks).toEqual([]);
fireEvent.keyDown(window, { key: 'z', ctrlKey: true, shiftKey: true });
expect(useStore.getState().masks).toEqual([mask]);
fireEvent.keyDown(window, { key: 'z', ctrlKey: true });
expect(useStore.getState().masks).toEqual([]);
fireEvent.keyDown(window, { key: 'y', ctrlKey: true });
expect(useStore.getState().masks).toEqual([mask]);
fireEvent.keyDown(window, { key: 'Process', code: 'KeyZ', ctrlKey: true });
expect(useStore.getState().masks).toEqual([]);
fireEvent.keyDown(window, { key: 'Process', code: 'KeyY', ctrlKey: true });
expect(useStore.getState().masks).toEqual([mask]);
fireEvent.keyDown(screen.getByLabelText('传播起始帧'), { key: 'z', ctrlKey: true });
expect(useStore.getState().masks).toEqual([mask]);
});
it('auto-dismisses short workspace operation messages without blocking later actions', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
]);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
vi.useFakeTimers();
fireEvent.click(screen.getByRole('button', { name: '已全部保存' }));
expect(screen.getByText('没有待保存标注')).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(3600);
});
expect(screen.queryByText('没有待保存标注')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '已全部保存' })).not.toBeDisabled();
vi.useRealTimers();
});
it('does not auto-generate frames when a media project has no frames yet', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([]);
render(<VideoWorkspace />);
await waitFor(() => expect(apiMock.getProjectFrames).toHaveBeenCalledWith('1'));
expect(apiMock.parseMedia).not.toHaveBeenCalled();
expect(apiMock.getTask).not.toHaveBeenCalled();
expect(useStore.getState().frames).toEqual([]);
expect(await screen.findByText('该项目已导入视频但尚未生成帧,请在项目库点击“生成帧”')).toBeInTheDocument();
});
it('hydrates saved annotations after loading frames', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
]);
apiMock.getProjectAnnotations.mockResolvedValueOnce([{ id: 99, frame_id: 10 }]);
apiMock.annotationToMask.mockReturnValueOnce({
id: 'annotation-99',
annotationId: '99',
frameId: '10',
saved: true,
pathData: 'M 0 0 Z',
label: 'Saved',
color: '#06b6d4',
});
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().masks).toEqual([
expect.objectContaining({ id: 'annotation-99', saved: true }),
]));
});
it('downgrades masks whose saved class no longer exists in the template to maskid 0 pending classification', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
]);
apiMock.getProjectAnnotations.mockResolvedValueOnce([{ id: 100, frame_id: 10, template_id: 2 }]);
apiMock.annotationToMask.mockReturnValueOnce({
id: 'annotation-100',
annotationId: '100',
frameId: '10',
templateId: '2',
classId: 'deleted-class',
className: '已删除类别',
classMaskId: 7,
saved: true,
saveStatus: 'saved',
pathData: 'M 0 0 Z',
label: '已删除类别',
color: '#ff0000',
segmentation: [[0, 0, 10, 0, 10, 10]],
});
useStore.setState({
templates: [{
id: '2',
name: '当前模板',
classes: [{ id: 'c1', name: '胆囊', color: '#00ff00', zIndex: 10, maskId: 1 }],
rules: [],
}],
});
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().masks).toEqual([
expect.objectContaining({
id: 'annotation-100',
label: '待分类',
className: '待分类',
classMaskId: 0,
classId: undefined,
color: '#9ca3af',
saved: false,
saveStatus: 'dirty',
metadata: expect.objectContaining({
needs_classification: true,
stale_class: expect.objectContaining({ id: 'deleted-class', maskId: 7 }),
}),
}),
]));
expect(screen.getByRole('button', { name: '保存 1 个改动' })).toBeInTheDocument();
});
it('preserves unsaved AI masks when hydrating saved annotations after entering the workspace', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
]);
apiMock.getProjectAnnotations.mockResolvedValueOnce([{ id: 99, frame_id: 10 }]);
apiMock.annotationToMask.mockReturnValueOnce({
id: 'annotation-99',
annotationId: '99',
frameId: '10',
saved: true,
pathData: 'M 0 0 Z',
label: 'Saved',
color: '#06b6d4',
});
useStore.setState({
activeTool: 'edit_polygon',
selectedMaskIds: ['ai-mask'],
masks: [{
id: 'ai-mask',
frameId: '10',
pathData: 'M 10 10 L 40 10 L 40 40 Z',
label: 'AI Mask',
color: '#06b6d4',
segmentation: [[10, 10, 40, 10, 40, 40]],
saveStatus: 'draft',
saved: false,
metadata: { source: 'ai_segmentation' },
}],
});
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual([
'ai-mask',
'annotation-99',
]));
expect(useStore.getState().selectedMaskIds).toEqual(['ai-mask']);
expect(useStore.getState().activeTool).toBe('edit_polygon');
});
it('keeps the current non-first frame when returning from AI segmentation to the workspace', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-1.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-2.jpg', width: 640, height: 360 },
{ id: 12, project_id: 1, frame_index: 2, image_url: '/frame-3.jpg', width: 640, height: 360 },
]);
useStore.setState({
frames: [
{ id: '10', projectId: '1', index: 0, url: '/frame-1.jpg', width: 640, height: 360 },
{ id: '11', projectId: '1', index: 1, url: '/frame-2.jpg', width: 640, height: 360 },
{ id: '12', projectId: '1', index: 2, url: '/frame-3.jpg', width: 640, height: 360 },
],
currentFrameIndex: 1,
activeTool: 'edit_polygon',
selectedMaskIds: ['ai-mask-frame-2'],
masks: [{
id: 'ai-mask-frame-2',
frameId: '11',
pathData: 'M 10 10 L 40 10 L 40 40 Z',
label: 'AI Mask',
color: '#06b6d4',
segmentation: [[10, 10, 40, 10, 40, 40]],
saveStatus: 'draft',
saved: false,
metadata: { source: 'ai_segmentation' },
}],
});
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
expect(useStore.getState().currentFrameIndex).toBe(1);
expect(useStore.getState().selectedMaskIds).toEqual(['ai-mask-frame-2']);
});
it('saves pending masks through the archive button', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
]);
apiMock.getProjectAnnotations
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 5, frame_id: 10 }]);
apiMock.buildAnnotationPayload.mockReturnValueOnce({ project_id: 1, frame_id: 10, mask_data: { polygons: [] } });
apiMock.saveAnnotation.mockResolvedValueOnce({ id: 5 });
apiMock.annotationToMask.mockReturnValueOnce({
id: 'annotation-5',
annotationId: '5',
frameId: '10',
saved: true,
saveStatus: 'saved',
pathData: 'M 0 0 Z',
label: 'Saved AI Mask',
color: '#06b6d4',
});
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
act(() => {
useStore.setState({
activeTemplateId: '2',
masks: [{
id: 'mask-1',
frameId: '10',
pathData: 'M 0 0 Z',
label: 'AI Mask',
color: '#06b6d4',
segmentation: [[0, 0, 10, 0, 10, 10]],
bbox: [0, 0, 10, 10],
}],
});
});
expect(screen.getByRole('button', { name: '保存 1 个改动' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '保存 1 个改动' }));
await waitFor(() => expect(apiMock.saveAnnotation).toHaveBeenCalledWith({
project_id: 1,
frame_id: 10,
mask_data: { polygons: [] },
}));
expect(apiMock.buildAnnotationPayload).toHaveBeenCalledWith(
'1',
expect.objectContaining({ id: 'mask-1' }),
expect.objectContaining({ id: '10' }),
'2',
);
await waitFor(() => expect(useStore.getState().masks).toEqual([
expect.objectContaining({ id: 'annotation-5', saved: true, saveStatus: 'saved' }),
]));
expect(useStore.getState().masks.some((mask) => mask.id === 'mask-1')).toBe(false);
expect(screen.getByRole('button', { name: '已全部保存' })).toBeInTheDocument();
});
it('updates dirty saved masks through the archive button', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
]);
apiMock.getProjectAnnotations
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 99, frame_id: 10 }])
.mockResolvedValueOnce([]);
apiMock.buildAnnotationPayload.mockReturnValueOnce({
project_id: 1,
frame_id: 10,
template_id: 2,
mask_data: { polygons: [], label: '胆囊' },
});
apiMock.updateAnnotation.mockResolvedValueOnce({ id: 99 });
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
act(() => {
useStore.setState({
activeTemplateId: '2',
masks: [{
id: 'annotation-99',
annotationId: '99',
frameId: '10',
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
saveStatus: 'dirty',
segmentation: [[0, 0, 10, 0, 10, 10]],
bbox: [0, 0, 10, 10],
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
propagated_from_frame_id: 10,
propagation_seed_key: 'annotation:7',
source_annotation_id: 7,
source_mask_id: 'annotation-7',
propagation_seed_signature: 'old-signature',
geometry_smoothing_preview: { strength: 35, method: 'chaikin' },
},
}],
});
});
expect(screen.getByRole('button', { name: '保存 1 个改动' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '保存 1 个改动' }));
await waitFor(() => expect(apiMock.updateAnnotation).toHaveBeenCalledWith('99', {
template_id: 2,
mask_data: {
polygons: [],
label: '胆囊',
source: 'sam2.1_hiera_tiny_propagation',
propagated_from_frame_id: 10,
propagation_seed_key: 'annotation:7',
source_annotation_id: 7,
source_mask_id: 'annotation-7',
propagation_seed_signature: 'old-signature',
},
points: undefined,
bbox: undefined,
}));
expect(apiMock.updateAnnotation.mock.calls[0][1].mask_data).not.toHaveProperty('geometry_smoothing_preview');
expect(apiMock.saveAnnotation).not.toHaveBeenCalled();
});
it('recreates a dirty local annotation without PATCH when the backend preflight says it is missing', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
]);
apiMock.getProjectAnnotations
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 120, frame_id: 10 }]);
apiMock.annotationToMask.mockImplementation((annotation) => ({
id: `annotation-${annotation.id}`,
annotationId: String(annotation.id),
frameId: String(annotation.frame_id),
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
saved: true,
saveStatus: 'saved',
segmentation: [[0, 0, 10, 0, 10, 10]],
}));
apiMock.buildAnnotationPayload.mockReturnValueOnce({
project_id: 1,
frame_id: 10,
template_id: 2,
mask_data: { polygons: [], label: '胆囊' },
});
apiMock.saveAnnotation.mockResolvedValueOnce({ id: 120 });
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
act(() => {
useStore.setState({
activeTemplateId: '2',
masks: [{
id: 'annotation-11859',
annotationId: '11859',
frameId: '10',
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
saveStatus: 'dirty',
segmentation: [[0, 0, 10, 0, 10, 10]],
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
propagation_seed_key: 'annotation:7',
source_annotation_id: 7,
},
}],
});
});
fireEvent.click(screen.getByRole('button', { name: '保存 1 个改动' }));
await waitFor(() => expect(apiMock.saveAnnotation).toHaveBeenCalled());
expect(apiMock.updateAnnotation).not.toHaveBeenCalled();
expect(apiMock.saveAnnotation).toHaveBeenCalledWith({
project_id: 1,
frame_id: 10,
template_id: 2,
mask_data: {
polygons: [],
label: '胆囊',
source: 'sam2.1_hiera_tiny_propagation',
propagation_seed_key: 'annotation:7',
source_annotation_id: 7,
},
});
await waitFor(() => expect(useStore.getState().masks).toEqual([
expect.objectContaining({ id: 'annotation-120', annotationId: '120', saveStatus: 'saved' }),
]));
expect(screen.getByText('已保存 1 个标注,其中 1 个本地旧标注已重新创建')).toBeInTheDocument();
});
it('recreates a dirty local annotation if the backend PATCH target disappears after preflight', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
]);
apiMock.getProjectAnnotations
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 11859, frame_id: 10 }])
.mockResolvedValueOnce([{ id: 121, frame_id: 10 }]);
apiMock.annotationToMask.mockImplementation((annotation) => ({
id: `annotation-${annotation.id}`,
annotationId: String(annotation.id),
frameId: String(annotation.frame_id),
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
saved: true,
saveStatus: 'saved',
segmentation: [[0, 0, 10, 0, 10, 10]],
}));
apiMock.buildAnnotationPayload.mockReturnValueOnce({
project_id: 1,
frame_id: 10,
template_id: 2,
mask_data: { polygons: [], label: '胆囊' },
});
apiMock.updateAnnotation.mockRejectedValueOnce({ response: { status: 404 } });
apiMock.saveAnnotation.mockResolvedValueOnce({ id: 121 });
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
act(() => {
useStore.setState({
activeTemplateId: '2',
masks: [{
id: 'annotation-11859',
annotationId: '11859',
frameId: '10',
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
saveStatus: 'dirty',
segmentation: [[0, 0, 10, 0, 10, 10]],
}],
});
});
fireEvent.click(screen.getByRole('button', { name: '保存 1 个改动' }));
await waitFor(() => expect(apiMock.updateAnnotation).toHaveBeenCalledWith('11859', expect.objectContaining({
template_id: 2,
})));
expect(apiMock.saveAnnotation).toHaveBeenCalledWith({
project_id: 1,
frame_id: 10,
template_id: 2,
mask_data: { polygons: [], label: '胆囊' },
});
await waitFor(() => expect(useStore.getState().masks).toEqual([
expect.objectContaining({ id: 'annotation-121', annotationId: '121', saveStatus: 'saved' }),
]));
});
it('deletes saved annotations when clearing current-frame masks', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation.mockResolvedValueOnce(undefined);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
act(() => {
useStore.setState({
masks: [
{
id: 'annotation-99',
annotationId: '99',
frameId: '10',
pathData: 'M 0 0 Z',
label: 'Saved',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
},
{
id: 'draft-1',
frameId: '10',
pathData: 'M 1 1 Z',
label: 'Draft',
color: '#ff0000',
},
],
});
});
fireEvent.click(screen.getByTitle('清空遮罩'));
expect(screen.queryByText('选择清空范围')).not.toBeInTheDocument();
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
expect(useStore.getState().masks).toEqual([]);
});
it('clears linked propagated masks when clearing current-frame masks', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation.mockResolvedValue(undefined);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [
{
id: 'annotation-1',
annotationId: '1',
frameId: '10',
pathData: 'M 0 0 Z',
label: 'Seed',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
},
{
id: 'annotation-10',
annotationId: '10',
frameId: '11',
pathData: 'M 1 1 Z',
label: 'Propagated',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
},
],
selectedMaskIds: ['annotation-10'],
});
});
fireEvent.click(screen.getByTitle('清空遮罩'));
expect(screen.getByText('选择清空范围')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '清空传播所有帧' }));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('1'));
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10');
expect(useStore.getState().masks).toEqual([]);
expect(useStore.getState().selectedMaskIds).toEqual([]);
});
it('can clear only the current frame when current masks have propagated results', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation.mockResolvedValue(undefined);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [
{
id: 'annotation-1',
annotationId: '1',
frameId: '10',
pathData: 'M 0 0 Z',
label: 'Seed',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
},
{
id: 'annotation-10',
annotationId: '10',
frameId: '11',
pathData: 'M 1 1 Z',
label: 'Propagated',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
},
],
});
});
fireEvent.click(screen.getByTitle('清空遮罩'));
fireEvent.click(screen.getByRole('button', { name: '只清当前帧' }));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('1'));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('10');
expect(useStore.getState().masks).toEqual([
expect.objectContaining({ id: 'annotation-10' }),
]);
});
it('clears only selected current-frame masks when a selected mask has no propagated results', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation.mockResolvedValue(undefined);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [
{ id: 'annotation-1', annotationId: '1', frameId: '10', pathData: 'M 0 0 Z', label: 'Seed', color: '#06b6d4', saved: true, saveStatus: 'saved' },
{ id: 'annotation-2', annotationId: '2', frameId: '10', pathData: 'M 2 2 Z', label: 'Standalone', color: '#ff0000', saved: true, saveStatus: 'saved' },
{
id: 'annotation-10',
annotationId: '10',
frameId: '11',
pathData: 'M 1 1 Z',
label: 'Propagated',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
},
],
selectedMaskIds: ['annotation-2'],
});
});
fireEvent.click(screen.getByTitle('清空遮罩'));
expect(screen.queryByText('选择清空范围')).not.toBeInTheDocument();
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('2'));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('1');
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('10');
expect(useStore.getState().masks).toEqual([
expect.objectContaining({ id: 'annotation-1' }),
expect.objectContaining({ id: 'annotation-10' }),
]);
});
it('can cancel current-frame propagated clear confirmation', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [
{ id: 'annotation-1', annotationId: '1', frameId: '10', pathData: 'M 0 0 Z', label: 'Seed', color: '#06b6d4', saved: true, saveStatus: 'saved' },
{
id: 'annotation-10',
annotationId: '10',
frameId: '11',
pathData: 'M 1 1 Z',
label: 'Propagated',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
},
],
});
});
fireEvent.click(screen.getByTitle('清空遮罩'));
fireEvent.click(screen.getByRole('button', { name: '取消' }));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalled();
expect(useStore.getState().masks).toHaveLength(2);
expect(screen.queryByText('选择清空范围')).not.toBeInTheDocument();
});
it('clears masks across the selected frame range', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
{ id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation.mockResolvedValue(undefined);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
act(() => {
useStore.setState({
masks: [
{ id: 'annotation-99', annotationId: '99', frameId: '10', pathData: 'M 0 0 Z', label: 'Saved 1', color: '#06b6d4', saved: true, saveStatus: 'saved' },
{ id: 'draft-1', frameId: '11', pathData: 'M 1 1 Z', label: 'Draft', color: '#ff0000' },
{ id: 'annotation-100', annotationId: '100', frameId: '12', pathData: 'M 2 2 Z', label: 'Saved 2', color: '#00ff00', saved: true, saveStatus: 'saved' },
],
selectedMaskIds: ['draft-1', 'annotation-100'],
});
});
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
expect(screen.getByText('请选择清空模式,并在播放进度条或视频处理进度条上点击/拖拽选择清空起止帧,再点击“确认清空”')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '清空全部' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: '保留人工/AI' })).toBeInTheDocument();
const processingBar = screen.getByLabelText('视频处理进度条');
vi.spyOn(processingBar, 'getBoundingClientRect').mockReturnValue({
left: 0,
right: 100,
top: 0,
bottom: 10,
width: 100,
height: 10,
x: 0,
y: 0,
toJSON: () => ({}),
});
fireEvent.pointerDown(processingBar, { clientX: 0, pointerId: 1 });
fireEvent.pointerMove(processingBar, { clientX: 50, pointerId: 1 });
fireEvent.pointerUp(processingBar, { clientX: 50, pointerId: 1 });
expect(screen.getByLabelText('传播起始帧')).toHaveValue(1);
expect(screen.getByLabelText('传播结束帧')).toHaveValue(2);
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
expect(screen.getByText('清除人工/AI 标注帧')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '确认清除人工/AI 标注' }));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('100');
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['annotation-100']);
expect(useStore.getState().selectedMaskIds).not.toContain('draft-1');
expect(screen.getByText('已清空第 1-2 帧的 2 个遮罩,其中后端标注 1 个')).toBeInTheDocument();
});
it('clears a range after undo restores a mask whose backend annotation was already deleted', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation.mockRejectedValueOnce({ response: { status: 404 } });
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
const restoredMask = {
id: 'annotation-99',
annotationId: '99',
frameId: '10',
pathData: 'M 0 0 Z',
label: 'Restored',
color: '#06b6d4',
saved: true,
saveStatus: 'saved' as const,
};
act(() => {
useStore.setState({ masks: [restoredMask], selectedMaskIds: ['annotation-99'] });
useStore.getState().setMasks([]);
useStore.getState().undoMasks();
});
expect(useStore.getState().masks).toEqual([restoredMask]);
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
fireEvent.click(screen.getByRole('button', { name: '确认清除人工/AI 标注' }));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
expect(useStore.getState().masks).toEqual([]);
expect(screen.getByText('已清空第 1-2 帧的 1 个遮罩,其中后端标注 1 个')).toBeInTheDocument();
});
it('continues clearing a range when one of several annotation deletes returns 404', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation
.mockRejectedValueOnce({ status: 404 })
.mockResolvedValueOnce(undefined);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [
{ id: 'annotation-10149', annotationId: '10149', frameId: '10', pathData: 'M 0 0 Z', label: 'Missing', color: '#06b6d4', saved: true, saveStatus: 'saved' },
{ id: 'annotation-10150', annotationId: '10150', frameId: '11', pathData: 'M 1 1 Z', label: 'Saved', color: '#22c55e', saved: true, saveStatus: 'saved' },
],
});
});
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
fireEvent.click(screen.getByRole('button', { name: '确认清除人工/AI 标注' }));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10149'));
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10150');
expect(useStore.getState().masks).toEqual([]);
expect(screen.getByText('已清空第 1-2 帧的 2 个遮罩,其中后端标注 2 个')).toBeInTheDocument();
});
it('can clear only propagated masks while preserving manual or AI annotated frames', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation.mockResolvedValue(undefined);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [
{ id: 'manual-1', annotationId: '98', frameId: '10', pathData: 'M 0 0 Z', label: 'Manual', color: '#ef4444', saved: true, saveStatus: 'saved' },
{
id: 'propagated-1',
annotationId: '99',
frameId: '11',
pathData: 'M 1 1 Z',
label: 'Tracked',
color: '#3b82f6',
saved: true,
saveStatus: 'saved',
metadata: { source_annotation_id: 7, source_mask_id: 'annotation-7' },
},
],
selectedMaskIds: ['manual-1', 'propagated-1'],
});
});
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
fireEvent.click(screen.getByRole('button', { name: '保留人工/AI' }));
expect(screen.getByRole('button', { name: '保留人工/AI' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
expect(screen.queryByText('清除人工/AI 标注帧')).not.toBeInTheDocument();
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('98');
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['manual-1']);
expect(useStore.getState().selectedMaskIds).toEqual(['manual-1']);
expect(screen.getByText('已清空第 1-2 帧的 1 个自动传播遮罩,其中后端标注 1 个,人工/AI 标注帧已保留')).toBeInTheDocument();
});
it('cancels range clearing when manual or AI annotated frames are not confirmed', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [
{ id: 'annotation-99', annotationId: '99', frameId: '10', pathData: 'M 0 0 Z', label: 'Manual', color: '#06b6d4', saved: true, saveStatus: 'saved' },
],
});
});
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
expect(screen.getByText('清除人工/AI 标注帧')).toBeInTheDocument();
const modal = screen.getByText('清除人工/AI 标注帧').closest('.fixed') as HTMLElement;
fireEvent.click(within(modal).getByRole('button', { name: '取消' }));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalled();
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['annotation-99']);
expect(screen.getByText('已取消清空片段遮罩')).toBeInTheDocument();
});
it('does not ask for manual-frame confirmation when clearing propagated-only frames', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation.mockResolvedValue(undefined);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [
{
id: 'annotation-99',
annotationId: '99',
frameId: '10',
pathData: 'M 0 0 Z',
label: 'Propagated',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2_propagation', propagated_from_frame_id: 1 },
},
],
});
});
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
expect(screen.queryByText('清除人工/AI 标注帧')).not.toBeInTheDocument();
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
});
it('auto-saves pending masks before exporting segmentation results', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
]);
apiMock.buildAnnotationPayload.mockReturnValueOnce({ project_id: 1, frame_id: 10, mask_data: { polygons: [] } });
apiMock.saveAnnotation.mockResolvedValueOnce({ id: 5 });
apiMock.exportSegmentationResults.mockResolvedValueOnce(new Blob(['zip'], { type: 'application/zip' }));
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
act(() => {
useStore.setState({
masks: [{
id: 'mask-1',
frameId: '10',
pathData: 'M 0 0 Z',
label: 'AI Mask',
color: '#06b6d4',
segmentation: [[0, 0, 10, 0, 10, 10]],
}],
});
});
fireEvent.click(screen.getByRole('button', { name: '分割结果导出' }));
fireEvent.change(screen.getByLabelText('Mix_label 遮罩透明度'), { target: { value: '0.45' } });
fireEvent.click(screen.getByRole('button', { name: '开始导出' }));
await waitFor(() => expect(apiMock.saveAnnotation).toHaveBeenCalled());
expect(apiMock.exportSegmentationResults).toHaveBeenCalledWith('1', {
scope: 'current',
outputs: ['separate', 'gt_label', 'pro_label', 'mix_label'],
mixOpacity: 0.45,
startFrame: undefined,
endFrame: undefined,
frameId: '10',
});
});
it('exports a selected frame range with GT label masks', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360, timestamp_ms: 0, source_frame_number: 0 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360, timestamp_ms: 500, source_frame_number: 15 },
{ id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360, timestamp_ms: 1000, source_frame_number: 30 },
]);
apiMock.exportSegmentationResults.mockResolvedValueOnce(new Blob(['zip'], { type: 'application/zip' }));
const downloads: string[] = [];
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function mockClick(this: HTMLAnchorElement) {
downloads.push(this.download);
});
useStore.setState({ currentProject: { id: '1', name: '病例 A/1', status: 'ready', video_path: 'uploads/demo.mp4' } });
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
fireEvent.click(screen.getByRole('button', { name: '分割结果导出' }));
fireEvent.click(screen.getByRole('button', { name: '特定范围帧' }));
fireEvent.change(screen.getByLabelText('导出起始帧'), { target: { value: '2' } });
fireEvent.change(screen.getByLabelText('导出结束帧'), { target: { value: '3' } });
fireEvent.click(screen.getByRole('button', { name: '分开 Mask' }));
fireEvent.click(screen.getByRole('button', { name: 'Pro_label 彩色' }));
fireEvent.click(screen.getByRole('button', { name: 'Mix_label 叠加' }));
fireEvent.click(screen.getByRole('button', { name: '开始导出' }));
await waitFor(() => expect(apiMock.exportSegmentationResults).toHaveBeenCalledWith('1', {
scope: 'range',
outputs: ['gt_label'],
mixOpacity: 0.3,
startFrame: 2,
endFrame: 3,
frameId: undefined,
}));
expect(downloads[0]).toBe('病例_A_1_seg_T_0h00m00s500ms-0h00m01s000ms_P_2-3.zip');
clickSpy.mockRestore();
});
it('lets the timeline range picker update selected frame export bounds', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
{ id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
{ id: 13, project_id: 1, frame_index: 3, image_url: '/frame-3.jpg', width: 640, height: 360 },
{ id: 14, project_id: 1, frame_index: 4, image_url: '/frame-4.jpg', width: 640, height: 360 },
]);
apiMock.exportSegmentationResults.mockResolvedValueOnce(new Blob(['zip'], { type: 'application/zip' }));
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(5));
fireEvent.click(screen.getByRole('button', { name: '分割结果导出' }));
fireEvent.click(screen.getByRole('button', { name: '特定范围帧' }));
expect(screen.getByText('请在播放进度条或视频处理进度条上点击/拖拽选择导出起止帧,也可直接修改导出范围')).toBeInTheDocument();
const processingBar = screen.getByLabelText('视频处理进度条');
vi.spyOn(processingBar, 'getBoundingClientRect').mockReturnValue({
left: 0,
right: 100,
top: 0,
bottom: 10,
width: 100,
height: 10,
x: 0,
y: 0,
toJSON: () => ({}),
});
fireEvent.pointerDown(processingBar, { clientX: 25, pointerId: 1 });
fireEvent.pointerMove(processingBar, { clientX: 100, pointerId: 1 });
fireEvent.pointerUp(processingBar, { clientX: 100, pointerId: 1 });
expect(screen.getByLabelText('导出起始帧')).toHaveValue(2);
expect(screen.getByLabelText('导出结束帧')).toHaveValue(5);
fireEvent.click(screen.getByRole('button', { name: '开始导出' }));
await waitFor(() => expect(apiMock.exportSegmentationResults).toHaveBeenCalledWith('1', {
scope: 'range',
outputs: ['separate', 'gt_label', 'pro_label', 'mix_label'],
mixOpacity: 0.3,
startFrame: 2,
endFrame: 5,
frameId: undefined,
}));
});
it('switches from export range selection to propagation range selection without starting propagation immediately', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
{ id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
]);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
act(() => {
useStore.setState({
masks: [{
id: 'annotation-8',
annotationId: '8',
frameId: '10',
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[64, 36, 192, 36, 192, 108]],
bbox: [64, 36, 128, 72],
saveStatus: 'saved',
}],
});
});
fireEvent.click(screen.getByRole('button', { name: '分割结果导出' }));
fireEvent.click(screen.getByRole('button', { name: '特定范围帧' }));
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
expect(screen.getByText('请在播放进度条或视频处理进度条上点击/拖拽选择传播起止帧,再点击“开始传播”')).toBeInTheDocument();
expect(apiMock.queuePropagationTask).not.toHaveBeenCalled();
});
it('exports only the current frame when current image scope is selected', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.exportSegmentationResults.mockResolvedValueOnce(new Blob(['zip'], { type: 'application/zip' }));
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({ currentFrameIndex: 1 });
});
fireEvent.click(screen.getByRole('button', { name: '分割结果导出' }));
fireEvent.click(screen.getByRole('button', { name: '当前图片' }));
fireEvent.click(screen.getByRole('button', { name: 'GT_label 黑白' }));
fireEvent.click(screen.getByRole('button', { name: 'Pro_label 彩色' }));
fireEvent.click(screen.getByRole('button', { name: 'Mix_label 叠加' }));
fireEvent.click(screen.getByRole('button', { name: '开始导出' }));
await waitFor(() => expect(apiMock.exportSegmentationResults).toHaveBeenCalledWith('1', {
scope: 'current',
outputs: ['separate'],
mixOpacity: 0.3,
startFrame: undefined,
endFrame: undefined,
frameId: '11',
}));
});
it('imports a GT mask for the current frame and hydrates saved annotations', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
]);
apiMock.importGtMask.mockResolvedValueOnce([{ id: 88, frame_id: 10 }]);
apiMock.getProjectAnnotations
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 88, frame_id: 10 }]);
apiMock.annotationToMask.mockReturnValueOnce({
id: 'annotation-88',
annotationId: '88',
frameId: '10',
saved: true,
pathData: 'M 0 0 Z',
label: 'GT Mask',
color: '#22c55e',
});
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['mask'], 'mask.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
expect(screen.getByText('导入结果预览')).toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('button', { name: '导入为未定义' })).not.toBeDisabled());
fireEvent.click(screen.getByRole('button', { name: '导入为未定义' }));
await waitFor(() => expect(apiMock.importGtMask).toHaveBeenCalledWith(file, '1', '10', null, {
unknownColorPolicy: 'undefined',
}));
await waitFor(() => expect(useStore.getState().masks).toEqual([
expect.objectContaining({ id: 'annotation-88', label: 'GT Mask' }),
]));
});
it('lets users discard unknown GT mask classes before importing', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
]);
apiMock.importGtMask.mockResolvedValueOnce([]);
apiMock.getProjectAnnotations.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
useStore.setState({ activeTemplateId: '2' });
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['mask'], 'color-mask.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
expect(screen.getByText('导入结果预览')).toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('button', { name: '舍弃未知类别' })).not.toBeDisabled());
fireEvent.click(screen.getByRole('button', { name: '舍弃未知类别' }));
await waitFor(() => expect(apiMock.importGtMask).toHaveBeenCalledWith(file, '1', '10', '2', {
unknownColorPolicy: 'discard',
}));
});
it('blocks propagation with a clear message when the current reference frame has no masks', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [{
id: 'stale-other-frame',
annotationId: '10369',
frameId: '11',
pathData: 'M 0 0 Z',
label: '旧帧遮罩',
color: '#ff0000',
saveStatus: 'dirty',
}],
});
});
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
expect(await screen.findByText('当前参考帧无遮罩')).toBeInTheDocument();
expect(apiMock.saveAnnotation).not.toHaveBeenCalled();
expect(apiMock.updateAnnotation).not.toHaveBeenCalled();
expect(apiMock.deleteAnnotation).not.toHaveBeenCalled();
expect(apiMock.queuePropagationTask).not.toHaveBeenCalled();
});
it('only saves masks on the current reference frame before propagation', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.getProjectAnnotations
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 8, frame_id: 10 }])
.mockResolvedValue([{ id: 8, frame_id: 10 }]);
apiMock.annotationToMask.mockImplementation((annotation) => ({
id: `annotation-${annotation.id}`,
annotationId: String(annotation.id),
frameId: String(annotation.frame_id),
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
saved: true,
saveStatus: 'saved',
segmentation: [[64, 36, 192, 36, 192, 108]],
bbox: [64, 36, 128, 72],
}));
apiMock.buildAnnotationPayload.mockReturnValue({
project_id: 1,
frame_id: 10,
mask_data: {
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
label: '胆囊',
color: '#ff0000',
},
bbox: [0.1, 0.1, 0.2, 0.2],
});
apiMock.updateAnnotation.mockResolvedValueOnce({ id: 8 });
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [
{
id: 'annotation-8',
annotationId: '8',
frameId: '10',
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[64, 36, 192, 36, 192, 108]],
bbox: [64, 36, 128, 72],
saveStatus: 'dirty',
},
{
id: 'stale-other-frame',
annotationId: '10369',
frameId: '11',
pathData: 'M 1 1 Z',
label: '旧帧遮罩',
color: '#00ff00',
segmentation: [[10, 10, 20, 10, 20, 20]],
bbox: [10, 10, 10, 10],
saveStatus: 'dirty',
},
],
});
});
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
await waitFor(() => expect(apiMock.updateAnnotation).toHaveBeenCalledTimes(1));
expect(apiMock.updateAnnotation).toHaveBeenCalledWith('8', expect.any(Object));
expect(apiMock.updateAnnotation).not.toHaveBeenCalledWith('10369', expect.any(Object));
await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledTimes(1));
});
it('auto-propagates reference-frame masks through the configured frame range', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
const seedPayload = {
project_id: 1,
frame_id: 10,
template_id: 2,
mask_data: {
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
label: '胆囊',
color: '#ff0000',
class: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 },
geometry_smoothing: { strength: 35, method: 'chaikin' },
},
bbox: [0.1, 0.1, 0.2, 0.2],
};
apiMock.getProjectAnnotations
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 5, frame_id: 10 }])
.mockResolvedValue([
{ id: 5, frame_id: 10 },
{ id: 6, frame_id: 11 },
]);
apiMock.buildAnnotationPayload.mockReturnValue(seedPayload);
apiMock.saveAnnotation.mockResolvedValueOnce({ id: 5 });
apiMock.annotationToMask.mockImplementation((annotation) => ({
id: `annotation-${annotation.id}`,
annotationId: String(annotation.id),
frameId: String(annotation.frame_id),
saved: true,
saveStatus: 'saved',
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[64, 36, 192, 36, 192, 108]],
bbox: [64, 36, 128, 72],
metadata: annotation.frame_id === 11
? { source: 'sam2.1_hiera_tiny_propagation', propagated_from_frame_id: 10, source_annotation_id: 5 }
: undefined,
}));
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
aiModel: 'sam2.1_hiera_tiny',
activeTemplateId: '2',
masks: [{
id: 'mask-1',
frameId: '10',
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[64, 36, 192, 36, 192, 108]],
bbox: [64, 36, 128, 72],
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
source_annotation_id: 5,
source_mask_id: 'annotation-5',
propagation_seed_signature: 'seed-signature-5',
geometry_smoothing: { strength: 35, method: 'chaikin' },
},
}],
});
});
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
expect(apiMock.queuePropagationTask).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
await waitFor(() => expect(apiMock.saveAnnotation).toHaveBeenCalledTimes(1));
await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledWith({
project_id: 1,
frame_id: 10,
model: 'sam2.1_hiera_tiny',
include_source: false,
save_annotations: true,
steps: [{
direction: 'forward',
max_frames: 2,
seed: {
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
bbox: [0.1, 0.1, 0.2, 0.2],
points: undefined,
label: '胆囊',
color: '#ff0000',
class_metadata: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 },
template_id: 2,
source_mask_id: 'annotation-5',
source_annotation_id: 5,
smoothing: { strength: 35, method: 'chaikin' },
},
}],
}));
await waitFor(() => expect(screen.getByText('已自动传播 1 个参考 mask处理 3 帧次,删除旧区域 0 个,保存 2 个区域')).toBeInTheDocument());
expect(screen.getByTestId('propagation-history-segment')).toHaveAttribute('title', 'SAM 2.1 Tiny 自动传播:第 1-2 帧');
});
it('uses the separately selected propagation weight when queueing propagation', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.buildAnnotationPayload.mockReturnValueOnce({
project_id: 1,
frame_id: 10,
mask_data: {
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
label: '胆囊',
color: '#ff0000',
},
bbox: [0.1, 0.1, 0.2, 0.2],
});
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
aiModel: 'sam2.1_hiera_tiny',
masks: [{
id: 'annotation-6',
annotationId: '6',
frameId: '10',
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[64, 36, 192, 36, 192, 108]],
bbox: [64, 36, 128, 72],
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
source_annotation_id: 5,
source_mask_id: 'annotation-5',
propagation_seed_signature: 'seed-signature-5',
},
}],
});
});
const propagationWeightSelect = screen.getByLabelText('传播权重');
expect(propagationWeightSelect).toHaveClass('bg-[#050809]');
expect(within(propagationWeightSelect).getByRole('option', { name: 'tiny' })).toHaveClass('text-cyan-100');
fireEvent.change(propagationWeightSelect, { target: { value: 'sam2.1_hiera_small' } });
expect(propagationWeightSelect).toHaveValue('sam2.1_hiera_small');
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledWith(expect.objectContaining({
model: 'sam2.1_hiera_small',
steps: [expect.objectContaining({
seed: expect.objectContaining({
source_annotation_id: 5,
source_mask_id: 'annotation-5',
propagation_seed_signature: 'seed-signature-5',
}),
})],
})));
await waitFor(() => expect(screen.getByText('已自动传播 1 个参考 mask处理 3 帧次,删除旧区域 0 个,保存 2 个区域')).toBeInTheDocument());
});
it('shows propagation task progress and reports empty results', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.buildAnnotationPayload.mockReturnValueOnce({
project_id: 1,
frame_id: 10,
mask_data: {
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
label: '胆囊',
color: '#ff0000',
},
bbox: [0.1, 0.1, 0.2, 0.2],
});
apiMock.queuePropagationTask.mockResolvedValueOnce({ id: 44, status: 'queued', progress: 0, message: '自动传播任务已入队' });
apiMock.getTask.mockResolvedValueOnce({
id: 44,
status: 'success',
progress: 100,
message: '自动传播完成,但没有生成新的 mask',
result: { processed_frame_count: 2, created_annotation_count: 0, completed_steps: 1 },
});
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [{
id: 'annotation-7',
annotationId: '7',
frameId: 10 as unknown as string,
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[64, 36, 192, 36, 192, 108]],
bbox: [64, 36, 128, 72],
}],
});
});
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
const progressPanel = await screen.findByLabelText('自动传播进度');
expect(progressPanel).toBeInTheDocument();
expect(within(progressPanel).getByText('0%')).toBeInTheDocument();
expect(await screen.findByText(/没有生成新的 mask/)).toBeInTheDocument();
});
it('lets users select the propagation range on the timeline before queueing', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
{ id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
{ id: 13, project_id: 1, frame_index: 3, image_url: '/frame-3.jpg', width: 640, height: 360 },
{ id: 14, project_id: 1, frame_index: 4, image_url: '/frame-4.jpg', width: 640, height: 360 },
]);
apiMock.buildAnnotationPayload.mockReturnValueOnce({
project_id: 1,
frame_id: 10,
mask_data: {
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
label: '胆囊',
color: '#ff0000',
},
bbox: [0.1, 0.1, 0.2, 0.2],
});
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(5));
act(() => {
useStore.setState({
masks: [{
id: 'annotation-8',
annotationId: '8',
frameId: '10',
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[64, 36, 192, 36, 192, 108]],
bbox: [64, 36, 128, 72],
}],
});
});
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
const processingBar = screen.getByLabelText('视频处理进度条');
vi.spyOn(processingBar, 'getBoundingClientRect').mockReturnValue({
left: 0,
right: 100,
top: 0,
bottom: 10,
width: 100,
height: 10,
x: 0,
y: 0,
toJSON: () => ({}),
});
fireEvent.pointerDown(processingBar, { clientX: 25, pointerId: 1 });
fireEvent.pointerMove(processingBar, { clientX: 100, pointerId: 1 });
fireEvent.pointerUp(processingBar, { clientX: 100, pointerId: 1 });
expect(screen.getByLabelText('传播起始帧')).toHaveValue(2);
expect(screen.getByLabelText('传播结束帧')).toHaveValue(5);
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledWith(expect.objectContaining({
frame_id: 10,
steps: [expect.objectContaining({ direction: 'forward', max_frames: 5 })],
})));
});
it('removes propagation history bars when clearing the same frame range', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
{ id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
{ id: 13, project_id: 1, frame_index: 3, image_url: '/frame-3.jpg', width: 640, height: 360 },
{ id: 14, project_id: 1, frame_index: 4, image_url: '/frame-4.jpg', width: 640, height: 360 },
]);
apiMock.buildAnnotationPayload.mockReturnValue({
project_id: 1,
frame_id: 10,
mask_data: {
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
label: '胆囊',
color: '#ff0000',
},
bbox: [0.1, 0.1, 0.2, 0.2],
});
apiMock.getProjectAnnotations
.mockResolvedValueOnce([])
.mockResolvedValue([
{ id: 101, frame_id: 11 },
{ id: 102, frame_id: 12 },
]);
apiMock.annotationToMask.mockImplementation((annotation) => ({
id: `annotation-${annotation.id}`,
annotationId: String(annotation.id),
frameId: String(annotation.frame_id),
pathData: 'M 0 0 Z',
label: 'Propagated',
color: '#ff0000',
saved: true,
saveStatus: 'saved',
segmentation: [[64, 36, 192, 36, 192, 108]],
metadata: { source: 'sam2_propagation', propagated_from_frame_id: 10 },
}));
apiMock.deleteAnnotation.mockResolvedValue(undefined);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(5));
act(() => {
useStore.setState({
masks: [{
id: 'annotation-8',
annotationId: '8',
frameId: '10',
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[64, 36, 192, 36, 192, 108]],
bbox: [64, 36, 128, 72],
}],
});
});
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
const processingBar = screen.getByLabelText('视频处理进度条');
vi.spyOn(processingBar, 'getBoundingClientRect').mockReturnValue({
left: 0,
right: 100,
top: 0,
bottom: 10,
width: 100,
height: 10,
x: 0,
y: 0,
toJSON: () => ({}),
});
fireEvent.pointerDown(processingBar, { clientX: 25, pointerId: 1 });
fireEvent.pointerMove(processingBar, { clientX: 100, pointerId: 1 });
fireEvent.pointerUp(processingBar, { clientX: 100, pointerId: 1 });
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
expect(await screen.findByTestId('propagation-history-segment')).toBeInTheDocument();
act(() => {
useStore.setState({
masks: [
{ id: 'annotation-101', annotationId: '101', frameId: '11', pathData: 'M 1 1 Z', label: 'Propagated 1', color: '#ff0000', saved: true, saveStatus: 'saved', metadata: { source: 'sam2_propagation', propagated_from_frame_id: 10 } },
{ id: 'annotation-102', annotationId: '102', frameId: '12', pathData: 'M 2 2 Z', label: 'Propagated 2', color: '#ff0000', saved: true, saveStatus: 'saved', metadata: { source: 'sam2_propagation', propagated_from_frame_id: 10 } },
],
});
});
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
await waitFor(() => expect(screen.queryByTestId('propagation-history-segment')).not.toBeInTheDocument());
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('101');
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('102');
});
it('auto-propagates all reference-frame masks in both directions inside the selected range', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
{ id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
]);
apiMock.getTask.mockResolvedValue({
id: 31,
status: 'success',
progress: 100,
message: '自动传播完成',
result: {
processed_frame_count: 8,
created_annotation_count: 4,
completed_steps: 4,
},
});
apiMock.buildAnnotationPayload.mockImplementation((_projectId, mask) => (
mask.id === 'annotation-10'
? {
project_id: 1,
frame_id: 11,
mask_data: {
polygons: [[[0.4, 0.4], [0.5, 0.4], [0.5, 0.5]]],
label: '肝脏',
color: '#00ff00',
},
bbox: [0.4, 0.4, 0.1, 0.1],
}
: {
project_id: 1,
frame_id: 11,
mask_data: {
polygons: [[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2]]],
label: '胆囊',
color: '#ff0000',
},
bbox: [0.1, 0.1, 0.1, 0.1],
}
));
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
act(() => {
useStore.setState({
currentFrameIndex: 1,
masks: [
{
id: 'annotation-9',
annotationId: '9',
frameId: '11',
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[64, 36, 128, 36, 128, 72]],
},
{
id: 'annotation-10',
annotationId: '10',
frameId: '11',
pathData: 'M 1 1 Z',
label: '肝脏',
color: '#00ff00',
segmentation: [[256, 144, 320, 144, 320, 180]],
},
],
});
});
fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '1' } });
fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '3' } });
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledTimes(1));
const queuedPayload = apiMock.queuePropagationTask.mock.calls[0][0];
expect(queuedPayload.steps).toEqual([
expect.objectContaining({ direction: 'backward', max_frames: 2, seed: expect.objectContaining({ label: '胆囊' }) }),
expect.objectContaining({ direction: 'forward', max_frames: 2, seed: expect.objectContaining({ label: '胆囊' }) }),
expect.objectContaining({ direction: 'backward', max_frames: 2, seed: expect.objectContaining({ label: '肝脏' }) }),
expect.objectContaining({ direction: 'forward', max_frames: 2, seed: expect.objectContaining({ label: '肝脏' }) }),
]);
await waitFor(() => expect(screen.getByText('已自动传播 2 个参考 mask处理 8 帧次,删除旧区域 0 个,保存 4 个区域')).toBeInTheDocument());
});
});