拆分图片画布历史与持久化协调器
新增画布历史 hook 承接撤销重做快照逻辑 新增项目持久化 hook 承接加载资源创建与自动保存时序 补充 hook 单测并更新图片画布拆分跟踪文档
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
||||
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||
|
||||
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
||||
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/image-editor/editorProjectClient')
|
||||
>('../../services/image-editor/editorProjectClient');
|
||||
return {
|
||||
...actual,
|
||||
createEditorProjectResource: createEditorProjectResourceMock,
|
||||
loadEditorProject: loadEditorProjectMock,
|
||||
loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock,
|
||||
saveEditorProjectLayout: saveEditorProjectLayoutMock,
|
||||
};
|
||||
});
|
||||
|
||||
function createLayer(id: string): CanvasLayer {
|
||||
return {
|
||||
id,
|
||||
resourceId: `local-${id}`,
|
||||
title: '账号素材A',
|
||||
src: 'data:image/png;base64,YQ==',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
zIndex: 3,
|
||||
sourceType: 'uploaded',
|
||||
sourceAssetId: 'asset-a',
|
||||
};
|
||||
}
|
||||
|
||||
function ProjectPersistenceHarness() {
|
||||
const [layers, setLayers] = useState<CanvasLayer[]>([]);
|
||||
const [viewport, setViewport] = useState<CanvasViewport>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
});
|
||||
const [projectTitle, setProjectTitle] = useState('');
|
||||
const [projectRenameValue, setProjectRenameValue] = useState('');
|
||||
const layersRef = useRef(layers);
|
||||
const viewportRef = useRef(viewport);
|
||||
const selectedLayerRef = useRef<string | null>(null);
|
||||
const layerCounterRef = useRef(0);
|
||||
|
||||
layersRef.current = layers;
|
||||
viewportRef.current = viewport;
|
||||
|
||||
const persistence = useImageCanvasProjectPersistence({
|
||||
refs: {
|
||||
layersRef,
|
||||
viewportRef,
|
||||
},
|
||||
setters: {
|
||||
setProjectTitle,
|
||||
setProjectRenameValue,
|
||||
setViewport,
|
||||
setLayers,
|
||||
selectSingleLayer: (layerId) => {
|
||||
selectedLayerRef.current = layerId;
|
||||
},
|
||||
setLayerCounter: (value) => {
|
||||
layerCounterRef.current = value;
|
||||
},
|
||||
},
|
||||
layers,
|
||||
viewport,
|
||||
openEditorLoginModal: vi.fn(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="project-id">{persistence.projectId ?? '-'}</span>
|
||||
<span data-testid="project-title">{projectTitle}</span>
|
||||
<span data-testid="project-rename">{projectRenameValue}</span>
|
||||
<span data-testid="layers">
|
||||
{layers.map((layer) => `${layer.id}:${layer.resourceId}`).join(',')}
|
||||
</span>
|
||||
<span data-testid="selected">{selectedLayerRef.current ?? '-'}</span>
|
||||
<span data-testid="counter">{layerCounterRef.current}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
persistence.appendCanvasLayersWithResources([createLayer('layer-a')]);
|
||||
}}
|
||||
>
|
||||
append
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useImageCanvasProjectPersistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValue({
|
||||
projectId: 'editor-project-default',
|
||||
title: '空画布项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
createEditorProjectResourceMock.mockResolvedValue({
|
||||
resourceId: 'resource-added-asset-a',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: 'data:image/png;base64,YQ==',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
});
|
||||
saveEditorProjectLayoutMock.mockResolvedValue({
|
||||
projectId: 'editor-project-default',
|
||||
title: '空画布项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('saves appended layers with the server resource id immediately after resource creation', async () => {
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
expect(screen.getByTestId('project-title').textContent).toBe('空画布项目');
|
||||
expect(screen.getByTestId('project-rename').textContent).toBe(
|
||||
'空画布项目',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'append' }).click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('layers').textContent).toBe(
|
||||
'layer-a:local-layer-a',
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('layers').textContent).toBe(
|
||||
'layer-a:resource-added-asset-a',
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
layers: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-a',
|
||||
resourceId: 'resource-added-asset-a',
|
||||
sourceAssetId: 'asset-a',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user