保存标注前预检陈旧id
- dirty 标注保存前拉取后端标注 id 列表,已缺失的本地旧 annotationId 直接重新创建 - 保留 PATCH 404 兜底,覆盖预检后并发删除的情况 - 扩充分割工作区测试,区分预检缺失和 PATCH 后缺失两条路径 - 同步更新接口契约、需求冻结、设计冻结、测试计划和 AGENTS 说明
This commit is contained in:
@@ -387,6 +387,10 @@ describe('VideoWorkspace', () => {
|
||||
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,
|
||||
@@ -445,11 +449,12 @@ describe('VideoWorkspace', () => {
|
||||
expect(apiMock.saveAnnotation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recreates a dirty local annotation when the backend PATCH target is already missing', async () => {
|
||||
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) => ({
|
||||
@@ -469,7 +474,6 @@ describe('VideoWorkspace', () => {
|
||||
template_id: 2,
|
||||
mask_data: { polygons: [], label: '胆囊' },
|
||||
});
|
||||
apiMock.updateAnnotation.mockRejectedValueOnce({ response: { status: 404 } });
|
||||
apiMock.saveAnnotation.mockResolvedValueOnce({ id: 120 });
|
||||
|
||||
render(<VideoWorkspace />);
|
||||
@@ -497,9 +501,8 @@ describe('VideoWorkspace', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存 1 个改动' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.updateAnnotation).toHaveBeenCalledWith('11859', expect.objectContaining({
|
||||
template_id: 2,
|
||||
})));
|
||||
await waitFor(() => expect(apiMock.saveAnnotation).toHaveBeenCalled());
|
||||
expect(apiMock.updateAnnotation).not.toHaveBeenCalled();
|
||||
expect(apiMock.saveAnnotation).toHaveBeenCalledWith({
|
||||
project_id: 1,
|
||||
frame_id: 10,
|
||||
@@ -518,6 +521,68 @@ describe('VideoWorkspace', () => {
|
||||
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 },
|
||||
|
||||
@@ -767,11 +767,27 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
return 0;
|
||||
}
|
||||
|
||||
let existingAnnotationIds: Set<string> | null = null;
|
||||
if (updateItems.length > 0) {
|
||||
try {
|
||||
const annotations = await getProjectAnnotations(currentProject.id);
|
||||
existingAnnotationIds = new Set(annotations.map((annotation) => String(annotation.id)));
|
||||
} catch {
|
||||
existingAnnotationIds = null;
|
||||
}
|
||||
}
|
||||
|
||||
let recreatedMissingCount = 0;
|
||||
const recreatedMaskIds: string[] = [];
|
||||
await Promise.all([
|
||||
...createItems.map(({ payload }) => saveAnnotation(payload)),
|
||||
...updateItems.map(async ({ maskId, annotationId, updatePayload, createPayload }) => {
|
||||
if (existingAnnotationIds && !existingAnnotationIds.has(String(annotationId))) {
|
||||
recreatedMissingCount += 1;
|
||||
recreatedMaskIds.push(maskId);
|
||||
await saveAnnotation(createPayload);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAnnotation(annotationId, updatePayload);
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user