Files
Genarrative/src/components/image-editor/ImageCanvasEditorAssetsIntegration.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

1960 lines
63 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,
defaultEditorProjectLayers,
defaultEditorProjectResources,
dispatchPointerEvent,
ImageCanvasEditorView,
setupImageCanvasEditorViewTestLifecycle,
withEditorProjectCanvasRevision,
} 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,
folderId = 'project',
sourceType = 'uploaded',
sourceResourceId,
}: {
assetId: string;
label: string;
imageSrc?: string;
objectKey?: string | null;
assetObjectId?: string | null;
folderId?: string;
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,
label,
imageSrc: imageSrc ?? (resolvedObjectKey ? `/${resolvedObjectKey}` : ''),
objectKey: resolvedObjectKey,
assetObjectId:
assetObjectId === undefined ? `assetobj-${assetId}` : assetObjectId,
width: 320,
height: 240,
sourceType,
assetKind: null,
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('downloads a character action from the asset library as a sequence zip', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
{
assetId: 'asset-action-download',
folderId: 'project',
label: '角色挥手',
imageSrc: '/generated/action/frame01.png',
width: 192,
height: 256,
sourceType: 'generated',
assetKind: 'character-animation',
imageSequenceFrames: [
{
imageSrc: '/generated/action/frame01.png',
width: 192,
height: 256,
},
{
imageSrc: '/generated/action/frame02.png',
width: 192,
height: 256,
},
],
imageSequenceDurationMs: 4_000,
},
],
});
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(
async (..._args: Parameters<typeof fetch>) =>
new Response(new Blob(['frame'], { type: 'image/png' })),
);
globalThis.fetch = fetchMock as typeof fetch;
let downloadName = '';
const downloadCapture: { blob: Blob | null } = { blob: null };
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn((blob: Blob) => {
downloadCapture.blob = blob;
return 'blob:character-action-zip';
}),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
function click(this: HTMLAnchorElement) {
downloadName = this.download;
},
);
try {
render(<ImageCanvasEditorView />);
fireEvent.click(
await screen.findByRole('button', { name: '下载素材角色挥手' }),
);
await waitFor(() => {
expect(downloadName).toMatch(/^角色挥手-SpineJSON-\d{8}-\d{6}\.zip$/u);
});
const fetchedUrls = fetchMock.mock.calls.map(([url]) => url);
expect(fetchedUrls).toContain('/generated/action/frame01.png');
expect(fetchedUrls).toContain('/generated/action/frame02.png');
expect(downloadCapture.blob?.type).toBe('application/zip');
} 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('clears drag targets and blocks folder drops after entering asset selection mode', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
{
folderId: 'folder-role',
label: '角色',
sortOrder: 100,
collapsed: false,
systemDefault: false,
},
],
assets: [
createTestImageAsset({ assetId: 'asset-puzzle', label: '拼图素材' }),
],
});
render(<ImageCanvasEditorView />);
const roleFolder = await screen.findByRole('region', { name: '角色' });
const internalTransfer = createDataTransferStub();
internalTransfer.setData(
'application/x-genarrative-editor-asset',
'asset-puzzle',
);
fireEvent.dragOver(roleFolder, { dataTransfer: internalTransfer });
expect(roleFolder.className).toContain(
'image-canvas-editor__asset-folder--move-target',
);
fireEvent.click(screen.getByRole('button', { name: '素材选择模式' }));
await waitFor(() => {
expect(roleFolder.className).not.toContain(
'image-canvas-editor__asset-folder--move-target',
);
});
fireEvent.drop(roleFolder, { dataTransfer: internalTransfer });
const fileTransfer = createDataTransferStub();
fileTransfer.types.push('Files');
(fileTransfer.files as File[]).push(
new File(['image'], '新增素材.png', { type: 'image/png' }),
);
fireEvent.dragOver(roleFolder, { dataTransfer: fileTransfer });
fireEvent.drop(roleFolder, { dataTransfer: fileTransfer });
expect(updateEditorAssetMock).not.toHaveBeenCalled();
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('adds visible ranges to the stable 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(
/已选 3/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('keeps hidden selections stable and applies visible actions as deltas', async () => {
const user = userEvent.setup();
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
{
folderId: 'folder-other',
label: '其它素材',
sortOrder: 1,
collapsed: false,
systemDefault: false,
},
],
assets: [
createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }),
createTestImageAsset({ assetId: 'asset-b', label: '账号素材B' }),
createTestImageAsset({
assetId: 'asset-c',
label: '账号素材C',
folderId: 'folder-other',
}),
],
});
render(<ImageCanvasEditorView />);
await screen.findByRole('button', { name: '添加账号素材A' });
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
fireEvent.click(screen.getByText('账号素材C'));
fireEvent.click(screen.getByText('账号素材A'));
fireEvent.click(screen.getByText('账号素材B'), { shiftKey: true });
const toolbar = screen.getByRole('toolbar', { name: '素材批量操作' });
expect(within(toolbar).getByText(/已选 3/u)).toBeTruthy();
fireEvent.change(screen.getByLabelText('搜索素材'), {
target: { value: '账号素材A' },
});
expect(screen.queryByText('账号素材B')).toBeNull();
expect(within(toolbar).getByText(/已选 3/u)).toBeTruthy();
fireEvent.click(screen.getByText('账号素材A'));
expect(within(toolbar).getByText(/已选 2/u)).toBeTruthy();
fireEvent.click(within(toolbar).getByRole('button', { name: /^全选/u }));
expect(within(toolbar).getByText(/已选 3/u)).toBeTruthy();
fireEvent.click(
within(toolbar).getByRole('button', { name: /^取消全选/u }),
);
expect(within(toolbar).getByText(/已选 2/u)).toBeTruthy();
fireEvent.change(screen.getByLabelText('搜索素材'), {
target: { value: '' },
});
await user.click(screen.getByRole('button', { name: '折叠其它素材' }));
expect(screen.getByRole('button', { name: '展开其它素材' })).toBeTruthy();
const foldedAssetList = screen
.getByRole('button', { name: '选择素材账号素材C', hidden: true })
.closest('.image-canvas-editor__asset-folder-list');
expect(foldedAssetList).toHaveProperty('hidden', true);
expect(within(toolbar).getByText(/已选 2/u)).toBeTruthy();
await user.click(screen.getByRole('button', { name: '展开其它素材' }));
expect(foldedAssetList).toHaveProperty('hidden', false);
expect(within(toolbar).getByText(/已选 2/u)).toBeTruthy();
await user.click(screen.getByRole('button', { name: '折叠其它素材' }));
await user.click(within(toolbar).getByRole('button', { name: '删除' }));
const hiddenDeleteDialog = screen.getByRole('dialog', {
name: '删除全部已选素材',
});
expect(
within(hiddenDeleteDialog).getByText(
'将删除 2 个已选素材,其中 1 个当前未显示。',
),
).toBeTruthy();
expect(deleteEditorAssetMock).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('搜索素材'), {
target: { value: '账号素材B' },
});
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: '删除全部已选素材' }),
).toBeNull();
});
expect(deleteEditorAssetMock).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('搜索素材'), {
target: { value: '' },
});
await user.click(within(toolbar).getByRole('button', { name: '删除' }));
const refreshedHiddenDeleteDialog = screen.getByRole('dialog', {
name: '删除全部已选素材',
});
await user.click(
within(refreshedHiddenDeleteDialog).getByRole('button', {
name: '确认删除全部已选素材',
}),
);
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-b');
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-c');
expect(deleteEditorAssetMock).not.toHaveBeenCalledWith('asset-a');
});
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(
withEditorProjectCanvasRevision({
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(
withEditorProjectCanvasRevision({
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('restores a copied layout that references the same project resource after re-entering', async () => {
const firstRender = render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
saveEditorProjectLayoutMock.mockClear();
createEditorProjectResourceMock.mockClear();
fireEvent.contextMenu(
screen.getByAltText('画布图片:拼图素材').closest('button')!,
{
clientX: 510,
clientY: 330,
},
);
fireEvent.click(screen.getByRole('menuitem', { name: '复制' }));
fireEvent.contextMenu(screen.getByLabelText('画布工作区'), {
clientX: 360,
clientY: 240,
});
fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' }));
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalled();
});
const savedLayers = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1]
?.layers as Array<{ layerId: string; resourceId: string }>;
const copiedPuzzleLayouts = savedLayers.filter(
(layer) => layer.resourceId === 'resource-puzzle',
);
expect(copiedPuzzleLayouts).toHaveLength(2);
expect(
new Set(copiedPuzzleLayouts.map((layer) => layer.layerId)).size,
).toBe(2);
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
firstRender.unmount();
window.sessionStorage.clear();
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
withEditorProjectCanvasRevision(
{
projectId: 'editor-project-default',
title: '默认项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: savedLayers,
resources: defaultEditorProjectResources,
updatedAt: '2026-08-03T00:00:00.000Z',
},
1,
),
);
render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2);
});
});
it('restores different layer overrides for one shared resource after re-entering', async () => {
const sharedResourceProject = withEditorProjectCanvasRevision(
{
projectId: 'editor-project-default',
title: '共享资源标签项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
...defaultEditorProjectLayers[0],
layerId: 'layer-shared-default',
title: '共享默认角色',
resourceId: 'resource-shared-kind',
assetKindOverride: null,
},
{
...defaultEditorProjectLayers[0],
layerId: 'layer-shared-override',
title: '共享图标覆盖',
resourceId: 'resource-shared-kind',
assetKindOverride: 'icon',
x: 1140,
},
],
resources: [
{
resourceId: 'resource-shared-kind',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: 'character',
},
],
updatedAt: '2026-08-04T00:00:00.000Z',
},
1,
);
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
sharedResourceProject,
);
const firstRender = render(<ImageCanvasEditorView />);
const defaultLayer = await screen.findByRole('button', {
name: '选择共享默认角色',
});
const overrideLayer = await screen.findByRole('button', {
name: '选择共享图标覆盖',
});
expect(
within(defaultLayer).getByRole('button', { name: '素材标签:角色' }),
).toBeTruthy();
expect(
within(overrideLayer).getByRole('button', { name: '素材标签:图标' }),
).toBeTruthy();
firstRender.unmount();
window.sessionStorage.clear();
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
sharedResourceProject,
);
render(<ImageCanvasEditorView />);
const restoredDefaultLayer = await screen.findByRole('button', {
name: '选择共享默认角色',
});
const restoredOverrideLayer = await screen.findByRole('button', {
name: '选择共享图标覆盖',
});
expect(
within(restoredDefaultLayer).getByRole('button', {
name: '素材标签:角色',
}),
).toBeTruthy();
expect(
within(restoredOverrideLayer).getByRole('button', {
name: '素材标签:图标',
}),
).toBeTruthy();
});
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('drags a character action onto the canvas as a saved playable sequence', async () => {
const frames = [
{
imageSrc: '/generated/action/run-frame01.png',
width: 192,
height: 256,
},
{
imageSrc: '/generated/action/run-frame02.png',
width: 192,
height: 256,
},
];
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
{
assetId: 'asset-action-run',
folderId: 'project',
label: '角色奔跑',
imageSrc: frames[0]!.imageSrc,
width: 192,
height: 256,
sourceType: 'generated',
assetKind: 'character-animation',
imageSequenceFrames: frames,
imageSequenceDurationMs: 4_000,
},
],
});
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();
expect(
screen.getByRole('button', { name: '暂停角色奔跑序列帧播放' }),
).toBeTruthy();
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
assetKind: 'character-animation',
imageSequenceFrames: frames,
imageSequenceDurationMs: 4_000,
}),
);
});
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: '角色奔跑',
}),
]),
}),
);
});
});
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();
});
});