Files
Genarrative/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx
T
k88936 8abeb333bb
Project CI / Repository checks (push) Successful in 56s
Project CI / Frontend tests (push) Successful in 4m1s
Project CI / Backend tests (push) Successful in 4m26s
Project CI / Native shell tests (push) Successful in 15m12s
统一画布快速编辑白名单与入口行为 (#140)
- 使用白名单控制快速编辑功能的显示

- 同步浮动工具栏、右键菜单、打开入口和提交门禁
- 禁用角色动作序列帧(整体)、音频和单个图标(explain: backend reject)的快速编辑

- 把快速编辑api改为只接受resource Id | asset Id,删除 image_src 参数, 以保证校验

其中包含生产数据迁移。迁移用于清理历史普通图片错误持久化的 assetKind="image",覆盖 asset → project-resource → showcase → canvas 四个作用域,并包含 operator 鉴权、分批 dry-run、批次 SHA-256 绑定 apply、active 结构化画
  > 布迁移摘要同步及最终零残留复核。

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/140
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-12 14:46:24 +08:00

715 lines
21 KiB
TypeScript

/* @vitest-environment jsdom */
import {
act,
fireEvent,
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 {
AssetPointerDragState,
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 submitEditorAssetShowcaseMock = 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,
submitEditorAssetShowcase: submitEditorAssetShowcaseMock,
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',
assetKind: null,
...overrides,
};
}
function AssetLibraryHarness({
canAccessProtectedData = true,
openEditorLoginModal = defaultOpenEditorLoginModal,
onDeleteAssets = defaultOnDeleteAssets,
}: {
canAccessProtectedData?: boolean;
openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void;
onDeleteAssets?: (assets: EditorAsset[]) => boolean | void;
}) {
const assetListRef = useRef<HTMLDivElement | null>(null);
const assetPointerDragRef = useRef<AssetPointerDragState | null>(null);
const assetLibrary = useImageCanvasAssetLibrary({
assetListRef,
assetPointerDragRef,
canAccessProtectedData,
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={(event) =>
assetLibrary.toggleAssetSelected(asset.id, {
visibleAssetIds: assetLibrary.selectableAssets.map(
(item) => item.id,
),
shiftKey: event.shiftKey,
})
}
>
{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}:${
asset.publicShowcaseEnabled ?? 'unset'
}`,
)
.join('|')}
</span>
<span data-testid="asset-submit-errors">
{assetLibrary.assets
.map((asset) => `${asset.id}:${asset.showcaseSubmitError ?? ''}`)
.join('|')}
</span>
<span data-testid="active-upload-folder">
{assetLibrary.activeUploadFolderId}
</span>
<span data-testid="all-selected">
{String(
assetLibrary.selectableAssets.length > 0 &&
assetLibrary.selectableAssets.every((asset) =>
assetLibrary.selectedAssetIds.has(asset.id),
),
)}
</span>
<span data-testid="selected-assets">
{[...assetLibrary.selectedAssetIds].join('|')}
</span>
<span data-testid="selection-mode">
{String(assetLibrary.isAssetSelectionMode)}
</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={() => {
const target = assetLibrary.assets.find(
(asset) => asset.id === 'asset-a',
);
if (target) {
assetLibrary.submitAssetToShowcase(target);
}
}}
>
submit asset to showcase
</button>
<button
type="button"
onClick={() =>
assetLibrary.toggleAllAssetsSelected(
assetLibrary.selectableAssets.map((asset) => asset.id),
)
}
>
toggle all
</button>
<button type="button" onClick={() => assetLibrary.deleteSelectedAssets()}>
delete selected
</button>
<button type="button" onClick={assetLibrary.openAssetSelectionMode}>
open selection
</button>
<button type="button" onClick={assetLibrary.closeAssetSelectionMode}>
close selection
</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());
submitEditorAssetShowcaseMock.mockResolvedValue({
resourceId: 'editor-showcase:asset-a',
showcaseId: 'editor-showcase:asset-a',
assetId: 'asset-a',
projectId: 'project-1',
imageSrc: 'data:image/png;base64,YQ==',
width: 320,
height: 240,
sourceType: 'generated',
reviewStatus: 'pending',
displayEnabled: false,
likeCount: 0,
generationCostMudPoints: 4,
});
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:unset',
);
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('does not request the protected asset library before login is available', () => {
render(<AssetLibraryHarness canAccessProtectedData={false} />);
expect(loadEditorAssetLibraryMock).not.toHaveBeenCalled();
expect(screen.getByTestId('folders').textContent).toBe(
'project:项目素材:false',
);
});
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:unset',
);
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('keeps uploaded assets when canvas cleanup vetoes deletion', async () => {
const onDeleteAssets = vi.fn().mockReturnValue(false);
render(<AssetLibraryHarness onDeleteAssets={onDeleteAssets} />);
await screen.findByText('素材A');
act(() => screen.getByRole('button', { name: 'delete asset' }).click());
expect(screen.getByTestId('assets').textContent).toContain('asset-a:素材A');
expect(onDeleteAssets).toHaveBeenCalledWith([
expect.objectContaining({ id: 'asset-a' }),
]);
expect(deleteEditorAssetMock).not.toHaveBeenCalled();
});
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:unset',
);
expect(onDeleteAssets).toHaveBeenCalledWith([
expect.objectContaining({ id: 'asset-a' }),
]);
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
expect(deleteEditorAssetMock).not.toHaveBeenCalledWith('asset-b');
});
it('clears selected status when selection mode closes', async () => {
render(<AssetLibraryHarness />);
await screen.findByText('素材A');
act(() => screen.getByRole('button', { name: 'open selection' }).click());
act(() => screen.getByRole('button', { name: '素材A' }).click());
expect(screen.getByTestId('selected-assets').textContent).toBe('asset-a');
act(() => screen.getByRole('button', { name: 'close selection' }).click());
expect(screen.getByTestId('selection-mode').textContent).toBe('false');
expect(screen.getByTestId('selected-assets').textContent).toBe('');
});
it('clears the range anchor when toggling all visible assets', async () => {
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' }),
createAssetSnapshot({ assetId: 'asset-c', label: '素材C' }),
],
});
render(<AssetLibraryHarness />);
fireEvent.click(await screen.findByRole('button', { name: '素材A' }));
fireEvent.click(screen.getByRole('button', { name: 'toggle all' }));
fireEvent.click(screen.getByRole('button', { name: 'toggle all' }));
fireEvent.click(screen.getByRole('button', { name: '素材C' }), {
shiftKey: true,
});
expect(screen.getByTestId('selected-assets').textContent).toBe('asset-c');
});
it('reconciles selection only when an asset actually ceases to exist', async () => {
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' }),
createAssetSnapshot({ assetId: 'asset-c', label: '素材C' }),
],
});
render(<AssetLibraryHarness />);
await screen.findByRole('button', { name: '素材A' });
fireEvent.click(screen.getByRole('button', { name: 'open selection' }));
fireEvent.click(screen.getByRole('button', { name: 'toggle all' }));
fireEvent.click(screen.getByRole('button', { name: 'delete asset' }));
await waitFor(() => {
expect(screen.getByTestId('selected-assets').textContent).toBe(
'asset-b|asset-c',
);
});
});
it('submits a generated asset to showcase review', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
createAssetSnapshot({
assetId: 'asset-a',
sourceType: 'generated',
publicShowcaseEnabled: true,
}),
],
});
render(<AssetLibraryHarness />);
await screen.findByText('素材A');
act(() =>
screen.getByRole('button', { name: 'submit asset to showcase' }).click(),
);
await waitFor(() => {
expect(screen.getByTestId('assets').textContent).toBe(
'asset-a:素材A:project:false',
);
});
expect(submitEditorAssetShowcaseMock).toHaveBeenCalledWith('asset-a');
});
it('submits legacy generated assets without a source resource id', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
createAssetSnapshot({
assetId: 'asset-a',
sourceType: 'generated',
publicShowcaseEnabled: null,
}),
],
});
render(<AssetLibraryHarness />);
await screen.findByText('素材A');
act(() =>
screen.getByRole('button', { name: 'submit asset to showcase' }).click(),
);
await waitFor(() => {
expect(submitEditorAssetShowcaseMock).toHaveBeenCalledWith('asset-a');
});
});
it('rolls back showcase state when submit fails', async () => {
submitEditorAssetShowcaseMock.mockRejectedValueOnce(new Error('network'));
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
createAssetSnapshot({
assetId: 'asset-a',
sourceType: 'generated',
sourceResourceId: 'resource-a',
publicShowcaseEnabled: true,
}),
],
});
render(<AssetLibraryHarness />);
await screen.findByText('素材A');
act(() =>
screen.getByRole('button', { name: 'submit asset to showcase' }).click(),
);
await waitFor(() => {
expect(screen.getByTestId('assets').textContent).toBe(
'asset-a:素材A:project:true',
);
});
});
it('keeps the backend showcase rejection reason on the asset', async () => {
submitEditorAssetShowcaseMock.mockRejectedValueOnce(
new ApiClientError({
message: '只有生成素材可以提交精选审核',
status: 400,
code: 'BAD_REQUEST',
}),
);
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
createAssetSnapshot({
assetId: 'asset-a',
sourceType: 'generated',
publicShowcaseEnabled: true,
}),
],
});
render(<AssetLibraryHarness />);
await screen.findByText('素材A');
act(() =>
screen.getByRole('button', { name: 'submit asset to showcase' }).click(),
);
await waitFor(() => {
expect(screen.getByTestId('asset-submit-errors').textContent).toBe(
'asset-a:只有生成素材可以提交精选审核',
);
});
expect(screen.getByTestId('assets').textContent).toBe(
'asset-a:素材A:project:true',
);
});
});