拆分图片画布素材库状态模型
新增图片画布素材库状态 hook 补充素材库状态 hook 单测 收口主视图素材库文件夹与选择逻辑 更新图片画布前端拆分跟踪文档
This commit is contained in:
392
src/components/image-editor/useImageCanvasAssetLibrary.test.tsx
Normal file
392
src/components/image-editor/useImageCanvasAssetLibrary.test.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useRef } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import type { EditorAssetSnapshot } from '../../services/image-editor/editorProjectClient';
|
||||
import type { EditorAsset } from './ImageCanvasEditorTypes';
|
||||
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
|
||||
|
||||
const createEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
||||
const deleteEditorAssetMock = vi.hoisted(() => vi.fn());
|
||||
const deleteEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
||||
const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn());
|
||||
const updateEditorAssetMock = vi.hoisted(() => vi.fn());
|
||||
const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const defaultOpenEditorLoginModal = () => {};
|
||||
const defaultOnDeleteAssets = () => {};
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/image-editor/editorProjectClient')
|
||||
>('../../services/image-editor/editorProjectClient');
|
||||
return {
|
||||
...actual,
|
||||
createEditorAssetFolder: createEditorAssetFolderMock,
|
||||
deleteEditorAsset: deleteEditorAssetMock,
|
||||
deleteEditorAssetFolder: deleteEditorAssetFolderMock,
|
||||
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
|
||||
updateEditorAsset: updateEditorAssetMock,
|
||||
updateEditorAssetFolder: updateEditorAssetFolderMock,
|
||||
};
|
||||
});
|
||||
|
||||
function createUploadedAsset(overrides: Partial<EditorAsset> = {}): EditorAsset {
|
||||
return {
|
||||
id: 'asset-a',
|
||||
label: '素材A',
|
||||
src: 'data:image/png;base64,YQ==',
|
||||
width: 320,
|
||||
height: 240,
|
||||
folderId: 'project',
|
||||
sourceKind: 'uploaded',
|
||||
sourceType: 'uploaded',
|
||||
persisted: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createAssetSnapshot(
|
||||
overrides: Partial<EditorAssetSnapshot> = {},
|
||||
): EditorAssetSnapshot {
|
||||
return {
|
||||
assetId: 'asset-a',
|
||||
label: '素材A',
|
||||
imageSrc: 'data:image/png;base64,YQ==',
|
||||
width: 320,
|
||||
height: 240,
|
||||
folderId: 'project',
|
||||
sourceType: 'uploaded',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function AssetLibraryHarness({
|
||||
openEditorLoginModal = defaultOpenEditorLoginModal,
|
||||
onDeleteAssets = defaultOnDeleteAssets,
|
||||
}: {
|
||||
openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void;
|
||||
onDeleteAssets?: (assets: EditorAsset[]) => void;
|
||||
}) {
|
||||
const assetListRef = useRef<HTMLDivElement | null>(null);
|
||||
const assetLibrary = useImageCanvasAssetLibrary({
|
||||
assetListRef,
|
||||
openEditorLoginModal,
|
||||
onDeleteAssets,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={assetListRef}>
|
||||
{assetLibrary.groupedAssets.map((folder) => (
|
||||
<section key={folder.id} data-asset-folder-id={folder.id}>
|
||||
<h2>{folder.label}</h2>
|
||||
{folder.assets.map((asset, index) => (
|
||||
<button
|
||||
key={asset.id || `asset-${index}`}
|
||||
type="button"
|
||||
data-asset-id={asset.id}
|
||||
onClick={() => assetLibrary.toggleAssetSelected(asset.id)}
|
||||
>
|
||||
{asset.label}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<span data-testid="folders">
|
||||
{assetLibrary.assetFolders
|
||||
.map((folder) => `${folder.id}:${folder.label}:${folder.persisted}`)
|
||||
.join('|')}
|
||||
</span>
|
||||
<span data-testid="assets">
|
||||
{assetLibrary.assets
|
||||
.map((asset) => `${asset.id}:${asset.label}:${asset.folderId}`)
|
||||
.join('|')}
|
||||
</span>
|
||||
<span data-testid="active-upload-folder">
|
||||
{assetLibrary.activeUploadFolderId}
|
||||
</span>
|
||||
<span data-testid="all-selected">
|
||||
{String(assetLibrary.allSelectableAssetsSelected)}
|
||||
</span>
|
||||
<span data-testid="selected-assets">
|
||||
{[...assetLibrary.selectedAssetIds].join('|')}
|
||||
</span>
|
||||
<span data-testid="new-folder-name">{assetLibrary.newFolderName}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
assetLibrary.setNewFolderName('角色素材');
|
||||
assetLibrary.setCreatingFolder(true);
|
||||
}}
|
||||
>
|
||||
prepare folder
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void assetLibrary.commitNewAssetFolder()}
|
||||
>
|
||||
commit folder
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => assetLibrary.moveAssetToFolder('asset-a', 'folder-role')}
|
||||
>
|
||||
move asset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const target = assetLibrary.assets.find(
|
||||
(asset) => asset.id === 'asset-a',
|
||||
);
|
||||
if (target) {
|
||||
assetLibrary.deleteUploadedAsset(target);
|
||||
}
|
||||
}}
|
||||
>
|
||||
delete asset
|
||||
</button>
|
||||
<button type="button" onClick={assetLibrary.toggleAllAssetsSelected}>
|
||||
toggle all
|
||||
</button>
|
||||
<button type="button" onClick={assetLibrary.deleteSelectedAssets}>
|
||||
delete selected
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useImageCanvasAssetLibrary', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
loadEditorAssetLibraryMock.mockResolvedValue({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [createAssetSnapshot()],
|
||||
});
|
||||
createEditorAssetFolderMock.mockResolvedValue({
|
||||
folderId: 'folder-role',
|
||||
label: '角色素材',
|
||||
collapsed: false,
|
||||
systemDefault: false,
|
||||
});
|
||||
updateEditorAssetMock.mockResolvedValue(createAssetSnapshot());
|
||||
deleteEditorAssetMock.mockResolvedValue(createAssetSnapshot());
|
||||
deleteEditorAssetFolderMock.mockResolvedValue({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [],
|
||||
});
|
||||
updateEditorAssetFolderMock.mockResolvedValue({
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads and normalizes the account asset library', async () => {
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
{
|
||||
folderId: 'project-duplicate',
|
||||
label: '重复默认',
|
||||
sortOrder: 1,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [createAssetSnapshot()],
|
||||
});
|
||||
|
||||
render(<AssetLibraryHarness />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('folders').textContent).toBe(
|
||||
'project:项目素材:true',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('assets').textContent).toBe(
|
||||
'asset-a:素材A:project',
|
||||
);
|
||||
expect(screen.getByTestId('active-upload-folder').textContent).toBe(
|
||||
'project',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens login when loading the asset library is unauthorized', async () => {
|
||||
const openEditorLoginModal = vi.fn();
|
||||
loadEditorAssetLibraryMock.mockRejectedValueOnce(
|
||||
new ApiClientError({
|
||||
message: '未授权访问',
|
||||
status: 401,
|
||||
code: 'UNAUTHORIZED',
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AssetLibraryHarness openEditorLoginModal={openEditorLoginModal} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openEditorLoginModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a local folder and replaces it with the persisted folder id', async () => {
|
||||
let resolveCreateFolder: (
|
||||
folder: Awaited<ReturnType<typeof createEditorAssetFolderMock>>,
|
||||
) => void = () => {};
|
||||
createEditorAssetFolderMock.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveCreateFolder = resolve;
|
||||
}),
|
||||
);
|
||||
render(<AssetLibraryHarness />);
|
||||
|
||||
await screen.findByText('素材A');
|
||||
act(() => screen.getByRole('button', { name: 'prepare folder' }).click());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('new-folder-name').textContent).toBe(
|
||||
'角色素材',
|
||||
);
|
||||
});
|
||||
act(() => screen.getByRole('button', { name: 'commit folder' }).click());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('folders').textContent).toContain(
|
||||
'folder-',
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
resolveCreateFolder({
|
||||
folderId: 'folder-role',
|
||||
label: '角色素材',
|
||||
collapsed: false,
|
||||
systemDefault: false,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('folders').textContent).toContain(
|
||||
'folder-role:角色素材:true',
|
||||
);
|
||||
});
|
||||
expect(createEditorAssetFolderMock).toHaveBeenCalledWith('角色素材', 101);
|
||||
expect(screen.getByTestId('active-upload-folder').textContent).toBe(
|
||||
'folder-role',
|
||||
);
|
||||
});
|
||||
|
||||
it('moves a persisted asset to another folder', async () => {
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
{
|
||||
folderId: 'folder-role',
|
||||
label: '角色素材',
|
||||
sortOrder: 1,
|
||||
collapsed: false,
|
||||
systemDefault: false,
|
||||
},
|
||||
],
|
||||
assets: [createAssetSnapshot()],
|
||||
});
|
||||
|
||||
render(<AssetLibraryHarness />);
|
||||
|
||||
await screen.findByText('素材A');
|
||||
act(() => screen.getByRole('button', { name: 'move asset' }).click());
|
||||
|
||||
expect(screen.getByTestId('assets').textContent).toBe(
|
||||
'asset-a:素材A:folder-role',
|
||||
);
|
||||
expect(updateEditorAssetMock).toHaveBeenCalledWith('asset-a', {
|
||||
folderId: 'folder-role',
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes uploaded assets and reports them to the canvas cleanup callback', async () => {
|
||||
const onDeleteAssets = vi.fn();
|
||||
render(<AssetLibraryHarness onDeleteAssets={onDeleteAssets} />);
|
||||
|
||||
await screen.findByText('素材A');
|
||||
act(() => screen.getByRole('button', { name: 'delete asset' }).click());
|
||||
|
||||
expect(screen.getByTestId('assets').textContent).toBe('');
|
||||
expect(onDeleteAssets).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ id: 'asset-a' }),
|
||||
]);
|
||||
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
|
||||
});
|
||||
|
||||
it('selects and deletes selected uploaded assets', async () => {
|
||||
const onDeleteAssets = vi.fn();
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
createAssetSnapshot({ assetId: 'asset-a', label: '素材A' }),
|
||||
createAssetSnapshot({ assetId: 'asset-b', label: '素材B' }),
|
||||
],
|
||||
});
|
||||
|
||||
render(<AssetLibraryHarness onDeleteAssets={onDeleteAssets} />);
|
||||
|
||||
await screen.findByText('素材A');
|
||||
act(() => screen.getByRole('button', { name: 'toggle all' }).click());
|
||||
expect(screen.getByTestId('all-selected').textContent).toBe('true');
|
||||
act(() => screen.getByRole('button', { name: '素材B' }).click());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('selected-assets').textContent).toBe('asset-a');
|
||||
});
|
||||
act(() => screen.getByRole('button', { name: 'delete selected' }).click());
|
||||
|
||||
expect(screen.getByTestId('assets').textContent).toBe(
|
||||
'asset-b:素材B:project',
|
||||
);
|
||||
expect(onDeleteAssets).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ id: 'asset-a' }),
|
||||
]);
|
||||
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
|
||||
expect(deleteEditorAssetMock).not.toHaveBeenCalledWith('asset-b');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user