- 抽出 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 文档
1834 lines
72 KiB
TypeScript
1834 lines
72 KiB
TypeScript
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());
|
||
});
|
||
});
|