1484 lines
47 KiB
TypeScript
1484 lines
47 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import {
|
|
act,
|
|
fireEvent,
|
|
render as testingLibraryRender,
|
|
screen,
|
|
waitFor,
|
|
within,
|
|
} from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
ApiClientError,
|
|
AuthUiContext,
|
|
createAuthValue,
|
|
createDataTransferStub,
|
|
createDeferred,
|
|
dispatchPointerEvent,
|
|
ImageCanvasEditorView,
|
|
setupImageCanvasEditorViewTestLifecycle,
|
|
} from './ImageCanvasEditorView.test-utils';
|
|
|
|
function render(ui: Parameters<typeof testingLibraryRender>[0]) {
|
|
const result = testingLibraryRender(ui);
|
|
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
|
|
return result;
|
|
}
|
|
|
|
const generateEditorImageMock = vi.hoisted(() => vi.fn());
|
|
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
|
|
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
|
|
const editEditorImageMock = vi.hoisted(() => vi.fn());
|
|
const createEditorAssetMock = vi.hoisted(() => vi.fn());
|
|
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
|
const createEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
|
const updateEditorAssetMock = vi.hoisted(() => vi.fn());
|
|
const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
|
const deleteEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
|
const deleteEditorAssetMock = vi.hoisted(() => vi.fn());
|
|
const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn());
|
|
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
|
|
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
|
|
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
|
|
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
|
|
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
|
const getSignedAssetReadUrlMock = 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,
|
|
editEditorImage: editEditorImageMock,
|
|
createEditorAsset: createEditorAssetMock,
|
|
createEditorAssetFolder: createEditorAssetFolderMock,
|
|
createEditorProjectResource: createEditorProjectResourceMock,
|
|
deleteEditorAsset: deleteEditorAssetMock,
|
|
deleteEditorAssetFolder: deleteEditorAssetFolderMock,
|
|
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
|
|
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
|
|
generateEditorImage: generateEditorImageMock,
|
|
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
|
|
loadEditorProject: loadEditorProjectMock,
|
|
loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock,
|
|
renameEditorProject: renameEditorProjectMock,
|
|
saveEditorProjectLayout: saveEditorProjectLayoutMock,
|
|
updateEditorAsset: updateEditorAssetMock,
|
|
updateEditorAssetFolder: updateEditorAssetFolderMock,
|
|
};
|
|
});
|
|
|
|
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
|
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
|
}));
|
|
|
|
vi.mock('../../services/assetReadUrlService', async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import('../../services/assetReadUrlService')
|
|
>('../../services/assetReadUrlService');
|
|
return {
|
|
...actual,
|
|
getSignedAssetReadUrl: (
|
|
...args: Parameters<typeof actual.getSignedAssetReadUrl>
|
|
) =>
|
|
getSignedAssetReadUrlMock(...args) ??
|
|
Promise.resolve(
|
|
`https://signed.example.com/${args[0]?.objectKey ?? 'asset'}`,
|
|
),
|
|
};
|
|
});
|
|
|
|
function createSizedPngFile(name: string, width: number, height: number) {
|
|
const bytes = new Uint8Array([
|
|
0x89,
|
|
0x50,
|
|
0x4e,
|
|
0x47,
|
|
0x0d,
|
|
0x0a,
|
|
0x1a,
|
|
0x0a,
|
|
0,
|
|
0,
|
|
0,
|
|
13,
|
|
0x49,
|
|
0x48,
|
|
0x44,
|
|
0x52,
|
|
(width >> 24) & 0xff,
|
|
(width >> 16) & 0xff,
|
|
(width >> 8) & 0xff,
|
|
width & 0xff,
|
|
(height >> 24) & 0xff,
|
|
(height >> 16) & 0xff,
|
|
(height >> 8) & 0xff,
|
|
height & 0xff,
|
|
8,
|
|
6,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
]);
|
|
return new File([bytes], name, { type: 'image/png' });
|
|
}
|
|
|
|
function createTestImageAsset({
|
|
assetId,
|
|
label,
|
|
imageSrc,
|
|
objectKey,
|
|
assetObjectId,
|
|
sourceType = 'uploaded',
|
|
sourceResourceId,
|
|
}: {
|
|
assetId: string;
|
|
label: string;
|
|
imageSrc?: string;
|
|
objectKey?: string | null;
|
|
assetObjectId?: string | null;
|
|
sourceType?: 'uploaded' | 'generated' | 'mock_generated';
|
|
sourceResourceId?: string | null;
|
|
}) {
|
|
const resolvedObjectKey =
|
|
objectKey === undefined
|
|
? `generated-character-drafts/editor/asset-library/image/${assetId}.png`
|
|
: objectKey;
|
|
return {
|
|
assetId,
|
|
folderId: 'project',
|
|
label,
|
|
imageSrc: imageSrc ?? (resolvedObjectKey ? `/${resolvedObjectKey}` : ''),
|
|
objectKey: resolvedObjectKey,
|
|
assetObjectId:
|
|
assetObjectId === undefined ? `assetobj-${assetId}` : assetObjectId,
|
|
width: 320,
|
|
height: 240,
|
|
sourceType,
|
|
sourceResourceId,
|
|
};
|
|
}
|
|
|
|
describe('ImageCanvasEditorView asset library integration', () => {
|
|
setupImageCanvasEditorViewTestLifecycle({
|
|
generateEditorImageMock,
|
|
generateEditorIconSpritesheetMock,
|
|
generateEditorCharacterAnimationMock,
|
|
editEditorImageMock,
|
|
createEditorAssetMock,
|
|
createEditorProjectResourceMock,
|
|
createEditorAssetFolderMock,
|
|
updateEditorAssetMock,
|
|
updateEditorAssetFolderMock,
|
|
deleteEditorAssetFolderMock,
|
|
deleteEditorAssetMock,
|
|
loadEditorAssetLibraryMock,
|
|
loadEditorProjectMock,
|
|
loadOrCreateRecentEditorProjectMock,
|
|
renameEditorProjectMock,
|
|
saveEditorProjectLayoutMock,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
getSignedAssetReadUrlMock.mockImplementation(
|
|
async ({ objectKey }: { objectKey?: string }) =>
|
|
`https://signed.example.com/${objectKey ?? 'asset'}`,
|
|
);
|
|
uploadEditorMediaAssetFileMock.mockImplementation(
|
|
async (file: File, mediaType: 'image' | 'video' | 'audio') => ({
|
|
src: `https://signed.example.com/editor/${mediaType}/${file.name}`,
|
|
legacyPublicPath: `/generated-character-drafts/editor/asset-library/${mediaType}/${file.name}`,
|
|
objectKey: `generated-character-drafts/editor/asset-library/${mediaType}/${file.name}`,
|
|
assetObjectId: `assetobj-editor-${mediaType}`,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('keeps only one default asset folder when the persisted library returns duplicated defaults', async () => {
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
{
|
|
folderId: 'legacy-project',
|
|
label: '旧项目素材',
|
|
sortOrder: 1,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [],
|
|
});
|
|
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
expect(
|
|
await screen.findByRole('region', { name: '项目素材' }),
|
|
).toBeTruthy();
|
|
expect(screen.queryByRole('region', { name: '旧项目素材' })).toBeNull();
|
|
expect(screen.getAllByRole('button', { name: /上传到/u })).toHaveLength(1);
|
|
});
|
|
|
|
it('toggles the shared sidebar from canvas panel buttons', () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
|
|
const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
|
|
const assetsButton = within(panelToolbar).getByRole('button', {
|
|
name: '打开素材',
|
|
});
|
|
const layersButton = within(panelToolbar).getByRole('button', {
|
|
name: '打开图层',
|
|
});
|
|
|
|
expect(within(sidebar).getByText('素材')).toBeTruthy();
|
|
expect(
|
|
within(sidebar).getByRole('button', { name: '添加拼图素材' }),
|
|
).toBeTruthy();
|
|
expect(assetsButton.getAttribute('aria-pressed')).toBe('true');
|
|
expect(screen.queryByRole('button', { name: '打开已生成文件' })).toBeNull();
|
|
expect(screen.queryByRole('button', { name: '收起素材栏' })).toBeNull();
|
|
expect(screen.queryByRole('button', { name: '展开素材栏' })).toBeNull();
|
|
|
|
fireEvent.click(layersButton);
|
|
|
|
const layerSidebar = screen.getByRole('complementary', {
|
|
name: '图片资源栏',
|
|
});
|
|
expect(within(layerSidebar).getByText('图层')).toBeTruthy();
|
|
expect(
|
|
within(layerSidebar).getByRole('button', { name: '选择图层拼图素材' }),
|
|
).toBeTruthy();
|
|
expect(layersButton.getAttribute('aria-pressed')).toBe('true');
|
|
expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
|
|
|
|
fireEvent.click(layersButton);
|
|
|
|
expect(
|
|
screen.queryByRole('complementary', { name: '图片资源栏' }),
|
|
).toBeNull();
|
|
expect(layersButton.getAttribute('aria-pressed')).toBe('false');
|
|
});
|
|
|
|
it('groups assets by folder and renames sidebar materials', async () => {
|
|
const user = userEvent.setup();
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
|
|
expect(
|
|
within(sidebar).getByRole('region', { name: '项目素材' }),
|
|
).toBeTruthy();
|
|
expect(
|
|
within(sidebar).queryByRole('region', { name: '参考素材' }),
|
|
).toBeNull();
|
|
|
|
await user.click(
|
|
screen.getByRole('button', { name: '重命名素材拼图素材' }),
|
|
);
|
|
const renameInput = screen.getByLabelText('重命名素材拼图素材');
|
|
await user.clear(renameInput);
|
|
await user.type(renameInput, '主视觉素材');
|
|
await user.click(
|
|
screen.getByRole('button', { name: '保存素材拼图素材名称' }),
|
|
);
|
|
|
|
expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
|
|
await user.click(screen.getByRole('button', { name: '添加主视觉素材' }));
|
|
|
|
expect(screen.getByAltText('画布图片:主视觉素材')).toBeTruthy();
|
|
});
|
|
|
|
it('downloads an asset directly from the asset library', async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = vi.fn(async (url: string) => {
|
|
if (url === '/creation-type-references/puzzle.webp') {
|
|
return new Response(new Blob(['asset'], { type: 'image/webp' }));
|
|
}
|
|
return new Response(null, { status: 404 });
|
|
}) as typeof fetch;
|
|
let downloadName = '';
|
|
let downloadHref = '';
|
|
Object.defineProperty(URL, 'createObjectURL', {
|
|
configurable: true,
|
|
value: vi.fn(() => 'blob:asset-library-download'),
|
|
});
|
|
Object.defineProperty(URL, 'revokeObjectURL', {
|
|
configurable: true,
|
|
value: vi.fn(),
|
|
});
|
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
|
|
function click(this: HTMLAnchorElement) {
|
|
downloadName = this.download;
|
|
downloadHref = this.href;
|
|
},
|
|
);
|
|
|
|
try {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
fireEvent.click(
|
|
await screen.findByRole('button', { name: '下载素材拼图素材' }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(downloadName).toMatch(/^拼图素材-\d{8}-\d{6}\.webp$/u);
|
|
});
|
|
expect(downloadHref).toBe('blob:asset-library-download');
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it('collapses folders, creates upload folders, and deletes uploaded materials', async () => {
|
|
const user = userEvent.setup();
|
|
const createObjectUrlSpy = vi.fn(() => 'blob:folder-uploaded-image');
|
|
Object.defineProperty(URL, 'createObjectURL', {
|
|
configurable: true,
|
|
value: createObjectUrlSpy,
|
|
});
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
await user.click(screen.getByRole('button', { name: '折叠项目素材' }));
|
|
expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
|
|
await user.click(screen.getByRole('button', { name: '展开项目素材' }));
|
|
expect(screen.getByRole('button', { name: '添加拼图素材' })).toBeTruthy();
|
|
|
|
await user.click(screen.getByRole('button', { name: '新建素材文件夹' }));
|
|
const folderNameInput = screen.getByLabelText('素材文件夹名称');
|
|
await user.type(folderNameInput, '角色上传');
|
|
await user.click(screen.getByRole('button', { name: '保存素材文件夹' }));
|
|
|
|
const uploadInput = screen.getByLabelText('上传媒体文件');
|
|
await user.click(screen.getByRole('button', { name: '上传到角色上传' }));
|
|
await userEvent.upload(
|
|
uploadInput,
|
|
new File(['image'], '角色草图.png', { type: 'image/png' }),
|
|
);
|
|
|
|
const customFolder = screen.getByRole('region', { name: '角色上传' });
|
|
await waitFor(() => {
|
|
expect(
|
|
within(customFolder).getByRole('button', { name: '添加角色草图.png' }),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
const uploadedAssetButton = within(customFolder).getByRole('button', {
|
|
name: '添加角色草图.png',
|
|
});
|
|
const uploadedAssetRow = uploadedAssetButton.closest(
|
|
'[data-asset-id="persisted-角色草图.png"]',
|
|
);
|
|
if (!uploadedAssetRow) {
|
|
throw new Error('uploaded asset row should exist');
|
|
}
|
|
fireEvent.contextMenu(uploadedAssetRow, {
|
|
clientX: 120,
|
|
clientY: 180,
|
|
});
|
|
await user.click(screen.getByRole('menuitem', { name: '删除' }));
|
|
|
|
expect(
|
|
screen.queryByRole('button', { name: '添加角色草图.png' }),
|
|
).toBeNull();
|
|
expect(screen.queryByAltText('画布图片:角色草图.png')).toBeNull();
|
|
});
|
|
|
|
it('renames and deletes asset folders through the persisted asset library API', async () => {
|
|
const user = userEvent.setup();
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
{
|
|
folderId: 'folder-role',
|
|
label: '角色',
|
|
sortOrder: 100,
|
|
collapsed: false,
|
|
systemDefault: false,
|
|
},
|
|
],
|
|
assets: [],
|
|
});
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
await screen.findByRole('region', { name: '角色' });
|
|
await user.click(screen.getByRole('button', { name: '重命名文件夹角色' }));
|
|
const folderRenameInput = screen.getByLabelText('重命名文件夹角色');
|
|
await user.clear(folderRenameInput);
|
|
await user.type(folderRenameInput, '角色参考');
|
|
await user.click(
|
|
screen.getByRole('button', { name: '保存文件夹角色名称' }),
|
|
);
|
|
|
|
expect(updateEditorAssetFolderMock).toHaveBeenCalledWith('folder-role', {
|
|
label: '角色参考',
|
|
});
|
|
|
|
await user.click(
|
|
screen.getByRole('button', { name: '删除文件夹角色参考' }),
|
|
);
|
|
|
|
expect(deleteEditorAssetFolderMock).toHaveBeenCalledWith('folder-role');
|
|
});
|
|
|
|
it('moves an asset to another folder when dragging inside the asset library', async () => {
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
{
|
|
folderId: 'folder-role',
|
|
label: '角色',
|
|
sortOrder: 100,
|
|
collapsed: false,
|
|
systemDefault: false,
|
|
},
|
|
],
|
|
assets: [
|
|
{
|
|
assetId: 'asset-puzzle',
|
|
folderId: 'project',
|
|
label: '拼图素材',
|
|
imageSrc: '/creation-type-references/puzzle.webp',
|
|
width: 640,
|
|
height: 640,
|
|
sourceType: 'uploaded',
|
|
},
|
|
],
|
|
});
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const sourceAsset = await screen.findByRole('button', {
|
|
name: '添加拼图素材',
|
|
});
|
|
const sourceAssetRow = sourceAsset.closest(
|
|
'.image-canvas-editor__asset-row',
|
|
);
|
|
const projectFolder = screen.getByRole('region', { name: '项目素材' });
|
|
const roleFolder = screen.getByRole('region', { name: '角色' });
|
|
const dataTransfer = createDataTransferStub();
|
|
|
|
if (!sourceAssetRow) {
|
|
throw new Error('asset row should exist');
|
|
}
|
|
fireEvent.dragStart(sourceAssetRow, { dataTransfer });
|
|
fireEvent.dragOver(roleFolder, { dataTransfer });
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('添加到素材')).toBeNull();
|
|
expect(roleFolder.className).toContain(
|
|
'image-canvas-editor__asset-folder--move-target',
|
|
);
|
|
});
|
|
fireEvent.drop(roleFolder, { dataTransfer });
|
|
|
|
expect(updateEditorAssetMock).toHaveBeenCalledWith('asset-puzzle', {
|
|
folderId: 'folder-role',
|
|
});
|
|
expect(
|
|
within(projectFolder).queryByRole('button', { name: '添加拼图素材' }),
|
|
).toBeNull();
|
|
expect(
|
|
within(roleFolder).getByRole('button', { name: '添加拼图素材' }),
|
|
).toBeTruthy();
|
|
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uploads multiple files as account-level assets without adding canvas layers', async () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
await userEvent.upload(screen.getByLabelText('上传媒体文件'), [
|
|
new File(['image-a'], '第一张.png', { type: 'image/png' }),
|
|
new File(['image-b'], '第二张.png', { type: 'image/png' }),
|
|
]);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole('button', { name: '添加第一张.png' }),
|
|
).toBeTruthy();
|
|
expect(
|
|
screen.getByRole('button', { name: '添加第二张.png' }),
|
|
).toBeTruthy();
|
|
});
|
|
expect(createEditorAssetMock).toHaveBeenCalledTimes(2);
|
|
expect(screen.queryByAltText('画布图片:第一张.png')).toBeNull();
|
|
expect(screen.queryByAltText('画布图片:第二张.png')).toBeNull();
|
|
});
|
|
|
|
it('adds bottom toolbar uploads to the canvas using the image resolution', async () => {
|
|
const user = userEvent.setup();
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
await user.click(screen.getByRole('button', { name: '上传工具' }));
|
|
await userEvent.upload(
|
|
screen.getByLabelText('上传媒体文件'),
|
|
createSizedPngFile('底栏上传.png', 1536, 1024),
|
|
);
|
|
|
|
expect(await screen.findByAltText('画布图片:底栏上传.png')).toBeTruthy();
|
|
await waitFor(() => {
|
|
expect(createEditorAssetMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
label: '底栏上传.png',
|
|
width: 1536,
|
|
height: 1024,
|
|
}),
|
|
);
|
|
});
|
|
const layerButton = screen.getByRole('button', {
|
|
name: '选择底栏上传.png',
|
|
});
|
|
expect(layerButton.getAttribute('style')).toContain('width: 1536px');
|
|
expect(layerButton.getAttribute('style')).toContain('height: 1024px');
|
|
});
|
|
|
|
it('uploads MP3 and MP4 files through OSS before adding them to the asset library', async () => {
|
|
const createdMediaElements: HTMLMediaElement[] = [];
|
|
const originalCreateElement = document.createElement.bind(document);
|
|
vi.spyOn(document, 'createElement').mockImplementation(
|
|
(tagName, options) => {
|
|
const element = originalCreateElement(tagName, options);
|
|
if (tagName === 'video' || tagName === 'audio') {
|
|
createdMediaElements.push(element as HTMLMediaElement);
|
|
}
|
|
return element;
|
|
},
|
|
);
|
|
Object.defineProperty(URL, 'createObjectURL', {
|
|
configurable: true,
|
|
value: vi.fn(() => 'blob:editor-media'),
|
|
});
|
|
Object.defineProperty(URL, 'revokeObjectURL', {
|
|
configurable: true,
|
|
value: vi.fn(),
|
|
});
|
|
Object.defineProperty(HTMLMediaElement.prototype, 'duration', {
|
|
configurable: true,
|
|
value: 6,
|
|
});
|
|
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(
|
|
() => undefined,
|
|
);
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
await userEvent.upload(screen.getByLabelText('上传媒体文件'), [
|
|
new File(['audio'], '胜利音效.mp3', { type: 'audio/mpeg' }),
|
|
new File(['video'], '开场动画.mp4', { type: 'video/mp4' }),
|
|
]);
|
|
act(() => {
|
|
createdMediaElements.forEach((element) =>
|
|
element.onloadedmetadata?.(new Event('loadedmetadata')),
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ name: '胜利音效.mp3' }),
|
|
'audio',
|
|
);
|
|
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ name: '开场动画.mp4' }),
|
|
'video',
|
|
);
|
|
});
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole('button', { name: '添加胜利音效.mp3' }),
|
|
).toBeTruthy();
|
|
expect(
|
|
screen.getByRole('button', { name: '添加开场动画.mp4' }),
|
|
).toBeTruthy();
|
|
});
|
|
expect(createEditorAssetMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
label: '胜利音效.mp3',
|
|
imageSrc:
|
|
'/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
|
|
objectKey:
|
|
'generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
|
|
assetObjectId: 'assetobj-editor-audio',
|
|
}),
|
|
);
|
|
expect(createEditorAssetMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
label: '开场动画.mp4',
|
|
imageSrc:
|
|
'/generated-character-drafts/editor/asset-library/video/开场动画.mp4',
|
|
objectKey:
|
|
'generated-character-drafts/editor/asset-library/video/开场动画.mp4',
|
|
assetObjectId: 'assetobj-editor-video',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('opens login before uploading assets while logged out and resumes after login', async () => {
|
|
const openLoginModal = vi.fn();
|
|
const authValue = createAuthValue({ openLoginModal });
|
|
|
|
const { rerender } = render(
|
|
<AuthUiContext.Provider value={authValue}>
|
|
<ImageCanvasEditorView />
|
|
</AuthUiContext.Provider>,
|
|
);
|
|
|
|
await userEvent.upload(screen.getByLabelText('上传媒体文件'), [
|
|
new File(['image'], '登录后上传.png', { type: 'image/png' }),
|
|
]);
|
|
|
|
expect(openLoginModal).toHaveBeenCalled();
|
|
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
|
expect(
|
|
screen.queryByRole('button', { name: '上传失败登录后上传.png' }),
|
|
).toBeNull();
|
|
|
|
const resumeUpload =
|
|
openLoginModal.mock.calls[openLoginModal.mock.calls.length - 1]?.[0];
|
|
expect(typeof resumeUpload).toBe('function');
|
|
rerender(
|
|
<AuthUiContext.Provider
|
|
value={createAuthValue({
|
|
user: {
|
|
id: 'user-1',
|
|
publicUserCode: 'U001',
|
|
displayName: '测试用户',
|
|
avatarUrl: null,
|
|
phoneNumberMasked: '138****0000',
|
|
loginMethod: 'password',
|
|
bindingStatus: 'active',
|
|
wechatBound: false,
|
|
},
|
|
canAccessProtectedData: true,
|
|
openLoginModal,
|
|
})}
|
|
>
|
|
<ImageCanvasEditorView />
|
|
</AuthUiContext.Provider>,
|
|
);
|
|
act(() => {
|
|
(resumeUpload as () => void)();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(createEditorAssetMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it('shows an uploading placeholder card before restoring the normal asset card', async () => {
|
|
const deferredAsset = createDeferred<{
|
|
assetId: string;
|
|
folderId: string;
|
|
label: string;
|
|
imageSrc: string;
|
|
objectKey?: string | null;
|
|
assetObjectId?: string | null;
|
|
width: number;
|
|
height: number;
|
|
sourceType: 'uploaded';
|
|
}>();
|
|
createEditorAssetMock.mockReturnValueOnce(deferredAsset.promise);
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
await userEvent.upload(screen.getByLabelText('上传媒体文件'), [
|
|
new File(['image'], '素材上传进度.png', { type: 'image/png' }),
|
|
]);
|
|
|
|
expect(
|
|
await screen.findByLabelText('素材素材上传进度.png上传进度'),
|
|
).toBeTruthy();
|
|
expect(
|
|
screen.getByRole('button', { name: '上传中素材上传进度.png' }),
|
|
).toBeTruthy();
|
|
|
|
deferredAsset.resolve({
|
|
assetId: 'asset-upload-progress',
|
|
folderId: 'project',
|
|
label: '素材上传进度.png',
|
|
imageSrc:
|
|
'/generated-character-drafts/editor/asset-library/image/素材上传进度.png',
|
|
objectKey:
|
|
'generated-character-drafts/editor/asset-library/image/素材上传进度.png',
|
|
assetObjectId: 'assetobj-editor-image',
|
|
width: 420,
|
|
height: 315,
|
|
sourceType: 'uploaded',
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole('button', { name: '添加素材上传进度.png' }),
|
|
).toBeTruthy();
|
|
});
|
|
expect(screen.queryByLabelText('素材素材上传进度.png上传进度')).toBeNull();
|
|
});
|
|
|
|
it('opens login when asset creation returns unauthorized during upload', async () => {
|
|
const openLoginModal = vi.fn();
|
|
createEditorAssetMock.mockRejectedValueOnce(
|
|
new ApiClientError({
|
|
message: '未授权访问',
|
|
status: 401,
|
|
code: 'UNAUTHORIZED',
|
|
}),
|
|
);
|
|
|
|
render(
|
|
<AuthUiContext.Provider
|
|
value={createAuthValue({
|
|
user: {
|
|
id: 'user-1',
|
|
publicUserCode: 'U001',
|
|
displayName: '测试用户',
|
|
avatarUrl: null,
|
|
phoneNumberMasked: '138****0000',
|
|
loginMethod: 'password',
|
|
bindingStatus: 'active',
|
|
wechatBound: false,
|
|
},
|
|
canAccessProtectedData: true,
|
|
openLoginModal,
|
|
})}
|
|
>
|
|
<ImageCanvasEditorView />
|
|
</AuthUiContext.Provider>,
|
|
);
|
|
|
|
await userEvent.upload(screen.getByLabelText('上传媒体文件'), [
|
|
new File(['image'], '过期登录.png', { type: 'image/png' }),
|
|
]);
|
|
|
|
await waitFor(() => {
|
|
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(screen.getByText('请先登录')).toBeTruthy();
|
|
});
|
|
|
|
it('supports asset selection mode and batch delete with shared toolbar', async () => {
|
|
const user = userEvent.setup();
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [
|
|
createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }),
|
|
createTestImageAsset({ assetId: 'asset-b', label: '账号素材B' }),
|
|
],
|
|
});
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
await screen.findByRole('button', { name: '添加账号素材A' });
|
|
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
|
|
await user.click(screen.getByRole('button', { name: '选择素材账号素材A' }));
|
|
|
|
const batchToolbar = screen.getByRole('toolbar', { name: '素材批量操作' });
|
|
expect(within(batchToolbar).getByText(/已选 1/u)).toBeTruthy();
|
|
await user.click(
|
|
within(batchToolbar).getByRole('button', { name: '删除' }),
|
|
);
|
|
|
|
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
|
|
expect(
|
|
screen.queryByRole('button', { name: '选择素材账号素材A' }),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('supports visible range and additive selection and clears state on exit', async () => {
|
|
const user = userEvent.setup();
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [
|
|
createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }),
|
|
createTestImageAsset({ assetId: 'asset-b', label: '账号素材B' }),
|
|
createTestImageAsset({ assetId: 'asset-c', label: '账号素材C' }),
|
|
],
|
|
});
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
await screen.findByRole('button', { name: '添加账号素材A' });
|
|
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
|
|
fireEvent.click(screen.getByText('账号素材A'));
|
|
fireEvent.click(screen.getByText('账号素材C'), {
|
|
ctrlKey: true,
|
|
});
|
|
expect(
|
|
within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByText(
|
|
/已选 2/u,
|
|
),
|
|
).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByText('账号素材B'), {
|
|
shiftKey: true,
|
|
});
|
|
expect(
|
|
within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByText(
|
|
/已选 2/u,
|
|
),
|
|
).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByText('账号素材A'), {
|
|
ctrlKey: true,
|
|
shiftKey: true,
|
|
});
|
|
expect(
|
|
within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByText(
|
|
/已选 3/u,
|
|
),
|
|
).toBeTruthy();
|
|
|
|
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
|
|
expect(screen.queryByRole('toolbar', { name: '素材批量操作' })).toBeNull();
|
|
expect(screen.getByRole('button', { name: '添加账号素材A' })).toBeTruthy();
|
|
|
|
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
|
|
expect(
|
|
within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByRole(
|
|
'button',
|
|
{ name: '删除' },
|
|
),
|
|
).toHaveProperty('disabled', true);
|
|
});
|
|
|
|
it('removes canvas layers linked to deleted assets', async () => {
|
|
const user = userEvent.setup();
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [
|
|
createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }),
|
|
createTestImageAsset({ assetId: 'asset-b', label: '账号素材B' }),
|
|
],
|
|
});
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
await user.click(
|
|
await screen.findByRole('button', { name: '添加账号素材A' }),
|
|
);
|
|
expect(await screen.findByAltText('画布图片:账号素材A')).toBeTruthy();
|
|
await user.click(screen.getByRole('button', { name: '添加账号素材B' }));
|
|
expect(await screen.findByAltText('画布图片:账号素材B')).toBeTruthy();
|
|
|
|
fireEvent.keyDown(window, { key: 'ArrowRight', code: 'ArrowRight' });
|
|
expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty(
|
|
'disabled',
|
|
false,
|
|
);
|
|
|
|
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
|
|
await user.click(
|
|
within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByRole(
|
|
'button',
|
|
{ name: '全选' },
|
|
),
|
|
);
|
|
await waitFor(() => {
|
|
expect(
|
|
within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByText(
|
|
/已选 2/u,
|
|
),
|
|
).toBeTruthy();
|
|
});
|
|
await user.click(
|
|
within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByRole(
|
|
'button',
|
|
{ name: '删除' },
|
|
),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByAltText('画布图片:账号素材A')).toBeNull();
|
|
expect(screen.queryByAltText('画布图片:账号素材B')).toBeNull();
|
|
});
|
|
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
|
|
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-b');
|
|
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
|
|
expect(screen.queryByAltText('画布图片:账号素材A')).toBeNull();
|
|
expect(screen.queryByAltText('画布图片:账号素材B')).toBeNull();
|
|
});
|
|
|
|
it('saves the canvas layout after deleting an asset linked by source resource id', async () => {
|
|
const user = userEvent.setup();
|
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-default',
|
|
title: '生成资源项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [
|
|
{
|
|
layerId: 'layer-generated-a',
|
|
resourceId: 'resource-generated-a',
|
|
title: '生成资源A',
|
|
x: 100,
|
|
y: 120,
|
|
width: 320,
|
|
height: 240,
|
|
originalWidth: 320,
|
|
originalHeight: 240,
|
|
zIndex: 1,
|
|
sourceType: 'generated',
|
|
},
|
|
],
|
|
resources: [
|
|
{
|
|
resourceId: 'resource-generated-a',
|
|
projectId: 'editor-project-default',
|
|
imageSrc: '/editor-test/project/generated-a.png',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'generated',
|
|
},
|
|
],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
});
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [
|
|
createTestImageAsset({
|
|
assetId: 'asset-generated-a',
|
|
label: '生成资源A',
|
|
imageSrc: '/editor-test/asset-library/copied-a.png',
|
|
objectKey: null,
|
|
assetObjectId: null,
|
|
sourceType: 'generated',
|
|
sourceResourceId: 'resource-generated-a',
|
|
}),
|
|
],
|
|
});
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
expect(await screen.findByAltText('画布图片:生成资源A')).toBeTruthy();
|
|
|
|
const generatedAssetButton = screen.getByRole('button', {
|
|
name: '添加生成资源A',
|
|
});
|
|
const generatedAssetRow = generatedAssetButton.closest(
|
|
'[data-asset-id="asset-generated-a"]',
|
|
);
|
|
if (!generatedAssetRow) {
|
|
throw new Error('generated asset row should exist');
|
|
}
|
|
fireEvent.contextMenu(generatedAssetRow, {
|
|
clientX: 120,
|
|
clientY: 180,
|
|
});
|
|
await user.click(screen.getByRole('menuitem', { name: '删除' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByAltText('画布图片:生成资源A')).toBeNull();
|
|
});
|
|
await waitFor(() => {
|
|
const savedLayoutWithoutDeletedLayer =
|
|
saveEditorProjectLayoutMock.mock.calls
|
|
.map((call) => call[1])
|
|
.find((layout) =>
|
|
layout.layers.every(
|
|
(item: { layerId?: unknown }) =>
|
|
item.layerId !== 'layer-generated-a',
|
|
),
|
|
);
|
|
expect(savedLayoutWithoutDeletedLayer).toBeTruthy();
|
|
});
|
|
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-generated-a');
|
|
});
|
|
|
|
it('saves a library asset layer right after creating its canvas resource', async () => {
|
|
const user = userEvent.setup();
|
|
createEditorProjectResourceMock.mockResolvedValueOnce({
|
|
resourceId: 'resource-added-asset-a',
|
|
projectId: 'editor-project-default',
|
|
imageSrc:
|
|
'/generated-character-drafts/editor/asset-library/image/asset-a.png',
|
|
objectKey:
|
|
'generated-character-drafts/editor/asset-library/image/asset-a.png',
|
|
assetObjectId: 'assetobj-asset-a',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
});
|
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-default',
|
|
title: '空画布项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
});
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [
|
|
createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }),
|
|
],
|
|
});
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
await user.click(
|
|
await screen.findByRole('button', { name: '添加账号素材A' }),
|
|
);
|
|
|
|
expect(await screen.findByAltText('画布图片:账号素材A')).toBeTruthy();
|
|
await waitFor(() => {
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
expect.objectContaining({
|
|
layers: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
title: '账号素材A',
|
|
resourceId: 'resource-added-asset-a',
|
|
sourceAssetId: 'asset-a',
|
|
}),
|
|
]),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('selects multiple assets with a marquee in asset selection mode', async () => {
|
|
const user = userEvent.setup();
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [
|
|
createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }),
|
|
createTestImageAsset({ assetId: 'asset-b', label: '账号素材B' }),
|
|
],
|
|
});
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const firstAssetButton = await screen.findByRole('button', {
|
|
name: '添加账号素材A',
|
|
});
|
|
const secondAssetButton = screen.getByRole('button', {
|
|
name: '添加账号素材B',
|
|
});
|
|
const assetList = firstAssetButton.closest(
|
|
'.image-canvas-editor__asset-list',
|
|
) as HTMLElement;
|
|
vi.spyOn(assetList, 'getBoundingClientRect').mockReturnValue({
|
|
x: 0,
|
|
y: 0,
|
|
left: 0,
|
|
top: 0,
|
|
right: 320,
|
|
bottom: 600,
|
|
width: 320,
|
|
height: 600,
|
|
toJSON: () => ({}),
|
|
});
|
|
vi.spyOn(
|
|
firstAssetButton.closest('[data-asset-id]') as HTMLElement,
|
|
'getBoundingClientRect',
|
|
).mockReturnValue({
|
|
x: 16,
|
|
y: 120,
|
|
left: 16,
|
|
top: 120,
|
|
right: 280,
|
|
bottom: 200,
|
|
width: 264,
|
|
height: 80,
|
|
toJSON: () => ({}),
|
|
});
|
|
vi.spyOn(
|
|
secondAssetButton.closest('[data-asset-id]') as HTMLElement,
|
|
'getBoundingClientRect',
|
|
).mockReturnValue({
|
|
x: 16,
|
|
y: 240,
|
|
left: 16,
|
|
top: 240,
|
|
right: 280,
|
|
bottom: 320,
|
|
width: 264,
|
|
height: 80,
|
|
toJSON: () => ({}),
|
|
});
|
|
|
|
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
|
|
dispatchPointerEvent(assetList, 'pointerdown', {
|
|
button: 0,
|
|
pointerId: 88,
|
|
clientX: 8,
|
|
clientY: 100,
|
|
});
|
|
dispatchPointerEvent(assetList, 'pointermove', {
|
|
button: 0,
|
|
pointerId: 88,
|
|
clientX: 300,
|
|
clientY: 330,
|
|
});
|
|
dispatchPointerEvent(assetList, 'pointerup', {
|
|
button: 0,
|
|
pointerId: 88,
|
|
clientX: 300,
|
|
clientY: 330,
|
|
});
|
|
|
|
const batchToolbar = screen.getByRole('toolbar', { name: '素材批量操作' });
|
|
expect(within(batchToolbar).getByText(/已选 2/u)).toBeTruthy();
|
|
});
|
|
|
|
it('drops an image file on the canvas as a new canvas layer', async () => {
|
|
render(<ImageCanvasEditorView />);
|
|
await waitFor(() => {
|
|
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
|
});
|
|
|
|
const viewport = screen.getByLabelText('画布工作区');
|
|
fireEvent.drop(viewport, {
|
|
clientX: 430,
|
|
clientY: 260,
|
|
dataTransfer: {
|
|
files: [new File(['image'], '测试上传.png', { type: 'image/png' })],
|
|
types: ['Files'],
|
|
},
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByAltText('画布图片:测试上传.png')).toBeTruthy();
|
|
});
|
|
expect(createEditorAssetMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
label: '测试上传.png',
|
|
imageSrc:
|
|
'/generated-character-drafts/editor/asset-library/image/测试上传.png',
|
|
objectKey:
|
|
'generated-character-drafts/editor/asset-library/image/测试上传.png',
|
|
assetObjectId: 'assetobj-editor-image',
|
|
}),
|
|
);
|
|
expect(screen.getByRole('heading', { name: '素材' })).toBeTruthy();
|
|
expect(
|
|
screen
|
|
.getByRole('button', { name: '打开素材' })
|
|
.getAttribute('aria-pressed'),
|
|
).toBe('true');
|
|
expect(
|
|
screen
|
|
.getByRole('button', { name: '选择测试上传.png' })
|
|
.className.includes('image-canvas-editor__layer--selected'),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('pastes a clipboard image onto the canvas through the upload workflow', async () => {
|
|
render(<ImageCanvasEditorView />);
|
|
await waitFor(() => {
|
|
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
|
});
|
|
|
|
const imageFile = new File(['image'], '剪贴板素材.png', {
|
|
type: 'image/png',
|
|
});
|
|
const pasteEvent = new Event('paste', {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
Object.defineProperty(pasteEvent, 'clipboardData', {
|
|
value: {
|
|
files: [imageFile],
|
|
items: [],
|
|
},
|
|
});
|
|
|
|
window.dispatchEvent(pasteEvent);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByAltText('画布图片:剪贴板素材.png')).toBeTruthy();
|
|
});
|
|
expect(pasteEvent.defaultPrevented).toBe(true);
|
|
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
|
imageFile,
|
|
'image',
|
|
);
|
|
expect(createEditorAssetMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
label: '剪贴板素材.png',
|
|
imageSrc:
|
|
'/generated-character-drafts/editor/asset-library/image/剪贴板素材.png',
|
|
objectKey:
|
|
'generated-character-drafts/editor/asset-library/image/剪贴板素材.png',
|
|
assetObjectId: 'assetobj-editor-image',
|
|
}),
|
|
);
|
|
expect(
|
|
screen
|
|
.getByRole('button', { name: '选择剪贴板素材.png' })
|
|
.className.includes('image-canvas-editor__layer--selected'),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('pastes a system clipboard image from the canvas context menu', async () => {
|
|
const originalClipboard = navigator.clipboard;
|
|
const clipboardBlob = new Blob(['image'], { type: 'image/png' });
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: {
|
|
read: vi.fn(async () => [
|
|
{
|
|
types: ['image/png'],
|
|
getType: vi.fn(async () => clipboardBlob),
|
|
},
|
|
]),
|
|
},
|
|
});
|
|
try {
|
|
render(<ImageCanvasEditorView />);
|
|
await waitFor(() => {
|
|
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
|
});
|
|
|
|
fireEvent.contextMenu(screen.getByLabelText('画布工作区'), {
|
|
clientX: 360,
|
|
clientY: 240,
|
|
});
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByAltText('画布图片:剪贴板图片.png')).toBeTruthy();
|
|
});
|
|
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: '剪贴板图片.png',
|
|
type: 'image/png',
|
|
}),
|
|
'image',
|
|
);
|
|
} finally {
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: originalClipboard,
|
|
});
|
|
}
|
|
});
|
|
|
|
it('keeps canvas clipboard paste ahead of system clipboard images', async () => {
|
|
render(<ImageCanvasEditorView />);
|
|
await waitFor(() => {
|
|
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
|
});
|
|
|
|
fireEvent.contextMenu(
|
|
screen.getByAltText('画布图片:拼图素材').closest('button')!,
|
|
{
|
|
clientX: 510,
|
|
clientY: 330,
|
|
},
|
|
);
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '复制' }));
|
|
|
|
const systemImageFile = new File(['image'], '系统剪贴板素材.png', {
|
|
type: 'image/png',
|
|
});
|
|
const pasteEvent = new Event('paste', {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
Object.defineProperty(pasteEvent, 'clipboardData', {
|
|
value: {
|
|
files: [systemImageFile],
|
|
items: [],
|
|
},
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(pasteEvent);
|
|
});
|
|
|
|
expect(pasteEvent.defaultPrevented).toBe(true);
|
|
await waitFor(() => {
|
|
expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2);
|
|
});
|
|
expect(screen.queryByAltText('画布图片:系统剪贴板素材.png')).toBeNull();
|
|
expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalledWith(
|
|
systemImageFile,
|
|
'image',
|
|
);
|
|
});
|
|
|
|
it('drops files into the asset panel only once without creating canvas layers', async () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
fireEvent.drop(screen.getByRole('region', { name: '项目素材' }), {
|
|
dataTransfer: {
|
|
files: [new File(['image'], '素材拖拽.png', { type: 'image/png' })],
|
|
types: ['Files'],
|
|
},
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole('button', { name: '添加素材拖拽.png' }),
|
|
).toBeTruthy();
|
|
});
|
|
expect(createEditorAssetMock).toHaveBeenCalledTimes(1);
|
|
expect(screen.queryByAltText('画布图片:素材拖拽.png')).toBeNull();
|
|
});
|
|
|
|
it('adds an asset library image to the canvas by dragging it onto the viewport', async () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const sourceAsset = await screen.findByRole('button', {
|
|
name: '添加抓大鹅素材',
|
|
});
|
|
const sourceAssetRow = sourceAsset.closest(
|
|
'.image-canvas-editor__asset-row',
|
|
);
|
|
const viewport = screen.getByLabelText('画布工作区');
|
|
const dataTransfer = createDataTransferStub();
|
|
|
|
if (!sourceAssetRow) {
|
|
throw new Error('asset row should exist');
|
|
}
|
|
fireEvent.dragStart(sourceAssetRow, { dataTransfer });
|
|
fireEvent.dragOver(viewport, {
|
|
clientX: 520,
|
|
clientY: 300,
|
|
dataTransfer,
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('添加到画布')).toBeTruthy();
|
|
});
|
|
|
|
fireEvent.drop(viewport, {
|
|
clientX: 520,
|
|
clientY: 300,
|
|
dataTransfer,
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('添加到画布')).toBeNull();
|
|
});
|
|
expect(screen.getByAltText('画布图片:抓大鹅素材')).toBeTruthy();
|
|
expect(screen.getByRole('button', { name: '选择抓大鹅素材' })).toBeTruthy();
|
|
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
expect.objectContaining({
|
|
imageSrc: '/creation-type-references/match3d.webp',
|
|
sourceType: 'uploaded',
|
|
}),
|
|
);
|
|
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps the source resource id when dragging a generated asset onto the viewport', async () => {
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [
|
|
createTestImageAsset({
|
|
assetId: 'asset-generated-character',
|
|
label: '生成角色素材',
|
|
imageSrc: '/generated/character.png',
|
|
objectKey: 'generated/character.png',
|
|
assetObjectId: 'asset-object-character',
|
|
sourceType: 'generated',
|
|
sourceResourceId: 'resource-original-character',
|
|
}),
|
|
],
|
|
});
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const sourceAsset = await screen.findByRole('button', {
|
|
name: '添加生成角色素材',
|
|
});
|
|
const sourceAssetRow = sourceAsset.closest(
|
|
'.image-canvas-editor__asset-row',
|
|
);
|
|
const viewport = screen.getByLabelText('画布工作区');
|
|
const dataTransfer = createDataTransferStub();
|
|
|
|
if (!sourceAssetRow) {
|
|
throw new Error('asset row should exist');
|
|
}
|
|
fireEvent.dragStart(sourceAssetRow, { dataTransfer });
|
|
fireEvent.drop(viewport, {
|
|
clientX: 520,
|
|
clientY: 300,
|
|
dataTransfer,
|
|
});
|
|
|
|
expect(await screen.findByAltText('画布图片:生成角色素材')).toBeTruthy();
|
|
await waitFor(() => {
|
|
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
expect.objectContaining({
|
|
imageSrc: '/generated/character.png',
|
|
sourceType: 'generated',
|
|
sourceResourceId: 'resource-original-character',
|
|
}),
|
|
);
|
|
});
|
|
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|